diff --git a/AGENTS.md b/AGENTS.md index cb26a5a..c4debf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,20 @@ - `pnpm format` / `pnpm format:check` - `pnpm test` +**Scaffolding a new CLI:** + +``` +pnpm scaffold:cli -- --name=my-cli --description="What it does" +``` + +Creates `src/cli/my-cli/` with starter files. After scaffolding: + +1. Add `"run:my-cli": "tsx src/cli/my-cli/main.ts"` to `package.json` +2. Implement logic in `main.ts` +3. Follow the checklist in `CHECKLIST.md` + +**Rule:** When creating a new CLI, use `pnpm scaffold:cli` — don't create ad-hoc folders. + --- ## 3) Hard rules (security & repo safety) diff --git a/package.json b/package.json index 4543f75..bd0366e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "run:guestbook": "tsx src/cli/guestbook/main.ts", "run:name-explorer": "pnpm -s node:tsx -- src/cli/name-explorer/main.ts", "run:scrape-publications": "tsx src/cli/scrape-publications/main.ts", + "scaffold:cli": "pnpm -s node:tsx -- scripts/scaffold-cli.ts", "node:tsx": "node --disable-warning=ExperimentalWarning --import tsx", "typecheck": "tsc --noEmit", "lint": "eslint .", diff --git a/scripts/scaffold-cli.ts b/scripts/scaffold-cli.ts new file mode 100644 index 0000000..40f43dd --- /dev/null +++ b/scripts/scaffold-cli.ts @@ -0,0 +1,151 @@ +#!/usr/bin/env tsx + +/** + * Scaffold a new CLI from the basic template. + * + * Usage: + * pnpm scaffold:cli -- --name=my-cli --description="My CLI description" + * pnpm scaffold:cli -- --name=my-cli # description defaults to "TODO: Add description" + */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { argv } from "zx"; + +const TEMPLATE_DIR = path.join(process.cwd(), "templates", "cli-basic"); +const CLI_DIR = path.join(process.cwd(), "src", "cli"); + +type Placeholders = { + _CLI_NAME_: string; + _CLI_TITLE_: string; + _CLI_DESCRIPTION_: string; +}; + +const KEBAB_CASE_REGEX = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; + +const validateCliName = (name: string): void => { + if (!KEBAB_CASE_REGEX.test(name)) { + console.error( + `Error: CLI name "${name}" must be kebab-case (e.g., "my-cli", "scrape-data")` + ); + process.exit(1); + } + + const reserved = ["cli", "test", "tmp", "node_modules"]; + if (reserved.includes(name)) { + console.error(`Error: "${name}" is a reserved name`); + process.exit(1); + } +}; + +const checkTargetNotExists = async ( + targetDir: string, + name: string +): Promise => { + try { + await fs.access(targetDir); + console.error(`Error: CLI directory already exists: src/cli/${name}/`); + console.error("Remove it first or choose a different name."); + process.exit(1); + } catch { + // Directory doesn't exist - expected + } +}; + +const toTitleCase = (kebabName: string): string => { + return kebabName + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +}; + +const replacePlaceholders = ( + content: string, + placeholders: Placeholders +): string => { + let result = content; + for (const [placeholder, value] of Object.entries(placeholders)) { + result = result.replaceAll(placeholder, value); + } + return result; +}; + +const copyTemplateFile = async ( + srcPath: string, + destPath: string, + placeholders: Placeholders +): Promise => { + const content = await fs.readFile(srcPath, "utf-8"); + const processed = replacePlaceholders(content, placeholders); + await fs.writeFile(destPath, processed, "utf-8"); +}; + +const main = async (): Promise => { + const name = argv.name as string | undefined; + const description = + (argv.description as string | undefined) ?? "TODO: Add description"; + + if (!name) { + console.error("Error: --name is required"); + console.error( + 'Usage: pnpm scaffold:cli -- --name=my-cli --description="My description"' + ); + process.exit(1); + } + + validateCliName(name); + + const targetDir = path.join(CLI_DIR, name); + await checkTargetNotExists(targetDir, name); + + try { + await fs.access(TEMPLATE_DIR); + } catch { + console.error(`Error: Template directory not found: ${TEMPLATE_DIR}`); + process.exit(1); + } + + const placeholders: Placeholders = { + _CLI_NAME_: name, + _CLI_TITLE_: toTitleCase(name), + _CLI_DESCRIPTION_: description, + }; + + console.log(`Scaffolding new CLI: ${name}`); + console.log(` Title: ${placeholders._CLI_TITLE_}`); + console.log(` Description: ${description}`); + console.log(` Target: src/cli/${name}/`); + console.log(); + + await fs.mkdir(targetDir, { recursive: true }); + + const templateFiles = await fs.readdir(TEMPLATE_DIR); + + for (const file of templateFiles) { + const srcPath = path.join(TEMPLATE_DIR, file); + const destPath = path.join(targetDir, file); + + const stat = await fs.stat(srcPath); + if (stat.isFile()) { + await copyTemplateFile(srcPath, destPath, placeholders); + console.log(` Created: src/cli/${name}/${file}`); + } + } + + console.log(); + console.log("Done! Next steps:"); + console.log(); + console.log(` 1. Add to package.json scripts:`); + console.log(` "run:${name}": "tsx src/cli/${name}/main.ts"`); + console.log(); + console.log(` 2. Implement your CLI logic in src/cli/${name}/main.ts`); + console.log(); + console.log(` 3. Run your CLI:`); + console.log(` pnpm run:${name}`); + console.log(); + console.log(` 4. See src/cli/${name}/CHECKLIST.md for full checklist`); +}; + +main().catch((err: unknown) => { + console.error("Scaffold failed:", err); + process.exit(1); +}); diff --git a/templates/cli-basic/CHECKLIST.md b/templates/cli-basic/CHECKLIST.md new file mode 100644 index 0000000..986ce0d --- /dev/null +++ b/templates/cli-basic/CHECKLIST.md @@ -0,0 +1,25 @@ +# Post-Scaffold Checklist + +## Setup + +- [ ] Update `main.ts` with CLI logic +- [ ] Add CLI arguments to the Zod schema +- [ ] Update `README.md` description and flowchart + +## Optional Structure + +- [ ] Create `./clients/` for pipeline/client classes +- [ ] Create `./types/` for Zod schemas +- [ ] Create `./tools/` for CLI-specific agent tools + +## Before Committing + +- [ ] `pnpm typecheck` +- [ ] `pnpm lint` +- [ ] `pnpm format:check` +- [ ] Add tests if behavior is testable +- [ ] `pnpm test` + +## Cleanup + +- [ ] Delete this CHECKLIST.md when done diff --git a/templates/cli-basic/README.md b/templates/cli-basic/README.md new file mode 100644 index 0000000..90e312b --- /dev/null +++ b/templates/cli-basic/README.md @@ -0,0 +1,30 @@ +# _CLI_TITLE_ + +_CLI_DESCRIPTION_ + +## Run + +``` +pnpm run:_CLI_NAME_ +``` + +## Arguments + +- `--verbose` (optional): Enable verbose logging. + +## Output + +Writes under `tmp/_CLI_NAME_/`. + +## Flowchart + +```mermaid +flowchart TD + A["Start"] --> B["Parse args"] + B --> C["Main logic"] + C --> D["Done"] +``` + +## Notes + +- See `CHECKLIST.md` for post-scaffold setup tasks. diff --git a/templates/cli-basic/main.ts b/templates/cli-basic/main.ts new file mode 100644 index 0000000..a967611 --- /dev/null +++ b/templates/cli-basic/main.ts @@ -0,0 +1,36 @@ +// pnpm run:_CLI_NAME_ + +// _CLI_DESCRIPTION_ + +import "dotenv/config"; + +import { Logger } from "~clients/logger"; +import { parseArgs } from "~utils/parse-args"; +import { z } from "zod"; + +const logger = new Logger(); + +logger.info("_CLI_NAME_ running..."); + +// --- Parse CLI arguments --- +const { verbose } = parseArgs({ + logger, + schema: z.object({ + verbose: z.coerce.boolean().default(false), + }), +}); + +if (verbose) { + logger.debug("Verbose mode enabled"); +} + +// --- Main logic --- +// TODO: Implement your CLI logic here +// +// Common patterns: +// - Create a Pipeline class in ./clients/ for multi-step workflows +// - Use QuestionHandler from ~utils/question-handler for interactive prompts +// - Store output under tmp/_CLI_NAME_/ +// - Use Zod schemas in ./types/ for data validation + +logger.info("_CLI_NAME_ completed."); diff --git a/tsconfig.json b/tsconfig.json index c61d911..15b5eb0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,12 @@ "moduleResolution": "Bundler", "noEmit": true }, - "include": ["src", "eslint.config.ts", "prettier.config.ts"], + "include": [ + "src", + "scripts", + "templates", + "eslint.config.ts", + "prettier.config.ts" + ], "exclude": ["node_modules"] }