kanbot 0.1.0 → 0.3.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/README.md +17 -1
- package/package.json +21 -2
- package/schema/config.schema.json +54 -0
- package/src/agents/load.ts +29 -7
- package/src/agents/sync.ts +6 -3
- package/src/cli.ts +24 -2
- package/src/commands/app.ts +169 -0
- package/src/commands/completion.ts +7 -1
- package/src/commands/doctor.ts +49 -4
- package/src/commands/init.ts +26 -9
- package/src/commands/skills.ts +1 -1
- package/src/commands/status.ts +11 -2
- package/src/config.ts +63 -2
- package/src/daemon/derive.ts +38 -1
- package/src/daemon/lock.ts +6 -1
- package/src/daemon/loop.ts +14 -2
- package/src/daemon/snapshot.ts +1 -0
- package/src/daemon/types.ts +17 -0
- package/src/errors.ts +171 -0
- package/src/github/api.ts +47 -2
- package/src/github/app.ts +153 -0
- package/src/github/gh.ts +29 -3
- package/src/github/project.ts +13 -3
- package/src/jobs/prompts.ts +5 -1
- package/src/jobs/run.ts +75 -35
- package/src/known-hashes.json +5 -0
- package/src/main.ts +1 -0
- package/src/templates.ts +1 -0
- package/templates/.agents/skills/kanbot-admin/SKILL.md +20 -5
- package/templates/.agents/skills/kanbot-guide/SKILL.md +91 -0
- package/templates/AGENTS.md +1 -1
package/README.md
CHANGED
|
@@ -26,10 +26,12 @@ Full docs: [docs/](./docs/00-index.md). Demo video: [docs/assets/demo.mp4](./doc
|
|
|
26
26
|
| **Ready** | Moving a card here approves the plan. Cards are picked top-down. |
|
|
27
27
|
| **In Progress** | An agent implements it in its own worktree and opens a PR. |
|
|
28
28
|
| **Review** | Two agent reviews, with fixes. Then you're notified. Comment on the PR and an agent addresses it. CI failures and merge conflicts are fixed too. |
|
|
29
|
-
| **Done** | You merge.
|
|
29
|
+
| **Done** | You merge, or approve and kanbot merges ([`merge.onApproval`](./docs/12-identity.md#merge-on-approval)). |
|
|
30
30
|
|
|
31
31
|
A Bun daemon polls the board and runs one `claude -p` / `codex exec` process per job. GitHub is the only state, so you can restart it any time. Agents never get a GitHub token; the daemon makes every comment, push and PR. Only collaborators with write access can steer agents.
|
|
32
32
|
|
|
33
|
+
kanbot talks as you by default. Run `kanbot app create` to have it talk as its own bot instead, so you can tell its messages apart and approve its PRs. Commits stay yours. With `merge.onApproval`, approving a PR merges it. [More](./docs/12-identity.md)
|
|
34
|
+
|
|
33
35
|
## Setup
|
|
34
36
|
|
|
35
37
|
Requires [Bun](https://bun.sh) ≥ 1.3, `gh` logged in, and `claude` or `codex` logged in.
|
|
@@ -46,6 +48,8 @@ kanbot run
|
|
|
46
48
|
|
|
47
49
|
Use `kanbot run --once --dry-run` to see what it would do. Run one daemon per repo.
|
|
48
50
|
|
|
51
|
+
For a guided setup, run `bunx kanbot skills install` and ask your agent "help me set up kanbot".
|
|
52
|
+
|
|
49
53
|
## Labels
|
|
50
54
|
|
|
51
55
|
| Label | Meaning |
|
|
@@ -101,3 +105,15 @@ bun test
|
|
|
101
105
|
pnpm check
|
|
102
106
|
pnpm typecheck
|
|
103
107
|
```
|
|
108
|
+
|
|
109
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org). A `commit-msg` hook and CI lint them.
|
|
110
|
+
|
|
111
|
+
### Releasing
|
|
112
|
+
|
|
113
|
+
1. Merge or push conventional commits to `main`.
|
|
114
|
+
2. A draft release updates with the next version and notes. `feat` bumps minor, `fix` patch. On 0.x, breaking changes bump minor.
|
|
115
|
+
3. Publish the draft on GitHub. This tags the commit, publishes to npm with provenance, and commits the version to `package.json` on `main`.
|
|
116
|
+
|
|
117
|
+
Docs: [kanbot.mib.im](https://kanbot.mib.im) shows the latest release, [kanbot.mib.im/main](https://kanbot.mib.im/main/) shows `main`. Both deploy on every push to `main` and on each release. `pnpm docs:dev` serves your working tree.
|
|
118
|
+
|
|
119
|
+
Preview the next release with `pnpm release:preview`. Mark the draft as a pre-release to publish under the `next` tag.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kanbot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Run an autonomous dev team (Claude Code or Codex) from a GitHub Project board",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,9 @@
|
|
|
41
41
|
"docs:dev": "pnpm --dir site dev",
|
|
42
42
|
"docs:build": "pnpm --dir site build",
|
|
43
43
|
"hashes": "bun scripts/known-hashes.ts",
|
|
44
|
-
"prepublishOnly": "bun run check && bun run typecheck && bun test"
|
|
44
|
+
"prepublishOnly": "bun run check && bun run typecheck && bun test",
|
|
45
|
+
"prepare": "lefthook install",
|
|
46
|
+
"release:preview": "git-cliff --unreleased --bump --strip all && git-cliff --bumped-version"
|
|
45
47
|
},
|
|
46
48
|
"dependencies": {
|
|
47
49
|
"citty": "^0.2.2",
|
|
@@ -51,7 +53,24 @@
|
|
|
51
53
|
},
|
|
52
54
|
"devDependencies": {
|
|
53
55
|
"@biomejs/biome": "^2.6.4",
|
|
56
|
+
"@commitlint/cli": "^21.2.4",
|
|
57
|
+
"@commitlint/config-conventional": "^21.2.4",
|
|
54
58
|
"@types/bun": "^1.4.4",
|
|
59
|
+
"git-cliff": "^2.14.2",
|
|
60
|
+
"lefthook": "^2.2.2",
|
|
55
61
|
"typescript": "^7.0.2"
|
|
62
|
+
},
|
|
63
|
+
"commitlint": {
|
|
64
|
+
"extends": [
|
|
65
|
+
"@commitlint/config-conventional"
|
|
66
|
+
],
|
|
67
|
+
"rules": {
|
|
68
|
+
"body-max-line-length": [
|
|
69
|
+
0
|
|
70
|
+
],
|
|
71
|
+
"footer-max-line-length": [
|
|
72
|
+
0
|
|
73
|
+
]
|
|
74
|
+
}
|
|
56
75
|
}
|
|
57
76
|
}
|
|
@@ -165,6 +165,60 @@
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
},
|
|
168
|
+
"identity": {
|
|
169
|
+
"default": {},
|
|
170
|
+
"type": "object",
|
|
171
|
+
"properties": {
|
|
172
|
+
"mode": {
|
|
173
|
+
"default": "user",
|
|
174
|
+
"description": "Who kanbot talks as. `user`: your account. `app`: a kanbot GitHub App, so its comments and PRs show as a bot. Commits are always yours.",
|
|
175
|
+
"type": "string",
|
|
176
|
+
"enum": ["user", "app"]
|
|
177
|
+
},
|
|
178
|
+
"app": {
|
|
179
|
+
"default": null,
|
|
180
|
+
"description": "Set by `kanbot app create`. The private key lives in ~/.kanbot/apps/<id>.pem.",
|
|
181
|
+
"anyOf": [
|
|
182
|
+
{
|
|
183
|
+
"type": "object",
|
|
184
|
+
"properties": {
|
|
185
|
+
"id": {
|
|
186
|
+
"type": "integer",
|
|
187
|
+
"minimum": -9007199254740991,
|
|
188
|
+
"maximum": 9007199254740991,
|
|
189
|
+
"description": "GitHub App ID."
|
|
190
|
+
},
|
|
191
|
+
"slug": {
|
|
192
|
+
"type": "string",
|
|
193
|
+
"description": "App slug, as in github.com/apps/<slug>."
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
"required": ["id", "slug"]
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"type": "null"
|
|
200
|
+
}
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
"merge": {
|
|
206
|
+
"default": {},
|
|
207
|
+
"type": "object",
|
|
208
|
+
"properties": {
|
|
209
|
+
"onApproval": {
|
|
210
|
+
"default": false,
|
|
211
|
+
"description": "Merge a kanbot PR when a collaborator approves it, checks pass and agent review is done. Merges as you. Needs identity.mode app to approve your own kanbot PRs.",
|
|
212
|
+
"type": "boolean"
|
|
213
|
+
},
|
|
214
|
+
"method": {
|
|
215
|
+
"default": "squash",
|
|
216
|
+
"description": "Merge method used for approved PRs.",
|
|
217
|
+
"type": "string",
|
|
218
|
+
"enum": ["squash", "merge", "rebase"]
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
},
|
|
168
222
|
"codex": {
|
|
169
223
|
"default": {},
|
|
170
224
|
"type": "object",
|
package/src/agents/load.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { readdir } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { parse } from "yaml";
|
|
4
5
|
import { z } from "zod";
|
|
6
|
+
import { UserError } from "../errors.ts";
|
|
5
7
|
import { RESULT_SCHEMAS } from "../schemas/results.ts";
|
|
6
8
|
|
|
7
9
|
export const TOOL_CAPABILITIES = ["read", "edit", "bash", "web"] as const;
|
|
@@ -34,16 +36,23 @@ export function agentsDir(root: string) {
|
|
|
34
36
|
|
|
35
37
|
export function parseAgent(text: string, file: string): AgentDef {
|
|
36
38
|
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
37
|
-
if (!m) throw
|
|
39
|
+
if (!m) throw invalid(file, "Missing YAML frontmatter between `---` lines.");
|
|
38
40
|
const fm = Frontmatter.safeParse(parse(m[1] as string));
|
|
39
41
|
if (!fm.success) {
|
|
40
|
-
throw
|
|
42
|
+
throw invalid(file, z.prettifyError(fm.error));
|
|
41
43
|
}
|
|
42
44
|
return { ...fm.data, body: (m[2] as string).trim(), file };
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export async function loadAgents(root: string): Promise<AgentDef[]> {
|
|
46
48
|
const dir = agentsDir(root);
|
|
49
|
+
if (!existsSync(dir)) {
|
|
50
|
+
throw new UserError("kanbot agents are missing.", {
|
|
51
|
+
why: "No .kanbot/agents directory in this repo.",
|
|
52
|
+
fix: "kanbot init (or kanbot upgrade to restore missing files)",
|
|
53
|
+
docs: "getting-started#set-up-a-repository",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
47
56
|
const files = (await readdir(dir)).filter((f) => f.endsWith(".md")).sort();
|
|
48
57
|
const agents = await Promise.all(
|
|
49
58
|
files.map(async (f) =>
|
|
@@ -57,25 +66,38 @@ export async function loadAgents(root: string): Promise<AgentDef[]> {
|
|
|
57
66
|
function validate(agents: AgentDef[]) {
|
|
58
67
|
const names = new Set(agents.map((a) => a.name));
|
|
59
68
|
if (names.size !== agents.length) {
|
|
60
|
-
throw new
|
|
69
|
+
throw new UserError("Duplicate agent names in .kanbot/agents.", {
|
|
70
|
+
why: "Two agent files declare the same `name`.",
|
|
71
|
+
fix: "Give each agent a unique name matching its filename.",
|
|
72
|
+
docs: "agents#frontmatter",
|
|
73
|
+
});
|
|
61
74
|
}
|
|
62
75
|
for (const a of agents) {
|
|
63
76
|
if (!a.file.endsWith(`${a.name}.md`)) {
|
|
64
|
-
throw
|
|
77
|
+
throw invalid(a.file, `name "${a.name}" must match the filename.`);
|
|
65
78
|
}
|
|
66
79
|
if (a.tier === "column" && !a.output) {
|
|
67
|
-
throw
|
|
80
|
+
throw invalid(a.file, "Column agents must declare an `output` schema.");
|
|
68
81
|
}
|
|
69
82
|
for (const s of a.subagents) {
|
|
70
83
|
const sub = agents.find((x) => x.name === s);
|
|
71
|
-
if (!sub)
|
|
84
|
+
if (!sub)
|
|
85
|
+
throw invalid(a.file, `Unknown subagent "${s}" in \`subagents\`.`);
|
|
72
86
|
if (sub.tier !== "sub") {
|
|
73
|
-
throw
|
|
87
|
+
throw invalid(a.file, `Subagent "${s}" must have \`tier: sub\`.`);
|
|
74
88
|
}
|
|
75
89
|
}
|
|
76
90
|
}
|
|
77
91
|
}
|
|
78
92
|
|
|
93
|
+
function invalid(file: string, why: string) {
|
|
94
|
+
return new UserError(`Invalid agent file ${file}`, {
|
|
95
|
+
why,
|
|
96
|
+
fix: "Fix the file, then run `kanbot sync`.",
|
|
97
|
+
docs: "agents#frontmatter",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
79
101
|
export function getAgent(agents: AgentDef[], name: string): AgentDef {
|
|
80
102
|
const a = agents.find((x) => x.name === name);
|
|
81
103
|
if (!a) throw new Error(`No agent named "${name}" in .kanbot/agents`);
|
package/src/agents/sync.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdir, readdir, rm, symlink } from "node:fs/promises";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { stringify } from "yaml";
|
|
5
5
|
import { configPath, loadConfig } from "../config.ts";
|
|
6
|
+
import { UserError } from "../errors.ts";
|
|
6
7
|
import type { AgentDef } from "./load.ts";
|
|
7
8
|
|
|
8
9
|
/*
|
|
@@ -205,9 +206,11 @@ async function linkSkills(root: string, check: boolean) {
|
|
|
205
206
|
const ok = existsSync(link) && lstatSync(link).isSymbolicLink();
|
|
206
207
|
if (ok || check) return ok;
|
|
207
208
|
if (existsSync(link)) {
|
|
208
|
-
throw new
|
|
209
|
-
".claude/skills exists and is not a symlink.
|
|
210
|
-
|
|
209
|
+
throw new UserError("Cannot link .claude/skills.", {
|
|
210
|
+
why: ".claude/skills exists and is not a symlink. kanbot keeps skills in .agents/skills and links to them.",
|
|
211
|
+
fix: "Move its skills into .agents/skills, delete .claude/skills, then rerun.",
|
|
212
|
+
docs: "skills#add-a-skill",
|
|
213
|
+
});
|
|
211
214
|
}
|
|
212
215
|
await mkdir(join(root, ".agents", "skills"), { recursive: true });
|
|
213
216
|
await mkdir(join(root, ".claude"), { recursive: true });
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { runMain } from "citty";
|
|
2
|
+
import { runCommand, runMain } from "citty";
|
|
3
|
+
import { formatError } from "./errors.ts";
|
|
3
4
|
import { main } from "./main.ts";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const usage =
|
|
8
|
+
!args.length ||
|
|
9
|
+
args.some((a) => a === "--help" || a === "-h") ||
|
|
10
|
+
(args.length === 1 && (args[0] === "--version" || args[0] === "-v"));
|
|
11
|
+
|
|
12
|
+
// citty prints usage for help, version and a bare `kanbot`.
|
|
13
|
+
if (usage) {
|
|
14
|
+
await runMain(main);
|
|
15
|
+
} else {
|
|
16
|
+
try {
|
|
17
|
+
await runCommand(main, { rawArgs: args });
|
|
18
|
+
} catch (e) {
|
|
19
|
+
// Leading positionals name the command, e.g. "board create".
|
|
20
|
+
const end = args.findIndex((a) => a.startsWith("-"));
|
|
21
|
+
const command = args.slice(0, end < 0 ? undefined : end).join(" ");
|
|
22
|
+
console.error(
|
|
23
|
+
formatError(e, { debug: !!process.env.KANBOT_DEBUG, command }),
|
|
24
|
+
);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { defineCommand } from "citty";
|
|
6
|
+
import { loadConfig, saveConfig } from "../config.ts";
|
|
7
|
+
import { UserError } from "../errors.ts";
|
|
8
|
+
import { appKeyPath, appManifest, convertManifest } from "../github/app.ts";
|
|
9
|
+
import { Gh } from "../github/gh.ts";
|
|
10
|
+
import { log } from "../log.ts";
|
|
11
|
+
|
|
12
|
+
const WAIT_MS = 10 * 60_000;
|
|
13
|
+
|
|
14
|
+
function openBrowser(url: string) {
|
|
15
|
+
const cmd =
|
|
16
|
+
process.platform === "darwin"
|
|
17
|
+
? "open"
|
|
18
|
+
: process.platform === "win32"
|
|
19
|
+
? "explorer"
|
|
20
|
+
: "xdg-open";
|
|
21
|
+
try {
|
|
22
|
+
Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" });
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const escAttr = (s: string) => s.replace(/&/g, "&").replace(/"/g, """);
|
|
27
|
+
|
|
28
|
+
/** A page that posts the manifest to GitHub, as the manifest flow requires. */
|
|
29
|
+
function formPage(action: string, manifest: unknown) {
|
|
30
|
+
return `<!doctype html><meta charset="utf-8"><title>kanbot app</title>
|
|
31
|
+
<form id="f" method="post" action="${escAttr(action)}">
|
|
32
|
+
<input type="hidden" name="manifest" value="${escAttr(JSON.stringify(manifest))}">
|
|
33
|
+
<button>Create the kanbot app on GitHub</button></form>
|
|
34
|
+
<script>document.getElementById("f").submit()</script>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const page = (msg: string) =>
|
|
38
|
+
new Response(
|
|
39
|
+
`<!doctype html><meta charset="utf-8"><title>kanbot app</title><p>${msg}</p>`,
|
|
40
|
+
{ headers: { "content-type": "text/html" } },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const create = defineCommand({
|
|
44
|
+
meta: {
|
|
45
|
+
name: "create",
|
|
46
|
+
description:
|
|
47
|
+
"Create a GitHub App for kanbot to talk as, and switch to app mode",
|
|
48
|
+
},
|
|
49
|
+
args: {
|
|
50
|
+
name: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: "App name, unique on GitHub (default: kanbot-<owner>)",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
async run({ args }) {
|
|
56
|
+
const root = process.cwd();
|
|
57
|
+
const config = await loadConfig(root);
|
|
58
|
+
const gh = new Gh(config.repo);
|
|
59
|
+
const owner = gh.owner;
|
|
60
|
+
const { type } = await gh.rest<{ type: string }>(`users/${owner}`);
|
|
61
|
+
const name = (args.name ?? `kanbot-${owner}`).slice(0, 34);
|
|
62
|
+
const state = randomUUID();
|
|
63
|
+
const action =
|
|
64
|
+
type === "Organization"
|
|
65
|
+
? `https://github.com/organizations/${owner}/settings/apps/new?state=${state}`
|
|
66
|
+
: `https://github.com/settings/apps/new?state=${state}`;
|
|
67
|
+
|
|
68
|
+
const got = Promise.withResolvers<string>();
|
|
69
|
+
const server = Bun.serve({
|
|
70
|
+
port: 0,
|
|
71
|
+
hostname: "localhost",
|
|
72
|
+
fetch(req): Response {
|
|
73
|
+
const url = new URL(req.url);
|
|
74
|
+
if (url.pathname === "/")
|
|
75
|
+
return new Response(
|
|
76
|
+
formPage(
|
|
77
|
+
action,
|
|
78
|
+
appManifest(name, `http://localhost:${url.port}/callback`),
|
|
79
|
+
),
|
|
80
|
+
{ headers: { "content-type": "text/html" } },
|
|
81
|
+
);
|
|
82
|
+
if (url.pathname !== "/callback")
|
|
83
|
+
return new Response("", { status: 404 });
|
|
84
|
+
const code = url.searchParams.get("code");
|
|
85
|
+
if (url.searchParams.get("state") !== state || !code)
|
|
86
|
+
return page("Unexpected request. Run `kanbot app create` again.");
|
|
87
|
+
got.resolve(code);
|
|
88
|
+
return page("App created. Go back to your terminal.");
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const start = `http://localhost:${server.port}/`;
|
|
92
|
+
log.info(`Opening ${start} — confirm the app on GitHub.`);
|
|
93
|
+
openBrowser(start);
|
|
94
|
+
const timer = setTimeout(
|
|
95
|
+
() => got.reject(new Error("timed out waiting for GitHub")),
|
|
96
|
+
WAIT_MS,
|
|
97
|
+
);
|
|
98
|
+
let code: string;
|
|
99
|
+
try {
|
|
100
|
+
code = await got.promise;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
throw new UserError("No app was created.", {
|
|
103
|
+
why: String(e),
|
|
104
|
+
fix: "kanbot app create",
|
|
105
|
+
docs: "identity#app-mode",
|
|
106
|
+
});
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
server.stop();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const app = await convertManifest(code);
|
|
113
|
+
const keyFile = appKeyPath(app.id);
|
|
114
|
+
await mkdir(dirname(keyFile), { recursive: true });
|
|
115
|
+
await Bun.write(keyFile, app.pem);
|
|
116
|
+
await chmod(keyFile, 0o600);
|
|
117
|
+
await saveConfig(root, {
|
|
118
|
+
identity: { mode: "app", app: { id: app.id, slug: app.slug } },
|
|
119
|
+
});
|
|
120
|
+
const install = `https://github.com/apps/${app.slug}/installations/new`;
|
|
121
|
+
log.info(`Created ${app.slug} (id ${app.id}). Key: ${keyFile}`);
|
|
122
|
+
log.info(`Now install it on ${config.repo}: ${install}`);
|
|
123
|
+
openBrowser(install);
|
|
124
|
+
console.log(
|
|
125
|
+
"\nThen commit .kanbot/config.yml, run `kanbot doctor` and restart the daemon.",
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const link = defineCommand({
|
|
131
|
+
meta: {
|
|
132
|
+
name: "link",
|
|
133
|
+
description: "Use a kanbot app you already created, e.g. for another repo",
|
|
134
|
+
},
|
|
135
|
+
args: {
|
|
136
|
+
id: { type: "string", required: true, description: "GitHub App ID" },
|
|
137
|
+
slug: {
|
|
138
|
+
type: "string",
|
|
139
|
+
required: true,
|
|
140
|
+
description: "App slug, as in github.com/apps/<slug>",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
async run({ args }) {
|
|
144
|
+
const root = process.cwd();
|
|
145
|
+
const id = Number(args.id);
|
|
146
|
+
if (!Number.isInteger(id))
|
|
147
|
+
throw new UserError("--id must be a number.", {
|
|
148
|
+
docs: "identity#use-the-app-in-another-repo",
|
|
149
|
+
});
|
|
150
|
+
await saveConfig(root, {
|
|
151
|
+
identity: { mode: "app", app: { id, slug: args.slug } },
|
|
152
|
+
});
|
|
153
|
+
const config = await loadConfig(root);
|
|
154
|
+
log.info(`kanbot talks as ${args.slug}[bot] in ${config.repo}.`);
|
|
155
|
+
if (!existsSync(appKeyPath(id)))
|
|
156
|
+
log.warn(`No private key at ${appKeyPath(id)}. Copy it there.`);
|
|
157
|
+
log.info(
|
|
158
|
+
`Install it on the repo if needed: https://github.com/apps/${args.slug}/installations/new`,
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export default defineCommand({
|
|
164
|
+
meta: {
|
|
165
|
+
name: "app",
|
|
166
|
+
description: "Manage the GitHub App kanbot talks as",
|
|
167
|
+
},
|
|
168
|
+
subCommands: { create, link },
|
|
169
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { defineCommand } from "citty";
|
|
3
3
|
import { buildSpec, render, SHELLS, type Shell } from "../completion.ts";
|
|
4
|
+
import { UserError } from "../errors.ts";
|
|
4
5
|
|
|
5
6
|
export default defineCommand({
|
|
6
7
|
meta: {
|
|
@@ -17,7 +18,12 @@ export default defineCommand({
|
|
|
17
18
|
async run({ args }) {
|
|
18
19
|
const shell = args.shell ?? basename(process.env.SHELL ?? "");
|
|
19
20
|
if (!SHELLS.includes(shell as Shell))
|
|
20
|
-
throw new
|
|
21
|
+
throw new UserError("Could not detect your shell.", {
|
|
22
|
+
why: `$SHELL is "${shell}", which is not supported.`,
|
|
23
|
+
fix: `kanbot completion --shell ${SHELLS.join("|")}`,
|
|
24
|
+
docs: "getting-started#shell-completion",
|
|
25
|
+
more: "",
|
|
26
|
+
});
|
|
21
27
|
const { main } = await import("../main.ts");
|
|
22
28
|
process.stdout.write(render(shell as Shell, await buildSpec(main)));
|
|
23
29
|
},
|
package/src/commands/doctor.ts
CHANGED
|
@@ -5,7 +5,9 @@ import { loadAgents } from "../agents/load.ts";
|
|
|
5
5
|
import { sync } from "../agents/sync.ts";
|
|
6
6
|
import { loadConfig, writeConfigSchema } from "../config.ts";
|
|
7
7
|
import { git } from "../daemon/git.ts";
|
|
8
|
+
import { describeError } from "../errors.ts";
|
|
8
9
|
import { copilotReviewRule } from "../github/api.ts";
|
|
10
|
+
import { appKeyPath, appTokens } from "../github/app.ts";
|
|
9
11
|
import { Gh, run } from "../github/gh.ts";
|
|
10
12
|
import {
|
|
11
13
|
boardWorkflows,
|
|
@@ -27,6 +29,16 @@ async function cmd(args: string[]) {
|
|
|
27
29
|
return { ok: code === 0, out: `${out}${err}`.trim() };
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/** Failure detail and fix for a check, from a thrown error. */
|
|
33
|
+
function explain(e: unknown, fallback?: string) {
|
|
34
|
+
const d = describeError(e);
|
|
35
|
+
const detail = [d.message, d.why].filter(Boolean).join("\n");
|
|
36
|
+
return {
|
|
37
|
+
detail: detail.replace(/\n/g, "\n "),
|
|
38
|
+
fix: d.fix ?? fallback,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
export default defineCommand({
|
|
31
43
|
meta: {
|
|
32
44
|
name: "doctor",
|
|
@@ -59,7 +71,7 @@ export default defineCommand({
|
|
|
59
71
|
detail: `${config.repo}, provider ${config.provider}`,
|
|
60
72
|
});
|
|
61
73
|
} catch (e) {
|
|
62
|
-
add({ name: "config", ok: false,
|
|
74
|
+
add({ name: "config", ok: false, ...explain(e, "kanbot init") });
|
|
63
75
|
return process.exit(1);
|
|
64
76
|
}
|
|
65
77
|
|
|
@@ -119,7 +131,7 @@ export default defineCommand({
|
|
|
119
131
|
fix: "kanbot sync",
|
|
120
132
|
});
|
|
121
133
|
} catch (e) {
|
|
122
|
-
add({ name: "agents", ok: false,
|
|
134
|
+
add({ name: "agents", ok: false, ...explain(e) });
|
|
123
135
|
}
|
|
124
136
|
|
|
125
137
|
const plan = await planUpgrade(root);
|
|
@@ -197,8 +209,7 @@ export default defineCommand({
|
|
|
197
209
|
add({
|
|
198
210
|
name: "project board",
|
|
199
211
|
ok: false,
|
|
200
|
-
|
|
201
|
-
fix: "kanbot board create",
|
|
212
|
+
...explain(e, "kanbot board create"),
|
|
202
213
|
});
|
|
203
214
|
}
|
|
204
215
|
|
|
@@ -226,6 +237,40 @@ export default defineCommand({
|
|
|
226
237
|
fix: "kanbot board create",
|
|
227
238
|
});
|
|
228
239
|
|
|
240
|
+
const { mode, app } = config.identity;
|
|
241
|
+
if (mode === "app") {
|
|
242
|
+
add({
|
|
243
|
+
name: "kanbot app set",
|
|
244
|
+
ok: app !== null,
|
|
245
|
+
detail: app ? `${app.slug} (id ${app.id})` : undefined,
|
|
246
|
+
fix: "kanbot app create",
|
|
247
|
+
});
|
|
248
|
+
if (app) {
|
|
249
|
+
const key = appKeyPath(app.id);
|
|
250
|
+
add({
|
|
251
|
+
name: "kanbot app private key",
|
|
252
|
+
ok: existsSync(key),
|
|
253
|
+
detail: key,
|
|
254
|
+
fix: "kanbot app create, or copy the key from the machine that created the app",
|
|
255
|
+
});
|
|
256
|
+
if (existsSync(key)) {
|
|
257
|
+
try {
|
|
258
|
+
await appTokens(app, config.repo)();
|
|
259
|
+
add({ name: `kanbot app installed on ${config.repo}`, ok: true });
|
|
260
|
+
} catch (e) {
|
|
261
|
+
add({
|
|
262
|
+
name: `kanbot app installed on ${config.repo}`,
|
|
263
|
+
ok: false,
|
|
264
|
+
...explain(
|
|
265
|
+
e,
|
|
266
|
+
`https://github.com/apps/${app.slug}/installations/new`,
|
|
267
|
+
),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
229
274
|
if (config.review.copilot) {
|
|
230
275
|
const rule = await copilotReviewRule(gh, config.baseBranch).catch(
|
|
231
276
|
() => null,
|
package/src/commands/init.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { defineCommand } from "citty";
|
|
|
4
4
|
import { loadAgents } from "../agents/load.ts";
|
|
5
5
|
import { sync } from "../agents/sync.ts";
|
|
6
6
|
import { configPath, saveConfig, writeConfigSchema } from "../config.ts";
|
|
7
|
+
import { UserError } from "../errors.ts";
|
|
7
8
|
import { run } from "../github/gh.ts";
|
|
8
9
|
import { log } from "../log.ts";
|
|
9
10
|
import { applyUpgrade, NEW_SUFFIX, planUpgrade } from "../templates.ts";
|
|
@@ -32,9 +33,31 @@ export default defineCommand({
|
|
|
32
33
|
async run({ args }) {
|
|
33
34
|
const root = process.cwd();
|
|
34
35
|
if (!existsSync(join(root, ".git")))
|
|
35
|
-
throw new
|
|
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
|
+
});
|
|
36
42
|
if (!["claude", "codex"].includes(args.provider))
|
|
37
|
-
throw new
|
|
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;
|
|
38
61
|
|
|
39
62
|
const plan = await planUpgrade(root, { force: args.force });
|
|
40
63
|
await applyUpgrade(root, plan);
|
|
@@ -47,13 +70,7 @@ export default defineCommand({
|
|
|
47
70
|
}
|
|
48
71
|
log.info("+ .kanbot/manifest.json");
|
|
49
72
|
|
|
50
|
-
if (
|
|
51
|
-
const repo = JSON.parse(
|
|
52
|
-
await run(["repo", "view", "--json", "nameWithOwner,defaultBranchRef"]),
|
|
53
|
-
) as {
|
|
54
|
-
nameWithOwner: string;
|
|
55
|
-
defaultBranchRef: { name: string };
|
|
56
|
-
};
|
|
73
|
+
if (repo) {
|
|
57
74
|
await saveConfig(root, {
|
|
58
75
|
provider: args.provider as "claude" | "codex",
|
|
59
76
|
repo: repo.nameWithOwner,
|
package/src/commands/skills.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { log } from "../log.ts";
|
|
|
6
6
|
import { listTemplates } from "../templates.ts";
|
|
7
7
|
|
|
8
8
|
/** Skills that work outside a kanbot repo, so they can be installed per user. */
|
|
9
|
-
export const GLOBAL_SKILLS = ["kanbot-idea"];
|
|
9
|
+
export const GLOBAL_SKILLS = ["kanbot-idea", "kanbot-guide"];
|
|
10
10
|
|
|
11
11
|
/** User-level skill folders: Claude Code reads the first, Codex the second. */
|
|
12
12
|
export const globalSkillDirs = (home = homedir()) => ({
|
package/src/commands/status.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import { COLUMN_KEYS, LABELS, loadConfig } from "../config.ts";
|
|
5
5
|
import { State } from "../daemon/state.ts";
|
|
6
|
-
import { Gh } from "../github/gh.ts";
|
|
6
|
+
import { Gh, run } from "../github/gh.ts";
|
|
7
7
|
import { listItems, loadBoard } from "../github/project.ts";
|
|
8
8
|
|
|
9
9
|
export default defineCommand({
|
|
@@ -38,7 +38,16 @@ export default defineCommand({
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const needsHuman = JSON.parse(
|
|
41
|
-
await
|
|
41
|
+
await run([
|
|
42
|
+
"issue",
|
|
43
|
+
"list",
|
|
44
|
+
"--repo",
|
|
45
|
+
config.repo,
|
|
46
|
+
"--label",
|
|
47
|
+
LABELS.needsHuman,
|
|
48
|
+
"--json",
|
|
49
|
+
"number,title",
|
|
50
|
+
]),
|
|
42
51
|
) as { number: number; title: string }[];
|
|
43
52
|
if (needsHuman.length) {
|
|
44
53
|
console.log(
|