kanbot 0.2.0 → 0.4.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 (62) hide show
  1. package/README.md +15 -9
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +92 -8
  4. package/src/agents/load.ts +11 -0
  5. package/src/agents/sync.ts +8 -8
  6. package/src/capabilities.ts +157 -0
  7. package/src/commands/app.ts +169 -0
  8. package/src/commands/board.ts +14 -7
  9. package/src/commands/doctor.ts +115 -6
  10. package/src/commands/init.ts +15 -3
  11. package/src/commands/skills.ts +1 -1
  12. package/src/commands/sync.ts +1 -1
  13. package/src/commands/upgrade.ts +20 -1
  14. package/src/config.ts +72 -8
  15. package/src/daemon/derive.ts +79 -1
  16. package/src/daemon/loop.ts +10 -1
  17. package/src/daemon/snapshot.ts +1 -0
  18. package/src/daemon/types.ts +17 -0
  19. package/src/github/api.ts +47 -2
  20. package/src/github/app.ts +153 -0
  21. package/src/github/gh.ts +29 -3
  22. package/src/jobs/prompts.ts +30 -6
  23. package/src/jobs/run.ts +135 -56
  24. package/src/known-hashes.json +47 -57
  25. package/src/main.ts +1 -0
  26. package/src/providers/claude.ts +40 -30
  27. package/src/providers/codex.ts +60 -30
  28. package/src/providers/mcp.ts +71 -0
  29. package/src/schemas/results.ts +2 -5
  30. package/src/templates.ts +1 -0
  31. package/templates/.agents/skills/approach-designer/SKILL.md +1 -1
  32. package/templates/.agents/skills/kanbot-admin/SKILL.md +39 -7
  33. package/templates/.agents/skills/kanbot-guide/SKILL.md +110 -0
  34. package/templates/.agents/skills/kanbot-idea/SKILL.md +1 -1
  35. package/templates/.agents/skills/plan-template/SKILL.md +14 -6
  36. package/templates/.agents/skills/pr-description/SKILL.md +7 -3
  37. package/templates/.agents/skills/project-conventions/SKILL.md +23 -35
  38. package/templates/.agents/skills/review-findings/SKILL.md +47 -0
  39. package/templates/.agents/skills/spec-review/SKILL.md +10 -27
  40. package/templates/.agents/skills/user-stories/SKILL.md +1 -1
  41. package/templates/.github/pull_request_template.md +6 -0
  42. package/templates/.kanbot/agents/acceptance-reviewer.md +43 -0
  43. package/templates/.kanbot/agents/fixer.md +7 -5
  44. package/templates/.kanbot/agents/implementer.md +49 -0
  45. package/templates/.kanbot/agents/plan-critic.md +3 -3
  46. package/templates/.kanbot/agents/planner.md +10 -9
  47. package/templates/.kanbot/agents/reviewer.md +42 -0
  48. package/templates/AGENTS.md +3 -4
  49. package/templates/.agents/skills/accessibility/SKILL.md +0 -76
  50. package/templates/.agents/skills/code-review/SKILL.md +0 -83
  51. package/templates/.agents/skills/frontend-design/LICENSE.txt +0 -177
  52. package/templates/.agents/skills/frontend-design/SKILL.md +0 -76
  53. package/templates/.agents/skills/tdd/SKILL.md +0 -51
  54. package/templates/.agents/skills/verify-ui/SKILL.md +0 -104
  55. package/templates/.kanbot/agents/implementer-backend.md +0 -57
  56. package/templates/.kanbot/agents/implementer-docs.md +0 -55
  57. package/templates/.kanbot/agents/implementer-frontend.md +0 -54
  58. package/templates/.kanbot/agents/implementer-technical.md +0 -53
  59. package/templates/.kanbot/agents/reviewer-code.md +0 -46
  60. package/templates/.kanbot/agents/reviewer-spec.md +0 -49
  61. package/templates/.kanbot/agents/test-writer.md +0 -35
  62. package/templates/.kanbot/agents/ui-verifier.md +0 -35
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,16 +48,17 @@ 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
- | Label | Meaning |
52
- | ------------------------------------- | ------------------------------------------------- |
53
- | `technical` | Plan as a technical goal, not a user story |
54
- | `type:frontend\|backend\|docs\|infra` | Picks the implementer agent (the planner sets it) |
55
- | `explore:2..4` | Build N alternative approaches as N PRs |
56
- | `agent-reviewed` | Passed agent review; ready for you |
57
- | `needs-human` | Agents gave up. Remove it to retry. |
58
- | `kanbot-review` | Agent review for your own PR, without a card |
55
+ | Label | Meaning |
56
+ | ---------------- | -------------------------------------------- |
57
+ | `technical` | Plan as a technical goal, not a user story |
58
+ | `explore:2..4` | Build N alternative approaches as N PRs |
59
+ | `agent-reviewed` | Passed agent review; ready for you |
60
+ | `needs-human` | Agents gave up. Remove it to retry. |
61
+ | `kanbot-review` | Agent review for your own PR, without a card |
59
62
 
