kanbot 0.0.0-stage → 0.2.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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/package.json +75 -5
  4. package/schema/config.schema.json +206 -0
  5. package/src/agents/load.ts +105 -0
  6. package/src/agents/sync.ts +219 -0
  7. package/src/cli.ts +27 -0
  8. package/src/commands/board.ts +140 -0
  9. package/src/commands/completion.ts +30 -0
  10. package/src/commands/doctor.ts +260 -0
  11. package/src/commands/init.ts +119 -0
  12. package/src/commands/run.ts +32 -0
  13. package/src/commands/skills.ts +63 -0
  14. package/src/commands/status.ts +70 -0
  15. package/src/commands/sync.ts +31 -0
  16. package/src/commands/upgrade.ts +70 -0
  17. package/src/completion.ts +217 -0
  18. package/src/config.ts +240 -0
  19. package/src/daemon/derive.ts +413 -0
  20. package/src/daemon/git.ts +187 -0
  21. package/src/daemon/lock.ts +29 -0
  22. package/src/daemon/loop.ts +212 -0
  23. package/src/daemon/notify.ts +44 -0
  24. package/src/daemon/snapshot.ts +109 -0
  25. package/src/daemon/state.ts +60 -0
  26. package/src/daemon/types.ts +73 -0
  27. package/src/errors.ts +171 -0
  28. package/src/github/api.ts +332 -0
  29. package/src/github/gh.ts +110 -0
  30. package/src/github/markers.ts +82 -0
  31. package/src/github/project.ts +288 -0
  32. package/src/github/trust.ts +38 -0
  33. package/src/jobs/prompts.ts +205 -0
  34. package/src/jobs/run.ts +569 -0
  35. package/src/known-hashes.json +132 -0
  36. package/src/log.ts +11 -0
  37. package/src/main.ts +21 -0
  38. package/src/providers/claude.ts +122 -0
  39. package/src/providers/codex.ts +98 -0
  40. package/src/providers/types.ts +94 -0
  41. package/src/schemas/results.ts +116 -0
  42. package/src/templates.ts +192 -0
  43. package/templates/.agents/skills/accessibility/SKILL.md +76 -0
  44. package/templates/.agents/skills/approach-designer/SKILL.md +79 -0
  45. package/templates/.agents/skills/code-review/SKILL.md +83 -0
  46. package/templates/.agents/skills/frontend-design/LICENSE.txt +177 -0
  47. package/templates/.agents/skills/frontend-design/SKILL.md +76 -0
  48. package/templates/.agents/skills/issue-splitting/SKILL.md +78 -0
  49. package/templates/.agents/skills/kanbot-admin/SKILL.md +57 -0
  50. package/templates/.agents/skills/kanbot-idea/SKILL.md +56 -0
  51. package/templates/.agents/skills/plan-template/SKILL.md +85 -0
  52. package/templates/.agents/skills/pr-description/SKILL.md +44 -0
  53. package/templates/.agents/skills/project-conventions/SKILL.md +79 -0
  54. package/templates/.agents/skills/project-memory/SKILL.md +39 -0
  55. package/templates/.agents/skills/project-memory/facts/.gitkeep +0 -0
  56. package/templates/.agents/skills/spec-review/SKILL.md +73 -0
  57. package/templates/.agents/skills/tdd/SKILL.md +51 -0
  58. package/templates/.agents/skills/user-stories/SKILL.md +82 -0
  59. package/templates/.agents/skills/verify-ui/SKILL.md +104 -0
  60. package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
  61. package/templates/.github/ISSUE_TEMPLATE/technical.yml +15 -0
  62. package/templates/.github/ISSUE_TEMPLATE/user-story.yml +15 -0
  63. package/templates/.github/pull_request_template.md +15 -0
  64. package/templates/.github/workflows/ci.yml +23 -0
  65. package/templates/.github/workflows/kanbot-merged.yml +30 -0
  66. package/templates/.kanbot/agents/approach-designer.md +31 -0
  67. package/templates/.kanbot/agents/explorer.md +35 -0
  68. package/templates/.kanbot/agents/fixer.md +54 -0
  69. package/templates/.kanbot/agents/implementer-backend.md +57 -0
  70. package/templates/.kanbot/agents/implementer-docs.md +55 -0
  71. package/templates/.kanbot/agents/implementer-frontend.md +54 -0
  72. package/templates/.kanbot/agents/implementer-technical.md +53 -0
  73. package/templates/.kanbot/agents/plan-critic.md +36 -0
  74. package/templates/.kanbot/agents/planner.md +52 -0
  75. package/templates/.kanbot/agents/reviewer-code.md +46 -0
  76. package/templates/.kanbot/agents/reviewer-spec.md +49 -0
  77. package/templates/.kanbot/agents/test-writer.md +35 -0
  78. package/templates/.kanbot/agents/ui-verifier.md +35 -0
  79. package/templates/AGENTS.md +39 -0
  80. package/templates/CLAUDE.md +1 -0
