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.
- package/LICENSE +21 -0
- package/README.md +112 -2
- package/package.json +75 -5
- package/schema/config.schema.json +206 -0
- package/src/agents/load.ts +105 -0
- package/src/agents/sync.ts +219 -0
- package/src/cli.ts +27 -0
- package/src/commands/board.ts +140 -0
- package/src/commands/completion.ts +30 -0
- package/src/commands/doctor.ts +260 -0
- package/src/commands/init.ts +119 -0
- package/src/commands/run.ts +32 -0
- package/src/commands/skills.ts +63 -0
- package/src/commands/status.ts +70 -0
- package/src/commands/sync.ts +31 -0
- package/src/commands/upgrade.ts +70 -0
- package/src/completion.ts +217 -0
- package/src/config.ts +240 -0
- package/src/daemon/derive.ts +413 -0
- package/src/daemon/git.ts +187 -0
- package/src/daemon/lock.ts +29 -0
- package/src/daemon/loop.ts +212 -0
- package/src/daemon/notify.ts +44 -0
- package/src/daemon/snapshot.ts +109 -0
- package/src/daemon/state.ts +60 -0
- package/src/daemon/types.ts +73 -0
- package/src/errors.ts +171 -0
- package/src/github/api.ts +332 -0
- package/src/github/gh.ts +110 -0
- package/src/github/markers.ts +82 -0
- package/src/github/project.ts +288 -0
- package/src/github/trust.ts +38 -0
- package/src/jobs/prompts.ts +205 -0
- package/src/jobs/run.ts +569 -0
- package/src/known-hashes.json +132 -0
- package/src/log.ts +11 -0
- package/src/main.ts +21 -0
- package/src/providers/claude.ts +122 -0
- package/src/providers/codex.ts +98 -0
- package/src/providers/types.ts +94 -0
- package/src/schemas/results.ts +116 -0
- package/src/templates.ts +192 -0
- package/templates/.agents/skills/accessibility/SKILL.md +76 -0
- package/templates/.agents/skills/approach-designer/SKILL.md +79 -0
- package/templates/.agents/skills/code-review/SKILL.md +83 -0
- package/templates/.agents/skills/frontend-design/LICENSE.txt +177 -0
- package/templates/.agents/skills/frontend-design/SKILL.md +76 -0
- package/templates/.agents/skills/issue-splitting/SKILL.md +78 -0
- package/templates/.agents/skills/kanbot-admin/SKILL.md +57 -0
- package/templates/.agents/skills/kanbot-idea/SKILL.md +56 -0
- package/templates/.agents/skills/plan-template/SKILL.md +85 -0
- package/templates/.agents/skills/pr-description/SKILL.md +44 -0
- package/templates/.agents/skills/project-conventions/SKILL.md +79 -0
- package/templates/.agents/skills/project-memory/SKILL.md +39 -0
- package/templates/.agents/skills/project-memory/facts/.gitkeep +0 -0
- package/templates/.agents/skills/spec-review/SKILL.md +73 -0
- package/templates/.agents/skills/tdd/SKILL.md +51 -0
- package/templates/.agents/skills/user-stories/SKILL.md +82 -0
- package/templates/.agents/skills/verify-ui/SKILL.md +104 -0
- package/templates/.github/ISSUE_TEMPLATE/config.yml +1 -0
- package/templates/.github/ISSUE_TEMPLATE/technical.yml +15 -0
- package/templates/.github/ISSUE_TEMPLATE/user-story.yml +15 -0
- package/templates/.github/pull_request_template.md +15 -0
- package/templates/.github/workflows/ci.yml +23 -0
- package/templates/.github/workflows/kanbot-merged.yml +30 -0
- package/templates/.kanbot/agents/approach-designer.md +31 -0
- package/templates/.kanbot/agents/explorer.md +35 -0
- package/templates/.kanbot/agents/fixer.md +54 -0
- package/templates/.kanbot/agents/implementer-backend.md +57 -0
- package/templates/.kanbot/agents/implementer-docs.md +55 -0
- package/templates/.kanbot/agents/implementer-frontend.md +54 -0
- package/templates/.kanbot/agents/implementer-technical.md +53 -0
- package/templates/.kanbot/agents/plan-critic.md +36 -0
- package/templates/.kanbot/agents/planner.md +52 -0
- package/templates/.kanbot/agents/reviewer-code.md +46 -0
- package/templates/.kanbot/agents/reviewer-spec.md +49 -0
- package/templates/.kanbot/agents/test-writer.md +35 -0
- package/templates/.kanbot/agents/ui-verifier.md +35 -0
- package/templates/AGENTS.md +39 -0
- 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
|
+
}
|