60
63
  ## Beyond the board
61
64
 
@@ -69,6 +72,7 @@ Use `kanbot run --once --dry-run` to see what it would do. Run one daemon per re
69
72
  - `.kanbot/config.yml`: concurrency, timeouts, notifications, column names.
70
73
  - `.kanbot/agents/*.md`: agent definitions. Run `kanbot sync` after editing; don't edit the generated `.claude/` or `.codex/` files.
71
74
  - `.agents/skills/`: skills shared by agents. Fill in `project-conventions` first.
75
+ - `capabilities` in config: your own skills and agents for building, testing and reviewing. [More](./docs/13-capabilities.md)
72
76
 
73
77
  ## Upgrading
74
78
 
@@ -110,4 +114,6 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org). A `c
110
114
  2. A draft release updates with the next version and notes. `feat` bumps minor, `fix` patch. On 0.x, breaking changes bump minor.
111
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`.
112
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
+
113
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.2.0",
3
+ "version": "0.4.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",
@@ -126,14 +126,7 @@
126
126
  }
127
127
  },
128
128
  "implementers": {
129
- "default": {
130
- "frontend": "implementer-frontend",
131
- "backend": "implementer-backend",
132
- "docs": "implementer-docs",
133
- "infra": "implementer-technical",
134
- "default": "implementer-backend"
135
- },
136
- "description": "Maps a type:<key> label to an agent. `default` is the fallback.",
129
+ "description": "Deprecated: implement always uses `implementer`. While set, maps a type:<key> label to an agent, with `default` as the fallback. Use capabilities instead.",
137
130
  "type": "object",
138
131
  "propertyNames": {
139
132
  "type": "string"
@@ -142,6 +135,43 @@
142
135
  "type": "string"
143
136
  }
144
137
  },
138
+ "capabilities": {
139
+ "description": "Needs mapped to the repo's own skills or agents. Reserved: verify (prove acceptance criteria), review (technical review), preview (something a human can try). Other names are yours.",
140
+ "type": "object",
141
+ "propertyNames": {
142
+ "type": "string",
143
+ "pattern": "^[a-z][a-z0-9-]*$"
144
+ },
145
+ "additionalProperties": {
146
+ "anyOf": [
147
+ {
148
+ "type": "string",
149
+ "minLength": 1,
150
+ "description": "Skill or agent name, short for `{ use: <name> }`."
151
+ },
152
+ {
153
+ "type": "object",
154
+ "properties": {
155
+ "use": {
156
+ "type": "string",
157
+ "minLength": 1,
158
+ "description": "Skill in .agents/skills or agent in .kanbot/agents that does the work."
159
+ },
160
+ "when": {
161
+ "description": "When it applies, e.g. `Changes to UI users see`.",
162
+ "type": "string"
163
+ },
164
+ "required": {
165
+ "default": false,
166
+ "description": "A miss when it applies blocks acceptance review.",
167
+ "type": "boolean"
168
+ }
169
+ },
170
+ "required": ["use"]
171
+ }
172
+ ]
173
+ }
174
+ },
145
175
  "review": {
146
176
  "default": {},
147
177
  "type": "object",
@@ -165,6 +195,60 @@
165
195
  }
166
196
  }
167
197
  },