@@ -0,0 +1,119 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { defineCommand } from "citty";
4
+ import { loadAgents } from "../agents/load.ts";
5
+ import { sync } from "../agents/sync.ts";
6
+ import { configPath, saveConfig, writeConfigSchema } from "../config.ts";
7
+ import { UserError } from "../errors.ts";
8
+ import { run } from "../github/gh.ts";
9
+ import { log } from "../log.ts";
10
+ import { applyUpgrade, NEW_SUFFIX, planUpgrade } from "../templates.ts";
11
+
12
+ const GITIGNORE = [".kanbot/state.json", ".kanbot/logs/", ".kanbot/daemon.pid"];
13
+
14
+ export default defineCommand({
15
+ meta: {
16
+ name: "init",
17
+ description:
18
+ "Scaffold kanbot agents, skills and GitHub files into this repo",
19
+ },
20
+ args: {
21
+ provider: {
22
+ type: "enum",
23
+ options: ["claude", "codex"],
24
+ description: "Agent provider",
25
+ default: "claude",
26
+ },
27
+ force: {
28
+ type: "boolean",
29
+ description: "Overwrite existing files, including your edits",
30
+ default: false,
31
+ },
32
+ },
33
+ async run({ args }) {
34
+ const root = process.cwd();
35
+ if (!existsSync(join(root, ".git")))
36
+ throw new UserError("Not a git repository.", {
37
+ why: `No .git in ${root}. Run init from the repo root.`,
38
+ fix: "cd into your repo's root, or run `git init`.",
39
+ docs: "getting-started#set-up-a-repository",
40
+ more: "",
41
+ });
42
+ if (!["claude", "codex"].includes(args.provider))
43
+ throw new UserError(`Unknown provider "${args.provider}".`, {
44
+ fix: "kanbot init --provider claude|codex",
45
+ docs: "providers#choose-a-provider",
46
+ more: "",
47
+ });
48
+
49
+ // Fail on gh or remote problems before writing anything.
50
+ const repo =
51
+ !existsSync(configPath(root)) || args.force
52
+ ? (JSON.parse(
53
+ await run([
54
+ "repo",
55
+ "view",
56
+ "--json",
57
+ "nameWithOwner,defaultBranchRef",
58
+ ]),
59
+ ) as { nameWithOwner: string; defaultBranchRef: { name: string } })
60
+ : null;
61
+
62
+ const plan = await planUpgrade(root, { force: args.force });
63
+ await applyUpgrade(root, plan);
64
+ for (const c of plan.changes) {
65
+ if (c.action === "add" || c.action === "update") log.info(`+ ${c.path}`);
66
+ if (c.action === "conflict")
67
+ log.warn(
68
+ `! ${c.path} exists; kanbot's version is in ${c.path}${NEW_SUFFIX}`,
69
+ );
70
+ }
71
+ log.info("+ .kanbot/manifest.json");
72
+
73
+ if (repo) {
74
+ await saveConfig(root, {
75
+ provider: args.provider as "claude" | "codex",
76
+ repo: repo.nameWithOwner,
77
+ baseBranch: repo.defaultBranchRef.name,
78
+ });
79
+ log.info(
80
+ `+ .kanbot/config.yml (${repo.nameWithOwner}, base ${repo.defaultBranchRef.name})`,
81
+ );
82
+ }
83
+
84
+ const codeowners = join(root, ".github", "CODEOWNERS");
85
+ if (!existsSync(codeowners)) {
86
+ const login = (await run(["api", "user", "--jq", ".login"])).trim();
87
+ await Bun.write(
88
+ codeowners,
89
+ `# Human reviewers. kanbot acts as your account, so this documents ownership\n# rather than triggering review requests for your own PRs.\n* @${login}\n`,
90
+ );
91
+ log.info(`+ .github/CODEOWNERS (@${login})`);
92
+ }
93
+
94
+ await appendGitignore(root);
95
+ const changed = await sync(root, await loadAgents(root));
96
+ if (await writeConfigSchema(root))
97
+ changed.push(".kanbot/config.schema.json");
98
+ for (const f of changed) log.info(`~ ${f} (generated)`);
99
+
100
+ console.log(`
101
+ Next steps:
102
+ 1. Review AGENTS.md and fill in .agents/skills/project-conventions/SKILL.md
103
+ 2. Adjust .github/workflows/ci.yml to your stack (agents fix failing CI)
104
+ 3. kanbot board create # creates the project board and labels
105
+ 4. kanbot doctor # checks auth, board, generated files
106
+ 5. Commit and push these files to the default branch (agents work from it)
107
+ 6. kanbot run # start the loop (or kanbot run --once --dry-run)`);
108
+ },
109
+ });
110
+
111
+ async function appendGitignore(root: string) {
112
+ const path = join(root, ".gitignore");
113
+ const current = existsSync(path) ? await Bun.file(path).text() : "";
114
+ const missing = GITIGNORE.filter((l) => !current.split("\n").includes(l));
115
+ if (!missing.length) return;
116
+ const sep = current && !current.endsWith("\n") ? "\n" : "";
117
+ await Bun.write(path, `${current}${sep}\n# kanbot\n${missing.join("\n")}\n`);
118
+ log.info("~ .gitignore");
119
+ }
@@ -0,0 +1,32 @@
1
+ import { defineCommand } from "citty";
2
+ import { runDaemon } from "../daemon/loop.ts";
3
+ import { log } from "../log.ts";
4
+
5
+ export default defineCommand({
6
+ meta: { name: "run", description: "Run the kanbot loop for this repo" },
7
+ args: {
8
+ once: {
9
+ type: "boolean",
10
+ description: "Run a single tick, wait for its jobs, exit",
11
+ default: false,
12
+ },
13
+ "dry-run": {
14
+ type: "boolean",
15
+ description: "Print what would happen; change nothing",
16
+ default: false,
17
+ },
18
+ verbose: {
19
+ type: "boolean",
20
+ alias: "v",
21
+ description: "Verbose logging",
22
+ default: false,
23
+ },
24
+ },
25
+ async run({ args }) {
26
+ log.verbose = args.verbose;
27
+ await runDaemon(process.cwd(), {
28
+ once: args.once || args["dry-run"],
29
+ dryRun: args["dry-run"],
30
+ });
31
+ },
32
+ });
@@ -0,0 +1,63 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { defineCommand } from "citty";
5
+ import { log } from "../log.ts";
6
+ import { listTemplates } from "../templates.ts";
7
+
8
+ /** Skills that work outside a kanbot repo, so they can be installed per user. */
9
+ export const GLOBAL_SKILLS = ["kanbot-idea"];
10
+
11
+ /** User-level skill folders: Claude Code reads the first, Codex the second. */
12
+ export const globalSkillDirs = (home = homedir()) => ({
13
+ claude: join(home, ".claude", "skills"),
14
+ codex: join(home, ".agents", "skills"),
15
+ });
16
+
17
+ export async function installGlobal(
18
+ providers: ("claude" | "codex")[],
19
+ home = homedir(),
20
+ ) {
21
+ const dirs = globalSkillDirs(home);
22
+ const templates = await listTemplates();
23
+ const written: string[] = [];
24
+ for (const [path, content] of templates) {
25
+ const m = path.match(/^\.agents\/skills\/([^/]+)\/(.+)$/);
26
+ if (!m?.[1] || !m[2] || !GLOBAL_SKILLS.includes(m[1])) continue;
27
+ for (const p of providers) {
28
+ const to = join(dirs[p], m[1], m[2]);
29
+ await mkdir(dirname(to), { recursive: true });
30
+ await Bun.write(to, content);
31
+ written.push(to);
32
+ }
33
+ }
34
+ return written;
35
+ }
36
+
37
+ const install = defineCommand({
38
+ meta: {
39
+ name: "install",
40
+ description: `Install ${GLOBAL_SKILLS.join(", ")} for your user, to use from any directory`,
41
+ },
42
+ args: {
43
+ provider: {
44
+ type: "enum",
45
+ options: ["claude", "codex", "both"],
46
+ description: "Which agent CLI to install for",
47
+ default: "both",
48
+ },
49
+ },
50
+ async run({ args }) {
51
+ const providers =
52
+ args.provider === "both"
53
+ ? (["claude", "codex"] as const)
54
+ : [args.provider as "claude" | "codex"];
55
+ for (const f of await installGlobal([...providers])) log.info(`+ ${f}`);
56
+ log.info("Rerun after upgrading kanbot to get the latest version.");
57
+ },
58
+ });
59
+
60
+ export default defineCommand({
61
+ meta: { name: "skills", description: "Manage kanbot skills" },
62
+ subCommands: { install },
63
+ });
@@ -0,0 +1,70 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { defineCommand } from "citty";
4
+ import { COLUMN_KEYS, LABELS, loadConfig } from "../config.ts";
5
+ import { State } from "../daemon/state.ts";
6
+ import { Gh, run } from "../github/gh.ts";
7
+ import { listItems, loadBoard } from "../github/project.ts";
8
+
9
+ export default defineCommand({
10
+ meta: {
11
+ name: "status",
12
+ description: "Show board columns, daemon state and failures",
13
+ },
14
+ async run() {
15
+ const root = process.cwd();
16
+ const config = await loadConfig(root);
17
+ const gh = new Gh(config.repo);
18
+ const board = await loadBoard(gh, config);
19
+ const items = await listItems(gh, board);
20
+
21
+ const pidFile = join(root, ".kanbot", "daemon.pid");
22
+ const pid = existsSync(pidFile)
23
+ ? (await Bun.file(pidFile).text()).trim()
24
+ : null;
25
+ console.log(
26
+ `${config.repo} · ${board.url}\ndaemon: ${pid ? `pid ${pid}` : "not running"} · provider ${config.provider}\n`,
27
+ );
28
+
29
+ for (const key of COLUMN_KEYS) {
30
+ const inCol = items.filter(
31
+ (i) => i.column === key && (key === "done" || i.state === "OPEN"),
32
+ );
33
+ const shown =
34
+ key === "done"
35
+ ? `${inCol.length}`
36
+ : inCol.map((i) => `#${i.number}`).join(" ") || "—";
37
+ console.log(`${config.columns[key].padEnd(12)} ${shown}`);
38
+ }
39
+
40
+ const needsHuman = JSON.parse(
41
+ await run([
42
+ "issue",
43
+ "list",
44
+ "--repo",
45
+ config.repo,
46
+ "--label",
47
+ LABELS.needsHuman,
48
+ "--json",
49
+ "number,title",
50
+ ]),
51
+ ) as { number: number; title: string }[];
52
+ if (needsHuman.length) {
53
+ console.log(
54
+ `\nneeds-human:\n${needsHuman.map((i) => ` #${i.number} ${i.title}`).join("\n")}`,
55
+ );
56
+ }
57
+
58
+ const state = await new State(root).load();
59
+ if (state.failures.size) {
60
+ console.log(
61
+ `\nfailures:\n${[...state.failures].map(([k, n]) => ` ${k}: ${n}`).join("\n")}`,
62
+ );
63
+ }
64
+ if (state.pausedUntil > Date.now()) {
65
+ console.log(
66
+ `\npaused (rate limit) until ${new Date(state.pausedUntil).toLocaleString()}`,
67
+ );
68
+ }
69
+ },
70
+ });
@@ -0,0 +1,31 @@
1
+ import { defineCommand } from "citty";
2
+ import { loadAgents } from "../agents/load.ts";
3
+ import { sync } from "../agents/sync.ts";
4
+ import { writeConfigSchema } from "../config.ts";
5
+ import { log } from "../log.ts";
6
+
7
+ export default defineCommand({
8
+ meta: {
9
+ name: "sync",
10
+ description:
11
+ "Generate .claude/agents, .codex/agents, permissions and the config schema",
12
+ },
13
+ args: {
14
+ check: {
15
+ type: "boolean",
16
+ description: "Exit 1 if generated files are stale",
17
+ default: false,
18
+ },
19
+ },
20
+ async run({ args }) {
21
+ const root = process.cwd();
22
+ const changed = await sync(root, await loadAgents(root), {
23
+ check: args.check,
24
+ });
25
+ if (await writeConfigSchema(root, { check: args.check }))
26
+ changed.push(".kanbot/config.schema.json");
27
+ if (!changed.length) return log.info("Generated files are up to date.");
28
+ for (const f of changed) log.info(`${args.check ? "stale" : "wrote"} ${f}`);
29
+ if (args.check) process.exit(1);
30
+ },
31
+ });
@@ -0,0 +1,70 @@
1
+ import { defineCommand } from "citty";
2
+ import pkg from "../../package.json" with { type: "json" };
3
+ import { loadAgents } from "../agents/load.ts";
4
+ import { sync } from "../agents/sync.ts";
5
+ import { writeConfigSchema } from "../config.ts";
6
+ import { log } from "../log.ts";
7
+ import {
8
+ applyUpgrade,
9
+ type Change,
10
+ NEW_SUFFIX,
11
+ planUpgrade,
12
+ readManifest,
13
+ } from "../templates.ts";
14
+
15
+ const HEADINGS: Record<Change["action"], string> = {
16
+ add: "Added",
17
+ update: "Updated",
18
+ conflict: `You edited these; kanbot's new version is next to each as *${NEW_SUFFIX}`,
19
+ deleted: "You deleted these; left out",
20
+ removed: "No longer shipped by kanbot; left in place",
21
+ };
22
+
23
+ export default defineCommand({
24
+ meta: {
25
+ name: "upgrade",
26
+ description:
27
+ "Update kanbot's agents, skills and workflows in this repo, keeping your edits",
28
+ },
29
+ args: {
30
+ "dry-run": {
31
+ type: "boolean",
32
+ description: "Show what would change without writing",
33
+ default: false,
34
+ },
35
+ },
36
+ async run({ args }) {
37
+ const root = process.cwd();
38
+ const from = (await readManifest(root))?.version ?? "no manifest";
39
+ const plan = await planUpgrade(root);
40
+ log.info(`kanbot ${from} → ${pkg.version}`);
41
+ for (const action of Object.keys(HEADINGS) as Change["action"][]) {
42
+ const paths = plan.changes
43
+ .filter((c) => c.action === action)
44
+ .map((c) => c.path);
45
+ if (paths.length)
46
+ console.log(
47
+ `\n${HEADINGS[action]}:\n${paths.map((p) => ` ${p}`).join("\n")}`,
48
+ );
49
+ }
50
+ if (!plan.changes.length) log.info("Templates are up to date.");
51
+ if (args["dry-run"]) return;
52
+
53
+ await applyUpgrade(root, plan);
54
+ const generated = await sync(root, await loadAgents(root));
55
+ if (await writeConfigSchema(root))
56
+ generated.push(".kanbot/config.schema.json");
57
+ for (const f of generated) log.info(`~ ${f} (generated)`);
58
+
59
+ const conflicts = plan.changes.some((c) => c.action === "conflict");
60
+ console.log(`
61
+ Next steps:
62
+ ${
63
+ conflicts
64
+ ? `1. Merge each *${NEW_SUFFIX} file into the file next to it, then delete it.
65
+ Ask your agent: "use kanbot-admin to merge the kanbot upgrade".
66
+ 2. `
67
+ : "1. "
68
+ }Review the diff and commit. Changes are not committed for you.`);
69
+ },
70
+ });
@@ -0,0 +1,217 @@
1
+ import type { ArgsDef, CommandDef, Resolvable } from "citty";
2
+
3
+ export const SHELLS = ["zsh", "bash", "fish"] as const;
4
+ export type Shell = (typeof SHELLS)[number];
5
+
6
+ type Flag = {
7
+ name: string;
8
+ alias: string[];
9
+ description: string;
10
+ takesValue: boolean;
11
+ options: string[];
12
+ };
13
+
14
+ export type Spec = {
15
+ name: string;
16
+ description: string;
17
+ flags: Flag[];
18
+ subs: Spec[];
19
+ };
20
+
21
+ async function resolve<T>(
22
+ v: Resolvable<T> | undefined,
23
+ ): Promise<T | undefined> {
24
+ return typeof v === "function" ? (v as () => T | Promise<T>)() : v;
25
+ }
26
+
27
+ const HELP: Flag = {
28
+ name: "help",
29
+ alias: ["h"],
30
+ description: "Show help",
31
+ takesValue: false,
32
+ options: [],
33
+ };
34
+
35
+ const VERSION: Flag = {
36
+ name: "version",
37
+ alias: [],
38
+ description: "Show version",
39
+ takesValue: false,
40
+ options: [],
41
+ };
42
+
43
+ /** Walks the citty command tree, loading lazy subcommands. */
44
+ export async function buildSpec(
45
+ cmd: CommandDef<ArgsDef>,
46
+ name?: string,
47
+ ): Promise<Spec> {
48
+ const meta = await resolve(cmd.meta);
49
+ const args = (await resolve(cmd.args)) ?? {};
50
+ const subs = (await resolve(cmd.subCommands)) ?? {};
51
+ const flags: Flag[] = Object.entries(args)
52
+ .filter(([, a]) => a.type !== "positional")
53
+ .map(([key, a]) => ({
54
+ name: key,
55
+ alias: "alias" in a && a.alias !== undefined ? [a.alias].flat() : [],
56
+ description: a.description ?? "",
57
+ takesValue: a.type !== "boolean",
58
+ options: a.type === "enum" ? (a.options ?? []) : [],
59
+ }));
60
+ flags.push(HELP);
61
+ if (meta?.version) flags.push(VERSION);
62
+ return {
63
+ name: name ?? meta?.name ?? "",
64
+ description: meta?.description ?? "",
65
+ flags,
66
+ subs: await Promise.all(
67
+ Object.entries(subs).map(async ([key, sub]) =>
68
+ buildSpec((await resolve(sub)) as CommandDef<ArgsDef>, key),
69
+ ),
70
+ ),
71
+ };
72
+ }
73
+
74
+ export function render(shell: Shell, spec: Spec): string {
75
+ return { zsh, bash, fish }[shell](spec);
76
+ }
77
+
78
+ const dashed = (f: string) => (f.length === 1 ? `-${f}` : `--${f}`);
79
+ const sq = (s: string) => `'${s.replaceAll("'", `'\\''`)}'`;
80
+
81
+ function walk(spec: Spec, path: string[] = []): [string[], Spec][] {
82
+ return [
83
+ [path, spec],
84
+ ...spec.subs.flatMap((s) => walk(s, [...path, s.name])),
85
+ ];
86
+ }
87
+
88
+ function zsh(root: Spec): string {
89
+ const esc = (s: string) => s.replace(/([[\]:\\])/g, "\\$1");
90
+ const fn = (path: string[]) => [`_${root.name}`, ...path].join("_");
91
+ const funcs = walk(root).map(([path, spec]) => {
92
+ const flags = spec.flags.map((f) => {
93
+ const names = [f.name, ...f.alias].map(dashed);
94
+ const excl = names.length > 1 ? `(${names.join(" ")})` : "";
95
+ const value = !f.takesValue
96
+ ? ""
97
+ : f.options.length
98
+ ? `:${f.name}:(${f.options.join(" ")})`
99
+ : `:${f.name}: `;
100
+ const spec = `[${esc(f.description)}]${value}`;
101
+ return names.length > 1
102
+ ? `${sq(excl)}{${names.join(",")}}${sq(spec)}`
103
+ : sq(names[0] + spec);
104
+ });
105
+ if (!spec.subs.length)
106
+ return `${fn(path)}() {\n\t_arguments -s \\\n\t\t${flags.join(" \\\n\t\t")}\n}`;
107
+ const cmds = spec.subs.map((s) => sq(`${esc(s.name)}:${s.description}`));
108
+ const cases = spec.subs.map(
109
+ (s) => `\t\t${s.name}) ${fn([...path, s.name])} ;;`,
110
+ );
111
+ return `${fn(path)}() {
112
+ local curcontext="$curcontext" state line
113
+ _arguments -C \\
114
+ ${flags.join(" \\\n\t\t")} \\
115
+ '1: :->cmd' \\
116
+ '*:: :->args'
117
+ case $state in
118
+ cmd)
119
+ local -a cmds=(${cmds.join(" ")})
120
+ _describe command cmds ;;
121
+ args)
122
+ case $line[1] in
123
+ ${cases.join("\n")}
124
+ esac ;;
125
+ esac
126
+ }`;
127
+ });
128
+ return `#compdef ${root.name}
129
+
130
+ ${funcs.join("\n\n")}
131
+
132
+ if [ "$funcstack[1]" = "_${root.name}" ]; then
133
+ _${root.name} "$@"
134
+ else
135
+ compdef _${root.name} ${root.name}
136
+ fi
137
+ `;
138
+ }
139
+
140
+ function bash(root: Spec): string {
141
+ const nodes = walk(root);
142
+ const specCases = nodes.map(([path, spec]) => {
143
+ const subs = spec.subs.map((s) => s.name).join(" ");
144
+ const flags = spec.flags
145
+ .flatMap((f) => [f.name, ...f.alias].map(dashed))
146
+ .join(" ");
147
+ return `\t${JSON.stringify(path.join(" "))}) subs=${sq(subs)}; flags=${sq(flags)} ;;`;
148
+ });
149
+ const valueCases = nodes.flatMap(([path, spec]) =>
150
+ spec.flags
151
+ .filter((f) => f.takesValue)
152
+ .map((f) => {
153
+ const pattern = [f.name, ...f.alias]
154
+ .map((n) => JSON.stringify(`${path.join(" ")}:${dashed(n)}`))
155
+ .join("|");
156
+ const action = f.options.length
157
+ ? `COMPREPLY=($(compgen -W ${sq(f.options.join(" "))} -- "$cur")); return`
158
+ : "return";
159
+ return `\t${pattern}) ${action} ;;`;
160
+ }),
161
+ );
162
+ const n = root.name;
163
+ return `_${n}_spec() {
164
+ case "$1" in
165
+ ${specCases.join("\n")}
166
+ esac
167
+ }
168
+
169
+ _${n}() {
170
+ local cur=\${COMP_WORDS[COMP_CWORD]} prev=\${COMP_WORDS[COMP_CWORD-1]}
171
+ local cmdpath="" subs flags i
172
+ _${n}_spec ""
173
+ for ((i = 1; i < COMP_CWORD; i++)); do
174
+ if [[ " $subs " == *" \${COMP_WORDS[i]} "* ]]; then
175
+ cmdpath="\${cmdpath:+$cmdpath }\${COMP_WORDS[i]}"
176
+ _${n}_spec "$cmdpath"
177
+ fi
178
+ done
179
+ case "$cmdpath:$prev" in
180
+ ${valueCases.join("\n")}
181
+ esac
182
+ COMPREPLY=($(compgen -W "$subs $flags" -- "$cur"))
183
+ }
184
+
185
+ complete -F _${n} ${n}
186
+ `;
187
+ }
188
+
189
+ function fish(root: Spec): string {
190
+ const n = root.name;
191
+ const lines = [`complete -c ${n} -f`];
192
+ for (const [path, spec] of walk(root)) {
193
+ const seen = path.map((p) => `__fish_seen_subcommand_from ${p}`);
194
+ if (spec.subs.length) {
195
+ const names = spec.subs.map((s) => s.name).join(" ");
196
+ const cond = path.length
197
+ ? [...seen, `not __fish_seen_subcommand_from ${names}`].join("; and ")
198
+ : "__fish_use_subcommand";
199
+ for (const s of spec.subs)
200
+ lines.push(
201
+ `complete -c ${n} -n ${sq(cond)} -a ${s.name} -d ${sq(s.description)}`,
202
+ );
203
+ }
204
+ for (const f of spec.flags) {
205
+ const parts = [`complete -c ${n}`];
206
+ if (seen.length) parts.push(`-n ${sq(seen.join("; and "))}`);
207
+ parts.push(`-l ${f.name}`);
208
+ for (const a of f.alias)
209
+ parts.push(a.length === 1 ? `-s ${a}` : `-l ${a}`);
210
+ if (f.takesValue) parts.push("-x");
211
+ if (f.options.length) parts.push(`-a ${sq(f.options.join(" "))}`);
212
+ if (f.description) parts.push(`-d ${sq(f.description)}`);
213
+ lines.push(parts.join(" "));
214
+ }
215
+ }
216
+ return `${lines.join("\n")}\n`;
217
+ }