@wirecat/cli-template 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/changelog-guard.yml +12 -0
- package/.github/workflows/ci.yml +88 -0
- package/.github/workflows/main.yml +17 -0
- package/.github/workflows/monthly.yml +28 -0
- package/.github/workflows/pr.yml +19 -0
- package/.github/workflows/release.yml +191 -0
- package/.github/workflows/standards.yml +51 -0
- package/.gitignore +42 -0
- package/.gitleaks.toml +5 -0
- package/.markdownlint-cli2.jsonc +7 -0
- package/.vale.ini +5 -0
- package/AGENTS.md +27 -0
- package/CHANGELOG.md +19 -0
- package/CLAUDE.md +17 -0
- package/LICENSE +204 -0
- package/README.md +52 -2
- package/SECURITY.md +15 -0
- package/bin/release +139 -0
- package/biome.json +6 -0
- package/config/wirecat/LICENSE +204 -0
- package/config/wirecat/biome.base.json +140 -0
- package/config/wirecat/check.mjs +41 -0
- package/config/wirecat/cspell.json +80 -0
- package/config/wirecat/lefthook.yml +26 -0
- package/config/wirecat/manifest.json +34 -0
- package/config/wirecat/markdownlint.json +10 -0
- package/config/wirecat/no-deep-wirecat-import.grit +15 -0
- package/config/wirecat/no-exec-interpolation.grit +17 -0
- package/config/wirecat/no-new-function.grit +8 -0
- package/config/wirecat/no-shell-true.grit +8 -0
- package/config/wirecat/no-unbounded-spread.grit +10 -0
- package/config/wirecat/release-lock-info.mjs +71 -0
- package/config/wirecat/release-lock.sh +34 -0
- package/config/wirecat/rumdl.toml +9 -0
- package/contracts/help.snap +16 -0
- package/contracts/mcp-tools.json +922 -0
- package/contracts/no-leak.json +3 -0
- package/contracts/plan.json +50 -0
- package/cspell.json +10 -0
- package/dist/bin/app.d.ts +3 -0
- package/dist/bin/app.d.ts.map +1 -0
- package/dist/bin/app.js +14 -0
- package/dist/bin/app.js.map +1 -0
- package/dist/commands/doctor.d.ts +23 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +44 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/mcp.d.ts +3 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +36 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -0
- package/dist/identity.d.ts +5 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +12 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +62 -0
- package/dist/mcp.js.map +1 -0
- package/dist/program.d.ts +6 -0
- package/dist/program.d.ts.map +1 -0
- package/dist/program.js +37 -0
- package/dist/program.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/docs/commands.md +227 -0
- package/docs/configuration.md +18 -0
- package/docs/dev/DESIGN.md +13 -0
- package/docs/dev/TESTING.md +16 -0
- package/docs/dev/design-questions.md +24 -0
- package/docs/dev/reference-clis.md +15 -0
- package/docs/dev/skills/design-interview/SKILL.md +34 -0
- package/docs/dev/skills/release/SKILL.md +24 -0
- package/docs/dev/test-matrix.md +52 -0
- package/docs/index.md +7 -0
- package/docs/installation.md +17 -0
- package/docs/meta.json +18 -0
- package/docs/security.md +8 -0
- package/docs/troubleshooting.md +14 -0
- package/docs/usage.md +31 -0
- package/kit/README.md +123 -0
- package/kit/dist/interview.js +170 -0
- package/kit/dist/main.js +70 -0
- package/kit/dist/manifest.js +111 -0
- package/kit/dist/new.js +249 -0
- package/kit/dist/rename.js +35 -0
- package/kit/dist/source.js +74 -0
- package/kit/dist/upgrade.js +223 -0
- package/kit/tsconfig.json +17 -0
- package/kit/tsconfig.test.json +5 -0
- package/lefthook.yml +2 -0
- package/lint/baseline/.gitkeep +0 -0
- package/lint/overrides.json +1 -0
- package/package.json +105 -4
- package/pnpm-workspace.yaml +6 -0
- package/scripts/audit.sh +22 -0
- package/scripts/command-program.ts +1 -0
- package/scripts/commands.ts +39 -0
- package/scripts/smoke.ts +56 -0
- package/scripts/test-matrix-untested.ts +12 -0
- package/skills/app-cli/SKILL.md +21 -0
- package/src/bin/app.ts +13 -0
- package/src/commands/doctor.ts +45 -0
- package/src/commands/mcp.ts +38 -0
- package/src/config.ts +4 -0
- package/src/identity.ts +15 -0
- package/src/index.ts +2 -0
- package/src/mcp.test.ts +84 -0
- package/src/mcp.ts +82 -0
- package/src/program.test.ts +66 -0
- package/src/program.ts +53 -0
- package/src/roots.test.ts +80 -0
- package/src/testing/argv-log.ts +1 -0
- package/src/testing/sandbox.ts +25 -0
- package/src/version.ts +1 -0
- package/template.json +40 -0
- package/tsconfig.json +9 -0
- package/tsconfig.scripts.json +1 -0
- package/tsconfig.test.json +1 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const npmName = /^(@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
2
|
+
const commandWord = /^[a-z][a-z0-9-]*$/;
|
|
3
|
+
const githubOwner = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
|
|
4
|
+
const languageCode = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
5
|
+
export const NAME_QUESTIONS = [
|
|
6
|
+
{
|
|
7
|
+
key: "command",
|
|
8
|
+
flag: "command",
|
|
9
|
+
text: "The command word people type (lowercase, e.g. cal)",
|
|
10
|
+
check: (value) => commandWord.test(value) ? undefined : "lowercase letters, digits and dashes, starting with a letter",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
key: "package",
|
|
14
|
+
flag: "package",
|
|
15
|
+
text: "The npm package name (e.g. @wirecat/cal-cli)",
|
|
16
|
+
check: (value) => (npmName.test(value) ? undefined : "a valid npm package name, scoped or not"),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
key: "owner",
|
|
20
|
+
flag: "owner",
|
|
21
|
+
text: "The GitHub owner of the repository (e.g. WireCatLabs)",
|
|
22
|
+
check: (value) => (githubOwner.test(value) ? undefined : "a GitHub user or organisation name"),
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
export const INTERVIEW = [
|
|
26
|
+
{
|
|
27
|
+
key: "category",
|
|
28
|
+
title: "Category",
|
|
29
|
+
flag: "category",
|
|
30
|
+
text: "What kind of tool is it?",
|
|
31
|
+
choices: [
|
|
32
|
+
{ value: "messenger", label: "messenger" },
|
|
33
|
+
{ value: "mail", label: "mail" },
|
|
34
|
+
{ value: "notes", label: "notes" },
|
|
35
|
+
{ value: "files", label: "files and documents" },
|
|
36
|
+
{ value: "calendar", label: "calendar" },
|
|
37
|
+
{ value: "meetings", label: "meetings" },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: "contentLang",
|
|
42
|
+
title: "Content languages",
|
|
43
|
+
flag: "content-lang",
|
|
44
|
+
text: "Which languages is the content in? Language codes, several allowed (e.g. en,ru)",
|
|
45
|
+
multiple: true,
|
|
46
|
+
check: (value) => (languageCode.test(value) ? undefined : "a language code such as en, ru or pt-BR"),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
key: "signIn",
|
|
50
|
+
title: "Sign-in",
|
|
51
|
+
flag: "sign-in",
|
|
52
|
+
text: "How does a person sign in?",
|
|
53
|
+
choices: [
|
|
54
|
+
{ value: "oauth", label: "OAuth in the browser" },
|
|
55
|
+
{ value: "token", label: "an API token or key" },
|
|
56
|
+
{ value: "session", label: "a session (phone code, QR)" },
|
|
57
|
+
{ value: "none", label: "none" },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "api",
|
|
62
|
+
title: "The API",
|
|
63
|
+
flag: "api",
|
|
64
|
+
text: "What does the service offer to build on?",
|
|
65
|
+
choices: [
|
|
66
|
+
{ value: "official-client", label: "an official API with an official client library" },
|
|
67
|
+
{ value: "official-docs", label: "official API documentation only" },
|
|
68
|
+
{ value: "unofficial", label: "nothing official" },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "filters",
|
|
73
|
+
title: "How people filter",
|
|
74
|
+
flag: "filters",
|
|
75
|
+
text: "How do people filter what they find? Several allowed",
|
|
76
|
+
multiple: true,
|
|
77
|
+
choices: [
|
|
78
|
+
{ value: "time", label: "by time" },
|
|
79
|
+
{ value: "person", label: "by person" },
|
|
80
|
+
{ value: "container", label: "by container (chat, folder, calendar)" },
|
|
81
|
+
{ value: "text", label: "by words in the text" },
|
|
82
|
+
{ value: "status", label: "by status or label" },
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
key: "neverStore",
|
|
87
|
+
title: "Never in the store, a log or the output",
|
|
88
|
+
flag: "never-store",
|
|
89
|
+
text: "What must never reach the store, a log or the output? Several allowed",
|
|
90
|
+
multiple: true,
|
|
91
|
+
choices: [
|
|
92
|
+
{ value: "tokens", label: "tokens and passwords" },
|
|
93
|
+
{ value: "codes", label: "one-time codes and passcodes" },
|
|
94
|
+
{ value: "secret-links", label: "links that carry a secret" },
|
|
95
|
+
{ value: "content", label: "the full content, keep only metadata" },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
key: "untrusted",
|
|
100
|
+
title: "Untrusted input",
|
|
101
|
+
flag: "untrusted",
|
|
102
|
+
text: "What untrusted input arrives? Several allowed",
|
|
103
|
+
multiple: true,
|
|
104
|
+
exclusive: "none",
|
|
105
|
+
choices: [
|
|
106
|
+
{ value: "messages", label: "messages from other people" },
|
|
107
|
+
{ value: "files", label: "files and attachments" },
|
|
108
|
+
{ value: "links", label: "links" },
|
|
109
|
+
{ value: "names", label: "names and titles set by others" },
|
|
110
|
+
{ value: "none", label: "none" },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
const OTHER = "other:";
|
|
115
|
+
export const choiceList = (question) => question.choices
|
|
116
|
+
? `${question.choices.map((choice) => choice.value).join(", ")}, or ${OTHER}<your answer>`
|
|
117
|
+
: (question.check?.("") ?? "");
|
|
118
|
+
const checkOne = (question, value) => {
|
|
119
|
+
if (value.length === 0)
|
|
120
|
+
return "empty answer";
|
|
121
|
+
if (!question.choices)
|
|
122
|
+
return question.check?.(value);
|
|
123
|
+
if (value.startsWith(OTHER))
|
|
124
|
+
return value.length > OTHER.length ? undefined : `write the answer after "${OTHER}"`;
|
|
125
|
+
return question.choices.some((choice) => choice.value === value) ? undefined : `"${value}" is not one of the choices`;
|
|
126
|
+
};
|
|
127
|
+
export const parseAnswer = (question, raw) => {
|
|
128
|
+
const parts = question.multiple ? raw.split(",").map((part) => part.trim()) : [raw.trim()];
|
|
129
|
+
for (const part of parts) {
|
|
130
|
+
const problem = checkOne(question, part);
|
|
131
|
+
if (problem)
|
|
132
|
+
return { error: `--${question.flag}: ${problem}; expected ${choiceList(question)}` };
|
|
133
|
+
}
|
|
134
|
+
if (question.exclusive && parts.length > 1 && parts.includes(question.exclusive)) {
|
|
135
|
+
return { error: `--${question.flag}: "${question.exclusive}" cannot be combined with other answers` };
|
|
136
|
+
}
|
|
137
|
+
return { value: [...new Set(parts)] };
|
|
138
|
+
};
|
|
139
|
+
export const labelOf = (question, value) => value.startsWith(OTHER)
|
|
140
|
+
? `other: ${value.slice(OTHER.length)}`
|
|
141
|
+
: (question.choices?.find((choice) => choice.value === value)?.label ?? value);
|
|
142
|
+
const menu = (question) => [
|
|
143
|
+
...(question.choices ?? []).map((choice, index) => ` ${index + 1}. ${choice.label}`),
|
|
144
|
+
` ${(question.choices?.length ?? 0) + 1}. other (type it)`,
|
|
145
|
+
].join("\n");
|
|
146
|
+
const fromMenu = async (rl, question, line) => {
|
|
147
|
+
const choices = question.choices ?? [];
|
|
148
|
+
const picked = [];
|
|
149
|
+
for (const part of line.split(",")) {
|
|
150
|
+
const index = Number(part.trim()) - 1;
|
|
151
|
+
if (index === choices.length)
|
|
152
|
+
picked.push(`${OTHER}${(await rl.question(" Other: ")).trim()}`);
|
|
153
|
+
else
|
|
154
|
+
picked.push(choices[index]?.value ?? part.trim());
|
|
155
|
+
}
|
|
156
|
+
return picked.join(",");
|
|
157
|
+
};
|
|
158
|
+
export const ask = async (rl, output, question) => {
|
|
159
|
+
for (;;) {
|
|
160
|
+
const prompt = question.choices
|
|
161
|
+
? `${question.text}\n${menu(question)}\n${question.multiple ? "Numbers, comma-separated: " : "Number: "}`
|
|
162
|
+
: `${question.text}: `;
|
|
163
|
+
const line = (await rl.question(prompt)).trim();
|
|
164
|
+
const raw = question.choices && /^[\d,\s]+$/.test(line) ? await fromMenu(rl, question, line) : line;
|
|
165
|
+
const { value, error } = parseAnswer(question, raw);
|
|
166
|
+
if (value)
|
|
167
|
+
return value;
|
|
168
|
+
output.write(`${error}\n`);
|
|
169
|
+
}
|
|
170
|
+
};
|
package/kit/dist/main.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { INTERVIEW, NAME_QUESTIONS } from "./interview.js";
|
|
4
|
+
import { runNew } from "./new.js";
|
|
5
|
+
import { fetchPublished, loadSource, ownRoot } from "./source.js";
|
|
6
|
+
import { parseMode, runUpgrade } from "./upgrade.js";
|
|
7
|
+
const USAGE = `Usage:
|
|
8
|
+
npx @wirecat/cli-template new <name> [answers] copy the template into ./<name>
|
|
9
|
+
npx @wirecat/cli-template upgrade [--apply | --replace | --skip] [--dry-run]
|
|
10
|
+
|
|
11
|
+
new asks for every missing answer in a terminal; without one, a missing answer is an error.
|
|
12
|
+
--command <word> the command word people type
|
|
13
|
+
--package <name> the npm package name
|
|
14
|
+
--owner <owner> the GitHub owner of the repository
|
|
15
|
+
${INTERVIEW.map((question) => ` --${question.flag.padEnd(20)}${question.title ?? question.text}`).join("\n")}
|
|
16
|
+
Any interview answer also takes "other:<your answer>"; several answers are comma-separated.
|
|
17
|
+
|
|
18
|
+
upgrade brings template changes into a CLI made from it; it needs a clean git working tree.
|
|
19
|
+
--apply merge the template's change into every changed file (conflicts get git markers)
|
|
20
|
+
--replace take the template's new file as it is
|
|
21
|
+
--skip keep this CLI's files
|
|
22
|
+
--dry-run list the files and what each would get; write nothing
|
|
23
|
+
`;
|
|
24
|
+
const answerFlags = Object.fromEntries([...NAME_QUESTIONS, ...INTERVIEW].map((question) => [question.flag, { type: "string" }]));
|
|
25
|
+
const main = async (argv, io) => {
|
|
26
|
+
const [command, ...rest] = argv;
|
|
27
|
+
if (!command || command === "--help" || command === "-h")
|
|
28
|
+
return USAGE;
|
|
29
|
+
const source = await loadSource(ownRoot());
|
|
30
|
+
if (command === "--version")
|
|
31
|
+
return source.version;
|
|
32
|
+
if (command === "new") {
|
|
33
|
+
const { values, positionals } = parseArgs({
|
|
34
|
+
args: rest,
|
|
35
|
+
options: answerFlags,
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
strict: true,
|
|
38
|
+
});
|
|
39
|
+
if (positionals.length > 1)
|
|
40
|
+
throw new Error(`new takes one folder name, got ${positionals.join(" ")}`);
|
|
41
|
+
return runNew(process.cwd(), source, { name: positionals[0], flags: values }, io);
|
|
42
|
+
}
|
|
43
|
+
if (command === "upgrade") {
|
|
44
|
+
const { values, positionals } = parseArgs({
|
|
45
|
+
args: rest,
|
|
46
|
+
options: {
|
|
47
|
+
apply: { type: "boolean" },
|
|
48
|
+
replace: { type: "boolean" },
|
|
49
|
+
skip: { type: "boolean" },
|
|
50
|
+
"dry-run": { type: "boolean" },
|
|
51
|
+
},
|
|
52
|
+
strict: true,
|
|
53
|
+
});
|
|
54
|
+
if (positionals.length)
|
|
55
|
+
throw new Error(`upgrade takes no arguments, got ${positionals.join(" ")}`);
|
|
56
|
+
return runUpgrade(process.cwd(), source, fetchPublished, { mode: parseMode(values), dryRun: values["dry-run"] ?? false }, io);
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`unknown command "${command}"; expected new or upgrade\n\n${USAGE}`);
|
|
59
|
+
};
|
|
60
|
+
const io = {
|
|
61
|
+
input: process.stdin,
|
|
62
|
+
output: process.stdout,
|
|
63
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
64
|
+
};
|
|
65
|
+
main(process.argv.slice(2), io).then((text) => {
|
|
66
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
67
|
+
}, (error) => {
|
|
68
|
+
process.stderr.write(`cli-template: ${error.message}\n`);
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export const MANIFEST = "template.json";
|
|
5
|
+
export const DEFAULT_PLACEHOLDERS = {
|
|
6
|
+
command: "app",
|
|
7
|
+
name: "app-cli",
|
|
8
|
+
package: "@wirecat/app-cli",
|
|
9
|
+
repository: "WireCatLabs/app-cli",
|
|
10
|
+
};
|
|
11
|
+
// package.json and the interview's answers belong to the CLI from the first copy.
|
|
12
|
+
const ALWAYS_SEEDS = ["package.json", "docs/dev/DESIGN.md"];
|
|
13
|
+
// The copy tool and the template's own manifest are never part of a CLI, whatever template.json lists.
|
|
14
|
+
const NEVER_COPIED = [MANIFEST, "kit/"];
|
|
15
|
+
export const sha256 = (content) => createHash("sha256").update(content).digest("hex");
|
|
16
|
+
const fail = (file, problem) => {
|
|
17
|
+
throw new Error(`${file}: ${problem}`);
|
|
18
|
+
};
|
|
19
|
+
const isStringList = (value) => Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0);
|
|
20
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
const checkEntries = (file, key, entries) => {
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (entry.startsWith("/") || entry.split("/").includes(".."))
|
|
24
|
+
fail(file, `"${key}" entry "${entry}" must be relative`);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
export const parseTemplateManifest = (file, raw) => {
|
|
28
|
+
if (!isRecord(raw))
|
|
29
|
+
return fail(file, "must be a JSON object");
|
|
30
|
+
if (!isStringList(raw.files))
|
|
31
|
+
return fail(file, `"files" must be a list of paths`);
|
|
32
|
+
if (!isStringList(raw.seeds))
|
|
33
|
+
return fail(file, `"seeds" must be a list of paths`);
|
|
34
|
+
checkEntries(file, "files", raw.files);
|
|
35
|
+
checkEntries(file, "seeds", raw.seeds);
|
|
36
|
+
const placeholders = raw.placeholders ?? {};
|
|
37
|
+
if (!isRecord(placeholders))
|
|
38
|
+
return fail(file, `"placeholders" must be an object`);
|
|
39
|
+
for (const [key, value] of Object.entries(placeholders)) {
|
|
40
|
+
if (!(key in DEFAULT_PLACEHOLDERS))
|
|
41
|
+
fail(file, `unknown placeholder "${key}"`);
|
|
42
|
+
if (typeof value !== "string" || value.length === 0)
|
|
43
|
+
fail(file, `placeholder "${key}" must be a string`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
files: raw.files,
|
|
47
|
+
seeds: raw.seeds,
|
|
48
|
+
placeholders: { ...DEFAULT_PLACEHOLDERS, ...placeholders },
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export const parseCliManifest = (file, raw) => {
|
|
52
|
+
if (!isRecord(raw))
|
|
53
|
+
return fail(file, "must be a JSON object");
|
|
54
|
+
const { template, version, names, files, seeds, pending } = raw;
|
|
55
|
+
if (typeof template !== "string")
|
|
56
|
+
return fail(file, `"template" must be the template's package name`);
|
|
57
|
+
if (typeof version !== "string")
|
|
58
|
+
return fail(file, `"version" must be the template version this CLI is on`);
|
|
59
|
+
if (!isRecord(names) || !["name", "command", "package", "owner"].every((key) => typeof names[key] === "string")) {
|
|
60
|
+
return fail(file, `"names" must hold name, command, package and owner`);
|
|
61
|
+
}
|
|
62
|
+
if (!isRecord(files) || !Object.values(files).every((hash) => typeof hash === "string")) {
|
|
63
|
+
return fail(file, `"files" must map each template-owned path to its sha256`);
|
|
64
|
+
}
|
|
65
|
+
if (!isStringList(seeds))
|
|
66
|
+
return fail(file, `"seeds" must be a list of paths`);
|
|
67
|
+
if (pending !== undefined &&
|
|
68
|
+
(!isRecord(pending) ||
|
|
69
|
+
typeof pending.version !== "string" ||
|
|
70
|
+
!isRecord(pending.files) ||
|
|
71
|
+
!isStringList(pending.conflicts))) {
|
|
72
|
+
return fail(file, `"pending" must hold version, files and conflicts`);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
template,
|
|
76
|
+
version,
|
|
77
|
+
names: names,
|
|
78
|
+
files: files,
|
|
79
|
+
seeds,
|
|
80
|
+
...(pending ? { pending: pending } : {}),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
export const readJson = async (path) => {
|
|
84
|
+
const text = await readFile(path, "utf8").catch(() => fail(path, "not found"));
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(text);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return fail(path, "is not valid JSON");
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
export const readCliManifest = async (dir) => {
|
|
93
|
+
const path = join(dir, MANIFEST);
|
|
94
|
+
return parseCliManifest(path, await readJson(path));
|
|
95
|
+
};
|
|
96
|
+
const matches = (entry, path) => entry.endsWith("/") ? path.startsWith(entry) : path === entry;
|
|
97
|
+
// An exact path beats a folder, and a deeper folder beats a shallower one, so a seed folder can hold
|
|
98
|
+
// one template-owned file and the other way round.
|
|
99
|
+
const specificity = (entry) => (entry.endsWith("/") ? entry.length : Number.MAX_SAFE_INTEGER);
|
|
100
|
+
export const classify = (path, files, seeds) => {
|
|
101
|
+
if (NEVER_COPIED.some((entry) => matches(entry, path)))
|
|
102
|
+
return undefined;
|
|
103
|
+
if (ALWAYS_SEEDS.includes(path))
|
|
104
|
+
return "seed";
|
|
105
|
+
const best = (entries) => entries.reduce((top, entry) => (matches(entry, path) ? Math.max(top, specificity(entry)) : top), -1);
|
|
106
|
+
const file = best(files);
|
|
107
|
+
const seed = best(seeds);
|
|
108
|
+
if (file < 0 && seed < 0)
|
|
109
|
+
return undefined;
|
|
110
|
+
return seed >= file ? "seed" : "file";
|
|
111
|
+
};
|
package/kit/dist/new.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { chmod, mkdir, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { ask, choiceList, INTERVIEW, labelOf, NAME_QUESTIONS, parseAnswer } from "./interview.js";
|
|
7
|
+
import { MANIFEST, sha256 } from "./manifest.js";
|
|
8
|
+
import { renderTemplate } from "./source.js";
|
|
9
|
+
const run = promisify(execFile);
|
|
10
|
+
const REFERENCES = {
|
|
11
|
+
messenger: ["whatsapp-cli", "tg-cli", "max-cli"],
|
|
12
|
+
files: ["drive-cli"],
|
|
13
|
+
meetings: ["zoom-cli"],
|
|
14
|
+
notes: ["cli-memo"],
|
|
15
|
+
};
|
|
16
|
+
const STEMMED = ["en", "ru", "es"];
|
|
17
|
+
// Parakeet v3's languages, from cli-core's speech model list.
|
|
18
|
+
const PARAKEET = "bg cs da de el en es et fi fr hr hu it lt lv mt nl pl pt ro ru sk sl sv uk".split(" ");
|
|
19
|
+
const baseLang = (code) => code.split("-")[0] ?? code;
|
|
20
|
+
export const speechModels = (languages) => {
|
|
21
|
+
const bases = languages.map(baseLang);
|
|
22
|
+
if (bases.length > 0 && bases.every((lang) => lang === "ru"))
|
|
23
|
+
return ["gigaam-v3", "gigaam-v3-ctc", "parakeet-v3"];
|
|
24
|
+
if (bases.length > 0 && bases.every((lang) => PARAKEET.includes(lang)))
|
|
25
|
+
return ["parakeet-v3"];
|
|
26
|
+
return undefined;
|
|
27
|
+
};
|
|
28
|
+
export const languageWarnings = (languages) => languages.flatMap((lang) => [
|
|
29
|
+
...(STEMMED.includes(baseLang(lang))
|
|
30
|
+
? []
|
|
31
|
+
: [`${lang}: cli-core has no search stemmer for it; search matches whole words only`]),
|
|
32
|
+
...(PARAKEET.includes(baseLang(lang)) ? [] : [`${lang}: no speech model in cli-core hears it`]),
|
|
33
|
+
]);
|
|
34
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
35
|
+
const NAME_QUESTION = {
|
|
36
|
+
key: "name",
|
|
37
|
+
flag: "name",
|
|
38
|
+
text: "The folder (and repository) name, e.g. cal-cli",
|
|
39
|
+
check: (value) => (NAME_PATTERN.test(value) ? undefined : "lowercase letters, digits, dots and dashes"),
|
|
40
|
+
};
|
|
41
|
+
const collect = async (args, io) => {
|
|
42
|
+
const questions = [NAME_QUESTION, ...NAME_QUESTIONS, ...INTERVIEW];
|
|
43
|
+
const given = { ...args.flags, name: args.name };
|
|
44
|
+
const answers = {};
|
|
45
|
+
const missing = [];
|
|
46
|
+
for (const question of questions) {
|
|
47
|
+
const raw = given[question.flag];
|
|
48
|
+
if (raw === undefined) {
|
|
49
|
+
missing.push(question);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const { value, error } = parseAnswer(question, raw);
|
|
53
|
+
if (error)
|
|
54
|
+
throw new Error(question === NAME_QUESTION ? `<name>: ${error.replace(/^--name: /, "")}` : error);
|
|
55
|
+
answers[question.key] = value ?? [];
|
|
56
|
+
}
|
|
57
|
+
if (missing.length > 0 && !io.interactive) {
|
|
58
|
+
const lines = missing.map((question) => question === NAME_QUESTION ? " <name>: the folder to create" : ` --${question.flag}: ${choiceList(question)}`);
|
|
59
|
+
throw new Error(`no terminal to ask in, and these answers are missing:\n${lines.join("\n")}`);
|
|
60
|
+
}
|
|
61
|
+
if (missing.length > 0) {
|
|
62
|
+
const rl = createInterface({ input: io.input, output: io.output, terminal: false });
|
|
63
|
+
try {
|
|
64
|
+
for (const question of missing)
|
|
65
|
+
answers[question.key] = await ask(rl, io.output, question);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
rl.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const one = (key) => answers[key]?.[0] ?? "";
|
|
72
|
+
return {
|
|
73
|
+
names: { name: one("name"), command: one("command"), package: one("package"), owner: one("owner") },
|
|
74
|
+
answers,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
const isEmptyOrMissing = async (dir) => readdir(dir).then((entries) => entries.length === 0, () => true);
|
|
78
|
+
const referenceLine = (category) => {
|
|
79
|
+
const refs = REFERENCES[category];
|
|
80
|
+
if (!refs) {
|
|
81
|
+
return "No reference CLI for this category yet: pick the nearest one with the owner from the Reference CLIs page.";
|
|
82
|
+
}
|
|
83
|
+
const [first, ...rest] = refs;
|
|
84
|
+
const link = (repo) => `[${repo}](https://github.com/WireCatLabs/${repo})`;
|
|
85
|
+
return `Learn from ${link(first ?? "")}${rest.length ? `, then ${rest.map(link).join(" and ")}` : ""}.`;
|
|
86
|
+
};
|
|
87
|
+
const START = "<!-- cli-template:reference-cli:start -->";
|
|
88
|
+
const END = "<!-- cli-template:reference-cli:end -->";
|
|
89
|
+
export const withReference = (agents, category) => {
|
|
90
|
+
const block = `${START}\n## Reference CLI\n\n${referenceLine(category)}\n${END}`;
|
|
91
|
+
const start = agents.indexOf(START);
|
|
92
|
+
const end = agents.indexOf(END);
|
|
93
|
+
if (start >= 0 && end > start)
|
|
94
|
+
return agents.slice(0, start) + block + agents.slice(end + END.length);
|
|
95
|
+
return `${agents.replace(/\n*$/, "")}\n\n${block}\n`;
|
|
96
|
+
};
|
|
97
|
+
export const designDoc = (names, answers) => {
|
|
98
|
+
const rows = INTERVIEW.map((question) => `- **${question.title ?? question.text}:** ${(answers[question.key] ?? []).map((value) => labelOf(question, value)).join(", ")}`);
|
|
99
|
+
const languages = answers.contentLang ?? [];
|
|
100
|
+
const speech = speechModels(languages);
|
|
101
|
+
const warnings = languageWarnings(languages);
|
|
102
|
+
return [
|
|
103
|
+
`# ${names.command} — design`,
|
|
104
|
+
"",
|
|
105
|
+
`Written by \`npx @wirecat/cli-template new\`. The agent reads it first, asks the further questions the reference CLI`,
|
|
106
|
+
"does not answer, and starts building only after the owner has read it.",
|
|
107
|
+
"",
|
|
108
|
+
"## Answers",
|
|
109
|
+
"",
|
|
110
|
+
...rows,
|
|
111
|
+
"",
|
|
112
|
+
"## Reference CLI",
|
|
113
|
+
"",
|
|
114
|
+
referenceLine(answers.category?.[0] ?? ""),
|
|
115
|
+
"",
|
|
116
|
+
"## Content languages",
|
|
117
|
+
"",
|
|
118
|
+
`Languages: ${languages.join(", ")}.`,
|
|
119
|
+
speech
|
|
120
|
+
? `If this CLI defines a messenger, set its \`speechModels\` to ${speech.map((model) => `\`${model}\``).join(", ")}.`
|
|
121
|
+
: "No speech model in cli-core covers all of these languages: leave `speechModels` out.",
|
|
122
|
+
...warnings.map((warning) => `- ${warning}`),
|
|
123
|
+
"",
|
|
124
|
+
"## Further answers",
|
|
125
|
+
"",
|
|
126
|
+
"## Open questions for the owner",
|
|
127
|
+
"",
|
|
128
|
+
].join("\n");
|
|
129
|
+
};
|
|
130
|
+
const removeKit = (command) => command
|
|
131
|
+
.split(" && ")
|
|
132
|
+
.filter((part) => !/\bkit[:/]/.test(part))
|
|
133
|
+
.join(" && ");
|
|
134
|
+
export const cliPackageJson = (text, names) => {
|
|
135
|
+
const pkg = JSON.parse(text);
|
|
136
|
+
const isKit = (value) => typeof value === "string" && /^(\.\/)?kit(\/|$)/.test(value);
|
|
137
|
+
const repoUrl = `git+https://github.com/${names.owner}/${names.name}.git`;
|
|
138
|
+
const next = { ...pkg, name: names.package, version: "0.0.0" };
|
|
139
|
+
if (typeof pkg.bin === "object" && pkg.bin) {
|
|
140
|
+
next.bin = Object.fromEntries(Object.entries(pkg.bin).filter(([, value]) => !isKit(value)));
|
|
141
|
+
}
|
|
142
|
+
if (Array.isArray(pkg.files))
|
|
143
|
+
next.files = pkg.files.filter((entry) => !isKit(entry));
|
|
144
|
+
if (typeof pkg.scripts === "object" && pkg.scripts) {
|
|
145
|
+
next.scripts = Object.fromEntries(Object.entries(pkg.scripts)
|
|
146
|
+
.filter(([key]) => !key.startsWith("kit:"))
|
|
147
|
+
.map(([key, value]) => [key, removeKit(value)])
|
|
148
|
+
.filter(([, value]) => value.length > 0));
|
|
149
|
+
}
|
|
150
|
+
if (pkg.repository)
|
|
151
|
+
next.repository = { type: "git", url: repoUrl };
|
|
152
|
+
if (pkg.homepage)
|
|
153
|
+
next.homepage = `https://github.com/${names.owner}/${names.name}#readme`;
|
|
154
|
+
if (pkg.bugs)
|
|
155
|
+
next.bugs = { url: `https://github.com/${names.owner}/${names.name}/issues` };
|
|
156
|
+
return `${JSON.stringify(next, null, 2)}\n`;
|
|
157
|
+
};
|
|
158
|
+
const VERSION_FILE = "src/version.ts";
|
|
159
|
+
export const cliReadme = (names) => [
|
|
160
|
+
`# ${names.command}`,
|
|
161
|
+
"",
|
|
162
|
+
`\`${names.command}\` is a WireCat command-line tool, started from`,
|
|
163
|
+
"[cli-template](https://github.com/WireCatLabs/cli-template). Its design is in",
|
|
164
|
+
"[docs/dev/DESIGN.md](docs/dev/DESIGN.md).",
|
|
165
|
+
"",
|
|
166
|
+
"## Quick start",
|
|
167
|
+
"",
|
|
168
|
+
"```sh",
|
|
169
|
+
"pnpm install",
|
|
170
|
+
"pnpm build",
|
|
171
|
+
`node dist/bin/${names.command}.js personal doctor`,
|
|
172
|
+
"```",
|
|
173
|
+
"",
|
|
174
|
+
"`personal` is the profile: a name you choose for one account. Every command that uses an account starts with it.",
|
|
175
|
+
"",
|
|
176
|
+
].join("\n");
|
|
177
|
+
export const cliChangelog = (names, source) => [
|
|
178
|
+
"# Changelog",
|
|
179
|
+
"",
|
|
180
|
+
`Notable changes to \`${names.package}\`. One section per version, newest first.`,
|
|
181
|
+
"",
|
|
182
|
+
"## Unreleased",
|
|
183
|
+
"",
|
|
184
|
+
"### Added",
|
|
185
|
+
"",
|
|
186
|
+
`- Started from \`${source.name}\` ${source.version}.`,
|
|
187
|
+
"",
|
|
188
|
+
].join("\n");
|
|
189
|
+
const writeOut = async (dir, path, content, mode = 0o644) => {
|
|
190
|
+
const full = join(dir, path);
|
|
191
|
+
await mkdir(dirname(full), { recursive: true });
|
|
192
|
+
await writeFile(full, content);
|
|
193
|
+
await chmod(full, mode);
|
|
194
|
+
};
|
|
195
|
+
export const manualSteps = (names) => [
|
|
196
|
+
`1. Create the repository ${names.owner}/${names.name} and its main ruleset (cli-base docs/standards/new-cli.md).`,
|
|
197
|
+
`2. Set npm trusted publishing for ${names.package}, naming release.yml (WC-REL-11), and add any secrets.`,
|
|
198
|
+
"3. Pick a monthly cron day no other repository uses: .github/workflows/monthly.yml holds a placeholder CI rejects.",
|
|
199
|
+
"4. WireCat CLIs only: a pull request to cli-base's PARITY_GROUPS, a line in cli-core's WIRECAT_TOOLS and in cli-docs' tools.json.",
|
|
200
|
+
];
|
|
201
|
+
export const runNew = async (cwd, source, args, io) => {
|
|
202
|
+
const { names, answers } = await collect(args, io);
|
|
203
|
+
const target = join(cwd, names.name);
|
|
204
|
+
if (!(await isEmptyOrMissing(target)))
|
|
205
|
+
throw new Error(`${target} already exists and is not empty`);
|
|
206
|
+
const rendered = await renderTemplate(source, names);
|
|
207
|
+
const category = answers.category?.[0] ?? "";
|
|
208
|
+
const rewrites = {
|
|
209
|
+
"package.json": (text) => cliPackageJson(text, names),
|
|
210
|
+
"README.md": () => cliReadme(names),
|
|
211
|
+
"CHANGELOG.md": () => cliChangelog(names, source),
|
|
212
|
+
[VERSION_FILE]: (text) => text.replace(/VERSION = "[^"]*"/, 'VERSION = "0.0.0"'),
|
|
213
|
+
"AGENTS.md": (text) => withReference(text, category),
|
|
214
|
+
};
|
|
215
|
+
const hashes = {};
|
|
216
|
+
for (const [path, file] of rendered) {
|
|
217
|
+
if (file.kind === "file")
|
|
218
|
+
hashes[path] = sha256(file.content);
|
|
219
|
+
const rewrite = rewrites[path];
|
|
220
|
+
const content = rewrite ? rewrite(file.content.toString("utf8")) : file.content;
|
|
221
|
+
await writeOut(target, path, content, file.mode);
|
|
222
|
+
}
|
|
223
|
+
if (!rendered.has("AGENTS.md"))
|
|
224
|
+
await writeOut(target, "AGENTS.md", withReference("", category));
|
|
225
|
+
await writeOut(target, "docs/dev/DESIGN.md", designDoc(names, answers));
|
|
226
|
+
const manifest = {
|
|
227
|
+
template: source.name,
|
|
228
|
+
version: source.version,
|
|
229
|
+
names,
|
|
230
|
+
files: hashes,
|
|
231
|
+
seeds: source.manifest.seeds,
|
|
232
|
+
};
|
|
233
|
+
await writeOut(target, MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
234
|
+
await run("git", ["init", "-q", "-b", "main"], { cwd: target });
|
|
235
|
+
const warnings = languageWarnings(answers.contentLang ?? []);
|
|
236
|
+
return [
|
|
237
|
+
`Created ${target} from ${source.name}@${source.version}.`,
|
|
238
|
+
...(warnings.length ? ["", "Content languages:", ...warnings.map((warning) => ` ${warning}`)] : []),
|
|
239
|
+
"",
|
|
240
|
+
"Next:",
|
|
241
|
+
` cd ${names.name} && pnpm install && pnpm ci:quick`,
|
|
242
|
+
' git add -A && git commit -m "chore: start from @wirecat/cli-template"',
|
|
243
|
+
` then give the agent docs/dev/DESIGN.md; every example passes a profile, e.g. ${names.command} personal doctor`,
|
|
244
|
+
"",
|
|
245
|
+
"Four steps need a person:",
|
|
246
|
+
...manualSteps(names).map((step) => ` ${step}`),
|
|
247
|
+
"",
|
|
248
|
+
].join("\n");
|
|
249
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const escapeRegex = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
2
|
+
const word = (text) => `(?<![A-Za-z0-9_])${escapeRegex(text)}(?![A-Za-z0-9])`;
|
|
3
|
+
export const upperCommand = (command) => command.toUpperCase().replaceAll("-", "_");
|
|
4
|
+
// The command word followed by ": " is a key, such as cli-core's `app: APP` in a program definition, and
|
|
5
|
+
// stays. One pass, so a replacement that itself contains the command placeholder (a package named
|
|
6
|
+
// "@x/app-tools") is never renamed a second time.
|
|
7
|
+
export const renamer = (placeholders, names) => {
|
|
8
|
+
const table = new Map([
|
|
9
|
+
[placeholders.package, names.package],
|
|
10
|
+
[placeholders.repository, `${names.owner}/${names.name}`],
|
|
11
|
+
[placeholders.name, names.name],
|
|
12
|
+
[placeholders.command, names.command],
|
|
13
|
+
[upperCommand(placeholders.command), upperCommand(names.command)],
|
|
14
|
+
]);
|
|
15
|
+
const pattern = new RegExp([escapeRegex(placeholders.package), escapeRegex(placeholders.repository), word(placeholders.name)]
|
|
16
|
+
.concat(`${word(placeholders.command)}(?!:\\s)`)
|
|
17
|
+
.concat(word(upperCommand(placeholders.command)))
|
|
18
|
+
.join("|"), "g");
|
|
19
|
+
return (text) => text.replace(pattern, (match) => table.get(match) ?? match);
|
|
20
|
+
};
|
|
21
|
+
const isBinary = (content) => content.subarray(0, 8000).includes(0);
|
|
22
|
+
export const renameContent = (rename, content) => isBinary(content) ? content : Buffer.from(rename(content.toString("utf8")), "utf8");
|
|
23
|
+
const KIT_CONFIG = /(^|\/)(vitest\.config\.ts|biome\.json|cspell\.json|tsconfig[^/]*\.json|\.markdownlint-cli2\.jsonc)$/;
|
|
24
|
+
// The template's configs name kit/ so its own checks cover the copy tool; a CLI has no kit/, and the
|
|
25
|
+
// entries would point at nothing (or fail, for an include that must match).
|
|
26
|
+
export const stripKit = (path, content) => {
|
|
27
|
+
if (!KIT_CONFIG.test(path))
|
|
28
|
+
return content;
|
|
29
|
+
const text = content.toString("utf8");
|
|
30
|
+
const stripped = text
|
|
31
|
+
.replace(/\[\s*"!?(\.\/)?kit\/[^"]*"\s*\]/g, "[]")
|
|
32
|
+
.replace(/,\s*"!?(\.\/)?kit\/[^"]*"/g, "")
|
|
33
|
+
.replace(/"!?(\.\/)?kit\/[^"]*",\s*/g, "");
|
|
34
|
+
return stripped === text ? content : Buffer.from(stripped, "utf8");
|
|
35
|
+
};
|