198
+ "identity": {
199
+ "default": {},
200
+ "type": "object",
201
+ "properties": {
202
+ "mode": {
203
+ "default": "user",
204
+ "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.",
205
+ "type": "string",
206
+ "enum": ["user", "app"]
207
+ },
208
+ "app": {
209
+ "default": null,
210
+ "description": "Set by `kanbot app create`. The private key lives in ~/.kanbot/apps/<id>.pem.",
211
+ "anyOf": [
212
+ {
213
+ "type": "object",
214
+ "properties": {
215
+ "id": {
216
+ "type": "integer",
217
+ "minimum": -9007199254740991,
218
+ "maximum": 9007199254740991,
219
+ "description": "GitHub App ID."
220
+ },
221
+ "slug": {
222
+ "type": "string",
223
+ "description": "App slug, as in github.com/apps/<slug>."
224
+ }
225
+ },
226
+ "required": ["id", "slug"]
227
+ },
228
+ {
229
+ "type": "null"
230
+ }
231
+ ]
232
+ }
233
+ }
234
+ },
235
+ "merge": {
236
+ "default": {},
237
+ "type": "object",
238
+ "properties": {
239
+ "onApproval": {
240
+ "default": false,
241
+ "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.",
242
+ "type": "boolean"
243
+ },
244
+ "method": {
245
+ "default": "squash",
246
+ "description": "Merge method used for approved PRs.",
247
+ "type": "string",
248
+ "enum": ["squash", "merge", "rebase"]
249
+ }
250
+ }
251
+ },
168
252
  "codex": {
169
253
  "default": {},
170
254
  "type": "object",
@@ -63,6 +63,17 @@ export async function loadAgents(root: string): Promise<AgentDef[]> {
63
63
  return agents;
64
64
  }
65
65
 
66
+ /** `agent: skill` for each skill an agent lists that has no .agents/skills/<skill>/SKILL.md. */
67
+ export function missingSkills(root: string, agents: AgentDef[]): string[] {
68
+ return agents.flatMap((a) =>
69
+ a.skills
70
+ .filter(
71
+ (s) => !existsSync(join(root, ".agents", "skills", s, "SKILL.md")),
72
+ )
73
+ .map((s) => `${a.name}: ${s}`),
74
+ );
75
+ }
76
+
66
77
  function validate(agents: AgentDef[]) {
67
78
  const names = new Set(agents.map((a) => a.name));
68
79
  if (names.size !== agents.length) {
@@ -42,7 +42,7 @@ export const CLAUDE_ATTRIBUTION = {
42
42
  export function claudeTools(agent: AgentDef): string[] {
43
43
  const tools = agent.tools.flatMap((t) => CLAUDE_TOOLS[t] ?? []);
44
44
  if (agent.skills.length) tools.push("Skill");
45
- if (agent.subagents.length) tools.push("Task");
45
+ if (agent.subagents.length) tools.push("Agent");
46
46
  return [...new Set(tools)];
47
47
  }
48
48
 
@@ -118,14 +118,14 @@ export type SyncFile = { path: string; content: string };
118
118
  const COPILOT_INSTRUCTIONS = join(".github", "copilot-instructions.md");
119
119
 
120
120
  /** Points Copilot code review at the same rules the kanbot reviewers use. */
121
- export const renderCopilotInstructions =
122
- () => `<!-- generated by kanbot from review.copilot in .kanbot/config.yml -->
121
+ export const renderCopilotInstructions = (review?: string) =>
122
+ `<!-- generated by kanbot from review.copilot in .kanbot/config.yml -->
123
123
 
124
124
  # Code review
125
125
 
126
126
  AI agents write most changes in this repo, from a plan in the linked issue. When you review a pull request:
127
127
 
128
- - Use the checklist and severity rubric in \`.agents/skills/code-review/SKILL.md\`.
128
+ - Use the severity rubric and finding format in \`.agents/skills/review-findings/SKILL.md\`.${review ? `\n- Review the way the repo's \`review\` capability does: \`${review}\` in \`.agents/skills/\` or \`.kanbot/agents/\`.` : ""}
129
129
  - Check the change against the plan in the linked issue and the PR description.
130
130
  - Hold it to \`AGENTS.md\`, \`.agents/skills/project-conventions/SKILL.md\` and the facts in \`.agents/skills/project-memory/\`.
131
131
  - Comment on real problems: bugs, missing tests, security, broken conventions. Leave style to the linter.
@@ -155,12 +155,12 @@ export async function sync(
155
155
  { check = false } = {},
156
156
  ): Promise<string[]> {
157
157
  const files = renderAll(agents);
158
- const copilot =
159
- existsSync(configPath(root)) && (await loadConfig(root)).review.copilot;
160
- if (copilot)
158
+ const config = existsSync(configPath(root)) ? await loadConfig(root) : null;
159
+ const copilot = config?.review.copilot ?? false;
160
+ if (config && copilot)
161
161
  files.push({
162
162
  path: COPILOT_INSTRUCTIONS,
163
- content: renderCopilotInstructions(),
163
+ content: renderCopilotInstructions(config.capabilities.review?.use),
164
164
  });
165
165
  const changed: string[] = [];
166
166
  for (const f of files) {
@@ -0,0 +1,157 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { AgentDef } from "./agents/load.ts";
5
+ import type { Config, RESERVED_CAPABILITIES } from "./config.ts";
6
+ import { TEMPLATES } from "./templates.ts";
7
+
8
+ export type Capabilities = Config["capabilities"];
9
+
10
+ /** Where a capability's `use` lives. `claude`/`codex` agents work on one provider only. */
11
+ export type CapabilitySource = "skill" | "agent" | "claude" | "codex";
12
+
13
+ export function findCapability(
14
+ root: string,
15
+ agents: AgentDef[],
16
+ use: string,
17
+ ): CapabilitySource | null {
18
+ if (existsSync(join(root, ".agents", "skills", use, "SKILL.md")))
19
+ return "skill";
20
+ if (agents.some((a) => a.name === use)) return "agent";
21
+ if (existsSync(join(root, ".claude", "agents", `${use}.md`))) return "claude";
22
+ if (existsSync(join(root, ".codex", "agents", `${use}.toml`))) return "codex";
23
+ return null;
24
+ }
25
+
26
+ /** Prompt section listing the repo's capabilities. Empty when none are mapped. */
27
+ export function capabilitiesSection(caps: Capabilities, extra = ""): string {
28
+ const rows = Object.entries(caps);
29
+ if (!rows.length) return "";
30
+ const cell = (s: string) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
31
+ return [
32
+ "## Capabilities",
33
+ `The repo maps these needs to its own skills or agents. Use one when its "when" fits the task. Required ones must be used when they apply. For needs not listed, decide yourself.${extra ? ` ${extra}` : ""}`,
34
+ "",
35
+ "| Name | When | Use | Required |",
36
+ "| --- | --- | --- | --- |",
37
+ ...rows.map(
38
+ ([name, c]) =>
39
+ `| ${name} | ${cell(c.when ?? "When it fits the task")} | \`${c.use}\` | ${c.required ? "yes" : "no"} |`,
40
+ ),
41
+ ].join("\n");
42
+ }
43
+
44
+ /** Craft skills earlier kanbot versions installed, and the capability each maps to. */
45
+ const LEGACY: [name: string, use: string, when?: string][] = [
46
+ ["verify", "verify-ui", "Changes to UI users see"],
47
+ ["review", "code-review"],
48
+ ["tdd", "tdd", "Writing or changing behaviour"],
49
+ ["ui-design", "frontend-design", "Building or restyling UI"],
50
+ ["accessibility", "accessibility", "Changes to UI users see"],
51
+ ];
52
+
53
+ /**
54
+ * A capabilities block for craft skills an earlier kanbot installed, so
55
+ * upgrades keep using them. Null when config already maps capabilities or
56
+ * none of them are present.
57
+ */
58
+ export function legacyCapabilities(
59
+ root: string,
60
+ current: Capabilities,
61
+ ): Capabilities | null {
62
+ if (Object.keys(current).length) return null;
63
+ const found = LEGACY.filter(([, use]) =>
64
+ existsSync(join(root, ".agents", "skills", use, "SKILL.md")),
65
+ );
66
+ if (!found.length) return null;
67
+ return Object.fromEntries(
68
+ found.map(([name, use, when]) => [
69
+ name,
70
+ { use, ...(when ? { when } : {}), required: false },
71
+ ]),
72
+ );
73
+ }
74
+
75
+ export const RESERVED: Record<(typeof RESERVED_CAPABILITIES)[number], string> =
76
+ {
77
+ verify: "prove acceptance criteria",
78
+ review: "technical review",
79
+ preview: "something a human can try",
80
+ };
81
+
82
+ /** Skills and agents in the repo that kanbot didn't ship. */
83
+ export async function ownWorkers(root: string): Promise<string[]> {
84
+ const list = async (dir: string, ext?: string) =>
85
+ existsSync(dir)
86
+ ? (await readdir(dir, { withFileTypes: true }))
87
+ .filter((e) => (ext ? e.name.endsWith(ext) : e.isDirectory()))
88
+ .map((e) => (ext ? e.name.slice(0, -ext.length) : e.name))
89
+ : [];
90
+ const shipped = new Set([
91
+ ...(await list(join(TEMPLATES, ".agents", "skills"))),
92
+ ...(await list(join(TEMPLATES, ".kanbot", "agents"), ".md")),
93
+ ]);
94
+ const all = [
95
+ ...(await list(join(root, ".agents", "skills"))),
96
+ ...(await list(join(root, ".kanbot", "agents"), ".md")),
97
+ ];
98
+ return [...new Set(all)].filter((n) => !shipped.has(n)).sort();
99
+ }
100
+
101
+ /**
102
+ * Asks which of the repo's own skills or agents fill each reserved need.
103
+ * `ask` returns the answer, or null/empty to skip.
104
+ */
105
+ export function askCapabilities(
106
+ workers: string[],
107
+ ask: (question: string) => string | null,
108
+ say: (line: string) => void = console.log,
109
+ ): Capabilities {
110
+ const caps: Capabilities = {};
111
+ if (!workers.length) return caps;
112
+ say(
113
+ `Your skills and agents:\n${workers.map((w, i) => ` ${i + 1}. ${w}`).join("\n")}`,
114
+ );
115
+ const yes = ask("Map them to kanbot capabilities now? [y/N]");
116
+ if (!/^y/i.test(yes?.trim() ?? "")) return caps;
117
+ for (const [name, what] of Object.entries(RESERVED)) {
118
+ const answer = ask(
119
+ `${name} (${what}): number or name, Enter to skip`,
120
+ )?.trim();
121
+ if (!answer) continue;
122
+ const use = workers[Number(answer) - 1] ?? answer;
123
+ if (workers.includes(use)) caps[name] = { use, required: false };
124
+ else say(` "${answer}" not found, skipped ${name}.`);
125
+ }
126
+ return caps;
127
+ }
128
+
129
+ /** One line: what is mapped, and which reserved needs aren't. */
130
+ export function capabilitySummary(caps: Capabilities): string {
131
+ const mapped = Object.entries(caps).map(([n, c]) => `${n} → ${c.use}`);
132
+ const missing = Object.keys(RESERVED).filter((n) => !caps[n]);
133
+ return [
134
+ mapped.join(", ") || "none mapped",
135
+ missing.length
136
+ ? `could add ${missing.join(", ")} (kanbot-guide can help)`
137
+ : "",
138
+ ]
139
+ .filter(Boolean)
140
+ .join("; ");
141
+ }
142
+
143
+ /** Capabilities as written to config: a bare name when that says it all. */
144
+ export function compact(caps: Capabilities) {
145
+ return Object.fromEntries(
146
+ Object.entries(caps).map(([name, c]) => [
147
+ name,
148
+ !c.when && !c.required
149
+ ? c.use
150
+ : {
151
+ use: c.use,
152
+ ...(c.when ? { when: c.when } : {}),
153
+ ...(c.required ? { required: true } : {}),
154
+ },
155
+ ]),
156
+ );
157
+ }
@@ -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, "&amp;").replace(/"/g, "&quot;");
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,5 +1,6 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { LABELS, loadConfig, saveConfig } from "../config.ts";
3
+ import { UserError } from "../errors.ts";
3
4
  import { ensureCopilotRuleset, ensureLabel } from "../github/api.ts";
4
5
  import { Gh } from "../github/gh.ts";
5
6
  import { createBoard, loadBoard, missingColumns } from "../github/project.ts";
@@ -12,10 +13,6 @@ export const REPO_LABELS: [name: string, color: string, description: string][] =
12
13
  "5319e7",
13
14
  "Technical work; planned as a technical goal instead of a user story",
14
15
  ],
