From 9cded039d32ef9a6d5a7ae82e79e29757bbfc393 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:23:15 +0200 Subject: [PATCH 1/2] feat: add CLI scaffolding tool and templates for new projects - Implement scaffolding script to create new CLI projects - Add README and CHECKLIST templates for new CLIs - Update package.json with new scaffold command --- AGENTS.md | 14 +++ package.json | 1 + scripts/scaffold-cli.ts | 151 +++++++++++++++++++++++++++++++ templates/cli-basic/CHECKLIST.md | 25 +++++ templates/cli-basic/README.md | 30 ++++++ templates/cli-basic/main.ts | 36 ++++++++ tsconfig.json | 8 +- 7 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 scripts/scaffold-cli.ts create mode 100644 templates/cli-basic/CHECKLIST.md create mode 100644 templates/cli-basic/README.md create mode 100644 templates/cli-basic/main.ts 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..d27f19e --- /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_NAME_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_NAME_TITLE__: toTitleCase(name), + __CLI_DESCRIPTION__: description, + }; + + console.log(`Scaffolding new CLI: ${name}`); + console.log(` Title: ${placeholders.__CLI_NAME_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..30431ae --- /dev/null +++ b/templates/cli-basic/README.md @@ -0,0 +1,30 @@ +# **__CLI_NAME_TITLE__** CLI + +**__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..b0cb2b0 --- /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"] } From a7c30314156dee857630f626c4e226556af416f8 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:45:25 +0200 Subject: [PATCH 2/2] refactor: update placeholder naming conventions in CLI templates - Change placeholders from double underscores to single underscores - Ensure consistency across scaffolded CLI files --- scripts/scaffold-cli.ts | 14 +++++++------- templates/cli-basic/README.md | 8 ++++---- templates/cli-basic/main.ts | 10 +++++----- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/scripts/scaffold-cli.ts b/scripts/scaffold-cli.ts index d27f19e..40f43dd 100644 --- a/scripts/scaffold-cli.ts +++ b/scripts/scaffold-cli.ts @@ -15,9 +15,9 @@ 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_NAME_TITLE__: string; - __CLI_DESCRIPTION__: string; + _CLI_NAME_: string; + _CLI_TITLE_: string; + _CLI_DESCRIPTION_: string; }; const KEBAB_CASE_REGEX = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; @@ -105,13 +105,13 @@ const main = async (): Promise => { } const placeholders: Placeholders = { - __CLI_NAME__: name, - __CLI_NAME_TITLE__: toTitleCase(name), - __CLI_DESCRIPTION__: description, + _CLI_NAME_: name, + _CLI_TITLE_: toTitleCase(name), + _CLI_DESCRIPTION_: description, }; console.log(`Scaffolding new CLI: ${name}`); - console.log(` Title: ${placeholders.__CLI_NAME_TITLE__}`); + console.log(` Title: ${placeholders._CLI_TITLE_}`); console.log(` Description: ${description}`); console.log(` Target: src/cli/${name}/`); console.log(); diff --git a/templates/cli-basic/README.md b/templates/cli-basic/README.md index 30431ae..90e312b 100644 --- a/templates/cli-basic/README.md +++ b/templates/cli-basic/README.md @@ -1,11 +1,11 @@ -# **__CLI_NAME_TITLE__** CLI +# _CLI_TITLE_ -**__CLI_DESCRIPTION__** +_CLI_DESCRIPTION_ ## Run ``` -pnpm run:__CLI_NAME__ +pnpm run:_CLI_NAME_ ``` ## Arguments @@ -14,7 +14,7 @@ pnpm run:__CLI_NAME__ ## Output -Writes under `tmp/__CLI_NAME__/`. +Writes under `tmp/_CLI_NAME_/`. ## Flowchart diff --git a/templates/cli-basic/main.ts b/templates/cli-basic/main.ts index b0cb2b0..a967611 100644 --- a/templates/cli-basic/main.ts +++ b/templates/cli-basic/main.ts @@ -1,6 +1,6 @@ -// pnpm run:__CLI_NAME__ +// pnpm run:_CLI_NAME_ -// __CLI_DESCRIPTION__ +// _CLI_DESCRIPTION_ import "dotenv/config"; @@ -10,7 +10,7 @@ import { z } from "zod"; const logger = new Logger(); -logger.info("__CLI_NAME__ running..."); +logger.info("_CLI_NAME_ running..."); // --- Parse CLI arguments --- const { verbose } = parseArgs({ @@ -30,7 +30,7 @@ if (verbose) { // 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__/ +// - Store output under tmp/_CLI_NAME_/ // - Use Zod schemas in ./types/ for data validation -logger.info("__CLI_NAME__ completed."); +logger.info("_CLI_NAME_ completed.");