docs-v2/scripts/docs-create.js

1003 lines
30 KiB
JavaScript

#!/usr/bin/env node
/**
* Documentation scaffolding tool
* Prepares context for Claude to analyze and generates file structure
*
* NOTE: This script uses the Task() function which is only available when
* executed by Claude Code. The Task() function should be globally available
* in that environment.
*/
import { parseArgs } from 'node:util';
import process from 'node:process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import yaml from 'js-yaml';
import {
prepareContext,
executeProposal,
validateProposal,
analyzeURLs,
loadProducts,
analyzeStructure,
} from './lib/content-scaffolding.js';
import { writeJson, readJson, fileExists } from './lib/file-operations.js';
import { parseMultipleURLs } from './lib/url-parser.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Repository root
const REPO_ROOT = join(__dirname, '..');
// Temp directory for context and proposal
const TMP_DIR = join(REPO_ROOT, '.tmp');
const CONTEXT_FILE = join(TMP_DIR, 'scaffold-context.json');
const PROPOSAL_FILE = join(TMP_DIR, 'scaffold-proposal.yml');
// Colors for console output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
red: '\x1b[31m',
cyan: '\x1b[36m',
};
/**
* Print colored output
*/
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
/**
* Prompt user for input (works in TTY and non-TTY environments)
*/
async function promptUser(question) {
// For non-TTY environments, return empty string
if (!process.stdin.isTTY) {
return '';
}
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
/**
* Print section divider
*/
function divider() {
log('━'.repeat(70), 'cyan');
}
/**
* Parse command line arguments
*/
function parseArguments() {
const { values, positionals } = parseArgs({
options: {
draft: { type: 'string' },
from: { type: 'string' },
url: { type: 'string', multiple: true },
urls: { type: 'string' },
products: { type: 'string' },
ai: { type: 'string', default: 'claude' },
execute: { type: 'boolean', default: false },
'context-only': { type: 'boolean', default: false },
proposal: { type: 'string' },
'dry-run': { type: 'boolean', default: false },
yes: { type: 'boolean', default: false },
help: { type: 'boolean', default: false },
},
allowPositionals: true,
});
// First positional argument is treated as draft path
if (positionals.length > 0 && !values.draft && !values.from) {
values.draft = positionals[0];
}
// --from is an alias for --draft
if (values.from && !values.draft) {
values.draft = values.from;
}
// Normalize URLs into array
if (values.urls && !values.url) {
// --urls provides comma-separated list
values.url = values.urls.split(',').map((u) => u.trim());
} else if (values.urls && values.url) {
// Combine --url and --urls
const urlsArray = values.urls.split(',').map((u) => u.trim());
values.url = [
...(Array.isArray(values.url) ? values.url : [values.url]),
...urlsArray,
];
}
return values;
}
/**
* Print usage information
*/
function printUsage() {
console.log(`
${colors.bright}Documentation Content Scaffolding${colors.reset}
${colors.bright}Usage:${colors.reset}
yarn docs:create <draft-path> Create from draft
yarn docs:create --url <url> --draft <path> Create at URL with draft content
${colors.bright}Options:${colors.reset}
<draft-path> Path to draft markdown file (positional argument)
--draft <path> Path to draft markdown file
--from <path> Alias for --draft
--url <url> Documentation URL for new content location
--context-only Stop after context preparation
(for non-Claude tools)
--proposal <path> Import and execute proposal from JSON file
--dry-run Show what would be created without creating
--yes Skip confirmation prompt
--help Show this help message
${colors.bright}Workflow (Create from draft):${colors.reset}
1. Create a draft markdown file with your content
2. Run: yarn docs:create drafts/new-feature.md
3. Script runs all agents automatically
4. Review and confirm to create files
${colors.bright}Workflow (Create at specific URL):${colors.reset}
1. Create draft: vim drafts/new-feature.md
2. Run: yarn docs:create \\
--url https://docs.influxdata.com/influxdb3/core/admin/new-feature/ \\
--draft drafts/new-feature.md
3. Script determines structure from URL and uses draft content
4. Review and confirm to create files
${colors.bright}Workflow (Manual - for non-Claude tools):${colors.reset}
1. Prepare context:
yarn docs:create --context-only drafts/new-feature.md
2. Run your AI tool with templates from scripts/templates/
3. Save proposal to .tmp/scaffold-proposal.json
4. Execute:
yarn docs:create --proposal .tmp/scaffold-proposal.json
${colors.bright}Examples:${colors.reset}
# Create from draft (AI determines location)
yarn docs:create drafts/new-feature.md
# Create at specific URL with draft content
yarn docs:create --url /influxdb3/core/admin/new-feature/ \\
--draft drafts/new-feature.md
# Preview changes
yarn docs:create --draft drafts/new-feature.md --dry-run
${colors.bright}Note:${colors.reset}
To edit existing pages, use: yarn docs:edit <url>
`);
}
/**
* Phase 1a: Prepare context from URLs
*/
async function prepareURLPhase(urls, draftPath, options) {
log('\n🔍 Analyzing URLs and finding files...', 'bright');
try {
// Parse URLs
const parsedURLs = parseMultipleURLs(urls);
log(`\n✓ Parsed ${parsedURLs.length} URL(s)`, 'green');
// Analyze URLs and find files
const urlAnalysis = analyzeURLs(parsedURLs);
// Print summary
for (const result of urlAnalysis) {
log(`\n URL: ${result.url}`);
log(` Product: ${result.parsed.product} (${result.parsed.namespace})`);
if (result.exists) {
log(` ✓ Found: ${result.files.main}`, 'green');
if (result.files.isShared) {
log(` ✓ Shared content: ${result.files.sharedSource}`, 'cyan');
log(` ✓ Found ${result.files.variants.length} variant(s)`, 'cyan');
for (const variant of result.files.variants) {
log(` - ${variant}`, 'cyan');
}
}
} else {
log(' ✗ Page does not exist (will create)', 'yellow');
log(` → Will create at: ${result.files.main}`, 'yellow');
}
}
// Determine mode
const mode = urlAnalysis.every((r) => r.exists) ? 'edit' : 'create';
log(`\n✓ Mode: ${mode}`, 'green');
// Load existing content if editing
const existingContent = {};
if (mode === 'edit') {
for (const result of urlAnalysis) {
if (result.exists) {
const fullPath = join(REPO_ROOT, result.files.main);
const content = readFileSync(fullPath, 'utf8');
existingContent[result.files.main] = content;
// Also load shared source if exists
if (result.files.isShared && result.files.sharedSource) {
const sharedPath = join(
REPO_ROOT,
`content${result.files.sharedSource}`
);
if (existsSync(sharedPath)) {
const sharedContent = readFileSync(sharedPath, 'utf8');
existingContent[`content${result.files.sharedSource}`] =
sharedContent;
}
}
}
}
}
// Build context (include URL analysis)
let context = null;
if (draftPath) {
// Use draft content if provided
context = prepareContext(draftPath);
} else {
// Minimal context for editing existing pages
const products = loadProducts();
context = {
draft: {
path: null,
content: null,
existingFrontmatter: {},
},
products,
productHints: {
mentioned: [],
suggested: [],
},
versionInfo: {
version: parsedURLs[0].namespace === 'influxdb3' ? '3.x' : '2.x',
tools: [],
apis: [],
},
structure: analyzeStructure(),
conventions: {
sharedContentDir: 'content/shared/',
menuKeyPattern: '{namespace}_{product}',
weightLevels: {
description: 'Weight ranges by level',
level1: '1-99 (top-level pages)',
level2: '101-199 (section landing pages)',
level3: '201-299 (detail pages)',
level4: '301-399 (sub-detail pages)',
},
namingRules: {
files: 'Use lowercase with hyphens (e.g., manage-databases.md)',
directories: 'Use lowercase with hyphens',
shared: 'Shared content in /content/shared/',
},
testing: {
codeblocks:
'Use pytest-codeblocks annotations for testable examples',
docker: 'Use compose.yaml services for testing code samples',
commands: '',
},
},
};
}
// Add URL analysis to context
context.mode = mode;
context.urls = urlAnalysis;
context.existingContent = existingContent;
// Write context to temp file
writeJson(CONTEXT_FILE, context);
log(
`\n✓ Prepared context → ${CONTEXT_FILE.replace(REPO_ROOT, '.')}`,
'green'
);
// If context-only mode, stop here
if (options['context-only']) {
log('');
divider();
log('Context preparation complete!', 'bright');
log('');
log('Next steps for manual workflow:', 'cyan');
log('1. Use your AI tool with prompts from scripts/templates/');
log(
'2. Generate proposal JSON matching ' +
'scripts/schemas/scaffold-proposal.schema.json'
);
log('3. Save to .tmp/scaffold-proposal.json');
log('4. Run: yarn docs:create --proposal .tmp/scaffold-proposal.json');
divider();
log('');
return null;
}
return context;
} catch (error) {
log(`\n✗ Error analyzing URLs: ${error.message}`, 'red');
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
}
}
/**
* Phase 1b: Prepare context from draft
*/
async function preparePhase(draftPath, options) {
log('\n🔍 Analyzing draft and repository structure...', 'bright');
// Validate draft exists
if (!fileExists(draftPath)) {
log(`✗ Draft file not found: ${draftPath}`, 'red');
process.exit(1);
}
try {
// Prepare context
const context = prepareContext(draftPath);
// Write context to temp file
writeJson(CONTEXT_FILE, context);
// Print summary
log(
'\n✓ Loaded draft content ' +
`(${context.draft.content.split('\n').length} lines)`,
'green'
);
log(
`✓ Analyzed ${Object.keys(context.products).length} products ` +
'from data/products.yml',
'green'
);
log(
`✓ Found ${context.structure.existingPaths.length} existing pages`,
'green'
);
log(
`✓ Prepared context → ${CONTEXT_FILE.replace(REPO_ROOT, '.')}`,
'green'
);
// If context-only mode, stop here
if (options['context-only']) {
log('');
divider();
log('Context preparation complete!', 'bright');
log('');
log('Next steps for manual workflow:', 'cyan');
log('1. Use your AI tool with prompts from scripts/templates/');
log(
'2. Generate proposal JSON matching ' +
'scripts/schemas/scaffold-proposal.schema.json'
);
log('3. Save to .tmp/scaffold-proposal.json');
log('4. Run: yarn docs:create --proposal .tmp/scaffold-proposal.json');
divider();
log('');
return null;
}
return context;
} catch (error) {
log(`\n✗ Error preparing context: ${error.message}`, 'red');
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
}
}
/**
* Select target products (interactive or from flags)
*/
async function selectProducts(context, options) {
const detected = context.productHints?.mentioned || [];
// Expand products with multiple versions into separate entries
const allProducts = [];
const productMap = {}; // Maps display name to product key
for (const [key, product] of Object.entries(context.products)) {
if (product.versions && product.versions.length > 1) {
// Multi-version product: create separate entries for each version
product.versions.forEach((version) => {
const displayName = `${product.name} ${version}`;
allProducts.push(displayName);
productMap[displayName] = key;
});
} else {
// Single version or no version info: use product name as-is
allProducts.push(product.name);
productMap[product.name] = key;
}
}
// Case 1: Explicit flag provided
if (options.products) {
const requested = options.products.split(',').map((p) => p.trim());
const invalid = requested.filter((p) => !allProducts.includes(p));
if (invalid.length > 0) {
log(
`\n✗ Invalid products: ${invalid.join(', ')}\n` +
`Valid products: ${allProducts.join(', ')}`,
'red'
);
process.exit(1);
}
log(
`✓ Using products from --products flag: ${requested.join(', ')}`,
'green'
);
return requested;
}
// Case 2: Unambiguous (single product detected)
if (detected.length === 1) {
log(`✓ Auto-selected product: ${detected[0]}`, 'green');
return detected;
}
// Case 3: URL-based (extract from URL)
if (context.urls?.length > 0) {
const urlPath = context.urls[0].url;
// Extract product from URL like /influxdb3/core/... or /influxdb/cloud/...
const match = urlPath.match(/^\/(influxdb3?\/.+?)\//);
if (match) {
const productPath = match[1].replace(/\//g, '-');
const product = allProducts.find((p) => p.includes(productPath));
if (product) {
log(`✓ Product from URL: ${product}`, 'green');
return [product];
}
}
}
// Case 4: Ambiguous or none detected - show interactive menu
log('\n📦 Select target products:\n', 'bright');
allProducts.forEach((p, i) => {
const mark = detected.includes(p) ? '✓' : ' ';
log(` ${i + 1}. [${mark}] ${p}`, 'cyan');
});
const answer = await promptUser(
'\nEnter numbers (comma-separated, e.g., 1,3,5): '
);
if (!answer) {
log('✗ No products selected', 'red');
process.exit(1);
}
const indices = answer
.split(',')
.map((s) => parseInt(s.trim()) - 1)
.filter((i) => i >= 0 && i < allProducts.length);
if (indices.length === 0) {
log('✗ No valid products selected', 'red');
process.exit(1);
}
const selected = indices.map((i) => allProducts[i]);
log(`\n✓ Selected products: ${selected.join(', ')}`, 'green');
return selected;
}
/**
* Run single content generator agent with direct file generation (Claude Code)
*/
async function runAgentsWithTaskTool(
context,
selectedProducts,
mode,
isURLBased,
hasExistingContent
) {
// Build context description
const contextDesc = `
Mode: ${mode}
${isURLBased ? `URLs: ${context.urls.length} URL(s) analyzed` : 'Draft-based workflow'}
${hasExistingContent ? `Existing content: ${Object.keys(context.existingContent).length} file(s)` : 'Creating new content'}
Target Products: ${selectedProducts.join(', ')}
`;
log(` ${contextDesc.trim().split('\n').join('\n ')}\n`, 'cyan');
log('🤖 Generating documentation files directly...', 'bright');
// Use the same prompt as manual workflow for consistency
const prompt = generateClaudePrompt(
context,
selectedProducts,
mode,
isURLBased,
hasExistingContent
);
await Task({
subagent_type: 'general-purpose',
description:
mode === 'edit'
? 'Update documentation files'
: 'Generate documentation files',
prompt: prompt,
});
log(' ✓ Files generated\n', 'green');
log(
`\n✓ Summary written to ${PROPOSAL_FILE.replace(REPO_ROOT, '.')}`,
'green'
);
}
/**
* Generate simplified Claude prompt for direct file generation
*/
function generateClaudePrompt(
context,
selectedProducts,
mode,
isURLBased,
hasExistingContent
) {
const prompt = `You are an expert InfluxData documentation writer.
**Context File**: Read from \`.tmp/scaffold-context.json\`
**Target Products**: Use \`context.selectedProducts\` field (${selectedProducts.join(', ')})
**Mode**: ${mode === 'edit' ? 'Edit existing content' : 'Create new documentation'}
${isURLBased ? `**URLs**: ${context.urls.map((u) => u.url).join(', ')}` : ''}
**Your Task**: Generate complete documentation files directly (no proposal step).
**Important**: The context file contains all products from data/products.yml, but you should ONLY create documentation for the products listed in \`context.selectedProducts\`.
**Workflow**:
1. Read and analyze \`.tmp/scaffold-context.json\`
2. ${mode === 'edit' ? 'Review existing content and plan improvements' : 'Analyze draft content to determine topic, audience, and structure'}
3. ${isURLBased ? 'Use URL paths to determine file locations' : 'Determine appropriate section (admin, write-data, query-data, etc.)'}
4. Decide if content should be shared across products
5. **Generate and write markdown files directly** using the Write tool
6. Create a summary YAML file at \`.tmp/scaffold-proposal.yml\`
**Content Requirements**:
- **Style**: Active voice, present tense, second person ("you")
- **Formatting**: Semantic line feeds (one sentence per line)
- **Headings**: Use h2-h6 only (h1 comes from title)
- **Code Examples**:
- Use ${context.versionInfo?.tools?.join(' or ') || 'influxdb3, influx, or influxctl'} CLI
- Include pytest-codeblocks annotations
- Format to fit within 80 characters
- Use long options (--option vs -o)
- Show expected output
- **Links**: Descriptive link text, no "click here"
- **Placeholders**: Use UPPERCASE for values users need to replace (e.g., DATABASE_NAME, AUTH_TOKEN)
**File Structure**:
${
selectedProducts.length > 1 || context.productHints?.isShared
? `- Content applies to multiple products:
- Create ONE shared content file in content/shared/
- Create frontmatter-only files for each product referencing it`
: `- Product-specific content:
- Create files directly in product directories`
}
**Validation Checks** (run before writing files):
1. **Path validation**: Lowercase, hyphens only (no underscores in filenames)
2. **Weight conflicts**: Check sibling pages, choose unused weight 101-199
3. **Frontmatter completeness**: All required fields present
4. **Shared content**: If multi-product, verify source paths are correct
5. **Menu structure**: Parent sections exist in product menu hierarchy
**File Generation**:
For each file you need to create:
1. **Check if file exists**: Use Read tool first (ignore errors if not found)
2. **Generate frontmatter** in YAML format with proper nesting:
\`\`\`yaml
---
title: Page Title
description: SEO-friendly description under 160 characters
menu:
product_version:
name: Nav Name
parent: section
weight: 101
related:
- /related/page/
alt_links:
other_product: /other/path/
---
\`\`\`
3. **Write full markdown content** with:
- Frontmatter (YAML block)
- Complete article content
- Code examples with proper annotations
- Proper internal links
4. **Use Write tool**: Write the complete file
- For new files: just use Write
- For existing files: Read first, then Write
**Summary File**: After generating all files, create \`.tmp/scaffold-proposal.yml\`:
\`\`\`yaml
topic: Brief description of what was created
targetProducts:
- ${selectedProducts.join('\n - ')}
section: admin | write-data | query-data | get-started | reference
isShared: ${selectedProducts.length > 1}
filesCreated:
- path: content/path/to/file.md
type: shared-content | frontmatter-only | product-specific
status: created | updated
validationResults:
pathsValid: true | false
weightsValid: true | false
frontmatterComplete: true | false
issues: []
nextSteps:
- Review generated files
- Test code examples
- Check internal links
\`\`\`
**Important**:
- Use the Write tool for ALL files (markdown and YAML summary)
- For existing files, use Read first, then Write to overwrite
- Generate COMPLETE content, not stubs or placeholders
- Run validation checks before writing each file
Begin now. Generate the files directly.
`;
return prompt;
}
/**
* Phase 2: Run AI agent analysis
* Orchestrates multiple specialized agents to analyze draft and
* generate proposal
*/
async function runAgentAnalysis(context, options) {
log('📋 Phase 2: AI Analysis\n', 'cyan');
// Detect environment and determine workflow
const isClaudeCodeEnv = typeof Task !== 'undefined';
const aiMode = options.ai || 'claude';
const useTaskTool = isClaudeCodeEnv && aiMode === 'claude';
if (useTaskTool) {
log(
'🤖 Detected Claude Code environment - running agents automatically\n',
'green'
);
} else if (aiMode === 'claude') {
log(
'📋 Claude Code environment not detected - will output prompt for copy-paste\n',
'cyan'
);
}
try {
const mode = context.mode || 'create';
const isURLBased = context.urls && context.urls.length > 0;
const hasExistingContent =
context.existingContent &&
Object.keys(context.existingContent).length > 0;
// Select target products
const selectedProducts = await selectProducts(context, options);
// Add selectedProducts to context and update the context file
context.selectedProducts = selectedProducts;
writeJson(CONTEXT_FILE, context);
log(
`✓ Updated context with selected products: ${selectedProducts.join(', ')}`,
'green'
);
// Hybrid workflow: automatic (Task tool) vs manual (prompt output)
if (useTaskTool) {
// Automatic workflow using Task tool
await runAgentsWithTaskTool(
context,
selectedProducts,
mode,
isURLBased,
hasExistingContent
);
} else {
// Manual workflow: save consolidated prompt to file
const consolidatedPrompt = generateClaudePrompt(
context,
selectedProducts,
mode,
isURLBased,
hasExistingContent
);
// Generate filename from draft or topic
const draftName = context.draft?.path
? context.draft.path.split('/').pop().replace(/\.md$/, '')
: 'untitled';
const sanitizedName = draftName
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '');
const promptDir = join(REPO_ROOT, '.context/drafts');
const promptFile = join(promptDir, `${sanitizedName}-ai-prompt.md`);
// Ensure directory exists
if (!existsSync(promptDir)) {
const fs = await import('fs');
fs.mkdirSync(promptDir, { recursive: true });
}
// Write prompt to file
const fs = await import('fs');
fs.writeFileSync(promptFile, consolidatedPrompt, 'utf8');
log('\n✅ AI prompt saved!', 'green');
log(`\n📄 File: ${promptFile.replace(REPO_ROOT, '.')}\n`, 'cyan');
log('📝 Next steps:', 'bright');
log(' 1. Open the prompt file in your editor', 'yellow');
log(' 2. Copy the entire content', 'yellow');
log(' 3. Paste into your AI tool (Claude, ChatGPT, etc.)', 'yellow');
log(
' 4. The AI will generate documentation files directly in content/',
'yellow'
);
log(' 5. Review the generated files and iterate as needed', 'yellow');
log(
` 6. Check the summary at ${PROPOSAL_FILE.replace(REPO_ROOT, '.')}`,
'yellow'
);
process.exit(0);
}
} catch (error) {
log(`\n✗ Error during AI analysis: ${error.message}`, 'red');
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
}
}
// Remove all the old agent code below - it's been replaced by the hybrid approach above
// The function now ends here
/**
* Phase 3: Execute proposal
*/
async function executePhase(options) {
log('\n📁 Phase 3: Executing Proposal\n', 'bright');
// Auto-detect proposal if not specified
let proposalPath = options.proposal || PROPOSAL_FILE;
if (!fileExists(proposalPath)) {
log(`\n✗ Proposal file not found: ${proposalPath}`, 'red');
log(
'\nRun yarn docs:create --draft <file> first to generate proposal',
'yellow'
);
process.exit(1);
}
// Read and validate proposal
const proposal = readJson(proposalPath);
try {
validateProposal(proposal);
} catch (error) {
log(`\n✗ Invalid proposal: ${error.message}`, 'red');
process.exit(1);
}
// Show preview
log('\n📋 Proposal Summary:\n', 'cyan');
log(` Topic: ${proposal.analysis.topic}`, 'cyan');
log(` Products: ${proposal.analysis.targetProducts.join(', ')}`, 'cyan');
log(` Section: ${proposal.analysis.section}`, 'cyan');
log(` Shared: ${proposal.analysis.isShared ? 'Yes' : 'No'}`, 'cyan');
if (proposal.analysis.styleReview?.issues?.length > 0) {
log(
`\n⚠️ Style Issues (${proposal.analysis.styleReview.issues.length}):`,
'yellow'
);
proposal.analysis.styleReview.issues.forEach((issue) => {
log(`${issue}`, 'yellow');
});
}
log('\n📁 Files to create:\n', 'bright');
proposal.files.forEach((file) => {
const icon = file.type === 'shared-content' ? '📄' : '📋';
const size = file.content ? ` (${file.content.length} chars)` : '';
log(` ${icon} ${file.path}${size}`, 'cyan');
});
// Dry run mode
if (options['dry-run']) {
log('\n✓ Dry run complete (no files created)', 'green');
return;
}
// Confirm unless --yes flag
if (!options.yes) {
const answer = await promptUser('\nProceed with creating files? (y/n): ');
if (answer.toLowerCase() !== 'y') {
log('✗ Cancelled by user', 'yellow');
process.exit(0);
}
}
// Execute proposal
log('\n📁 Creating files...', 'bright');
const result = executeProposal(proposal);
// Report results
if (result.created.length > 0) {
log('\n✅ Created files:', 'green');
result.created.forEach((file) => {
log(`${file}`, 'green');
});
}
if (result.errors.length > 0) {
log('\n✗ Errors:', 'red');
result.errors.forEach((err) => log(`${err}`, 'red'));
}
// Print next steps
if (result.created.length > 0) {
log('\n🎉 Done! Next steps:', 'bright');
log(' 1. Review generated frontmatter and content');
log(' 2. Test locally: npx hugo server');
log(
` 3. Test links: yarn test:links ${result.created[0].replace(/\/[^/]+$/, '/')}**/*.md`
);
log(' 4. Commit changes: git add content/ && git commit');
}
if (result.errors.length > 0) {
process.exit(1);
}
}
/**
* Main entry point
*/
async function main() {
const options = parseArguments();
// Show help
if (options.help) {
printUsage();
process.exit(0);
}
// Determine workflow
if (options.url && options.url.length > 0) {
// URL-based workflow requires draft content
if (!options.draft) {
log('\n✗ Error: --url requires --draft <path>', 'red');
log('The --url option specifies WHERE to create content.', 'yellow');
log(
'You must provide --draft to specify WHAT content to create.',
'yellow'
);
log('\nExample:', 'cyan');
log(
' yarn docs:create --url /influxdb3/core/admin/new-feature/ \\',
'cyan'
);
log(' --draft drafts/new-feature.md', 'cyan');
log('\nTo edit an existing page, use: yarn docs:edit <url>', 'cyan');
process.exit(1);
}
const context = await prepareURLPhase(options.url, options.draft, options);
if (options['context-only']) {
// Stop after context preparation
process.exit(0);
}
// Continue with AI analysis (Phase 2)
log('\n🤖 Running AI analysis with specialized agents...\n', 'bright');
await runAgentAnalysis(context, options);
// Execute proposal (Phase 3)
await executePhase(options);
} else if (options.draft) {
// Draft-based workflow
const context = await preparePhase(options.draft, options);
if (options['context-only']) {
// Stop after context preparation
process.exit(0);
}
// Continue with AI analysis (Phase 2)
log('\n🤖 Running AI analysis with specialized agents...\n', 'bright');
await runAgentAnalysis(context, options);
// Execute proposal (Phase 3)
await executePhase(options);
} else if (options.proposal) {
// Import and execute external proposal
if (!fileExists(options.proposal)) {
log(`\n✗ Proposal file not found: ${options.proposal}`, 'red');
process.exit(1);
}
// Copy proposal to expected location
const proposal = readJson(options.proposal);
writeJson(PROPOSAL_FILE, proposal);
await executePhase(options);
} else if (options.execute || options['dry-run']) {
// Legacy: Execute proposal (deprecated)
log(
'\n⚠ Warning: --execute is deprecated. Use --proposal instead.',
'yellow'
);
await executePhase(options);
} else {
// No valid options provided
log(
'Error: Must specify a docs URL (new or existing), a draft path, or --proposal',
'red'
);
log('Run with --help for usage information\n');
process.exit(1);
}
}
// Run if called directly
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
log(`\nFatal error: ${error.message}`, 'red');
console.error(error.stack);
process.exit(1);
});
}
export { preparePhase, executePhase };