15
- ["type:frontend", "1d76db", "User-facing UI work"],
16
- ["type:backend", "0e8a16", "Server, data or API work"],
17
- ["type:docs", "c5def5", "Documentation"],
18
- ["type:infra", "bfd4f2", "Tooling, CI, infrastructure"],
19
16
  [
20
17
  "explore:2",
21
18
  "fbca04",
@@ -62,7 +59,7 @@ const create = defineCommand({
62
59
  meta: {
63
60
  name: "create",
64
61
  description:
65
- "Create a GitHub Project board for this repo with the kanbot columns",
62
+ "Create a GitHub Project board for this repo with the kanbot columns and labels",
66
63
  },
67
64
  args: {
68
65
  title: {
@@ -109,7 +106,11 @@ ${board.url}`);
109
106
  });
110
107
 
111
108
  const link = defineCommand({
112
- meta: { name: "link", description: "Use an existing project board" },
109
+ meta: {
110
+ name: "link",
111
+ description:
112
+ "Use an existing project board, e.g. github.com/users/<owner>/projects/<number>",
113
+ },
113
114
  args: {
114
115
  owner: {
115
116
  type: "string",
@@ -120,8 +121,14 @@ const link = defineCommand({
120
121
  },
121
122
  async run({ args }) {
122
123
  const root = process.cwd();
124
+ const number = Number(args.number);
125
+ if (!Number.isInteger(number))
126
+ throw new UserError("--number must be a number.", {
127
+ why: "It's the number at the end of the project URL.",
128
+ docs: "getting-started#create-the-board",
129
+ });
123
130
  await saveConfig(root, {
124
- project: { owner: args.owner, number: Number(args.number) },
131
+ project: { owner: args.owner, number },
125
132
  });
126
133
  const config = await loadConfig(root);
127
134
  const board = await loadBoard(new Gh(config.repo), config);