Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
151 changes: 151 additions & 0 deletions scripts/scaffold-cli.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
const content = await fs.readFile(srcPath, "utf-8");
const processed = replacePlaceholders(content, placeholders);
await fs.writeFile(destPath, processed, "utf-8");
};

const main = async (): Promise<void> => {
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);
});
25 changes: 25 additions & 0 deletions templates/cli-basic/CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions templates/cli-basic/README.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions templates/cli-basic/main.ts
Original file line number Diff line number Diff line change
@@ -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.");
8 changes: 7 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}