kanbot 0.5.2 → 0.6.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 CHANGED
@@ -114,6 +114,6 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org). A `c
114
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
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
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.
117
+ Docs: [kanbot.mib.im](https://kanbot.mib.im) shows the latest release plus later commits that only touch `docs/` or `README.md`, so docs fixes need no 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
118
 
119
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.5.2",
3
+ "version": "0.6.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",
@@ -79,6 +79,27 @@
79
79
  "minimum": 1,
80
80
  "maximum": 9007199254740991
81
81
  },
82
+ "wip": {
83
+ "description": "Work-in-progress limits.",
84
+ "default": {},
85
+ "type": "object",
86
+ "properties": {
87
+ "awaitingHuman": {
88
+ "default": null,
89
+ "description": "When this many items wait on you (`kanbot status`), no new Ready card starts. Started work runs on. null: no limit.",
90
+ "anyOf": [
91
+ {
92
+ "type": "integer",
93
+ "minimum": 1,
94
+ "maximum": 9007199254740991
95
+ },
96
+ {
97
+ "type": "null"
98
+ }
99
+ ]
100
+ }
101
+ }
102
+ },
82
103
  "pollSeconds": {
83
104
  "default": 60,
84
105
  "description": "Seconds between board polls.",
package/src/ask.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { color } from "./color.ts";
2
+
3
+ /** Reads one answer. Null means no answer (end of input). */
4
+ export type Ask = (question: string) => string | null;
5
+ export type Say = (line: string) => void;
6
+
7
+ export type Choice<T> = { value: T; label: string; hint?: string };
8
+
9
+ /** Numbered list; Enter picks the default. */
10
+ export function choose<T>(
11
+ ask: Ask,
12
+ say: Say,
13
+ question: string,
14
+ choices: Choice<T>[],
15
+ def = 0,
16
+ ): T {
17
+ say(color.bold(question));
18
+ choices.forEach((c, i) => {
19
+ const hint = c.hint ? color.dim(` — ${c.hint}`) : "";
20
+ say(` ${i + 1}. ${c.label}${hint}`);
21
+ });
22
+ for (;;) {
23
+ const a = ask(`Pick 1-${choices.length} [${def + 1}]:`)?.trim();
24
+ if (!a) return (choices[def] as Choice<T>).value;
25
+ const picked =
26
+ choices[Number(a) - 1] ??
27
+ choices.find((c) => String(c.value) === a || c.label === a);
28
+ if (picked) return picked.value;
29
+ say(` Type a number from 1 to ${choices.length}.`);
30
+ }
31
+ }
32
+
33
+ export function confirm(ask: Ask, question: string, def: boolean): boolean {
34
+ const a = ask(`${question} ${def ? "[Y/n]" : "[y/N]"}`)?.trim();
35
+ if (!a) return def;
36
+ return /^y/i.test(a);
37
+ }
38
+
39
+ /** A whole number ≥ min. With `none`, typing it returns null. */
40
+ export function askNumber(
41
+ ask: Ask,
42
+ say: Say,
43
+ question: string,
44
+ def: number | null,
45
+ opts: { min?: number; none?: string } = {},
46
+ ): number | null {
47
+ const min = opts.min ?? 1;
48
+ const shown = def ?? opts.none;
49
+ for (;;) {
50
+ const a = ask(`${question} [${shown}]`)?.trim();
51
+ if (!a) return def;
52
+ if (opts.none && a === opts.none) return null;
53
+ const n = Number(a);
54
+ if (Number.isInteger(n) && n >= min) return n;
55
+ say(
56
+ ` Type a whole number, ${min} or more${opts.none ? `, or "${opts.none}"` : ""}.`,
57
+ );
58
+ }
59
+ }
60
+
61
+ /** Free text; Enter keeps the default. `valid` returns an error to ask again. */
62
+ export function askText(
63
+ ask: Ask,
64
+ say: Say,
65
+ question: string,
66
+ valid: (s: string) => string | null = () => null,
67
+ ): string | null {
68
+ for (;;) {
69
+ const a = ask(question)?.trim();
70
+ if (!a) return null;
71
+ const err = valid(a);
72
+ if (!err) return a;
73
+ say(` ${err}`);
74
+ }
75
+ }
76
+
77
+ /** Opens a URL in the default browser. Silent if it can't. */
78
+ export function openBrowser(url: string) {
79
+ const cmd =
80
+ process.platform === "darwin"
81
+ ? "open"
82
+ : process.platform === "win32"
83
+ ? "explorer"
84
+ : "xdg-open";
85
+ try {
86
+ Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" });
87
+ } catch {}
88
+ }
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { chmod, mkdir } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
5
  import { defineCommand } from "citty";
6
+ import { openBrowser } from "../ask.ts";
6
7
  import { loadConfig, saveConfig } from "../config.ts";
7
8
  import { UserError } from "../errors.ts";
8
9
  import { appKeyPath, appManifest, convertManifest } from "../github/app.ts";
@@ -11,18 +12,6 @@ import { log } from "../log.ts";
11
12
 
12
13
  const WAIT_MS = 10 * 60_000;
13
14
 
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
15
  const escAttr = (s: string) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
27
16
 
28
17
  /** A page that posts the manifest to GitHub, as the manifest flow requires. */
@@ -40,6 +29,77 @@ const page = (msg: string) =>
40
29
  { headers: { "content-type": "text/html" } },
41
30
  );
42
31
 
32
+ /** Creates a GitHub App in the browser and switches config to app mode. */
33
+ export async function createApp(root: string, name?: string) {
34
+ const config = await loadConfig(root);
35
+ const gh = new Gh(config.repo);
36
+ const owner = gh.owner;
37
+ const { type } = await gh.rest<{ type: string }>(`users/${owner}`);
38
+ const appName = (name ?? `kanbot-${owner}`).slice(0, 34);
39
+ const state = randomUUID();
40
+ const action =
41
+ type === "Organization"
42
+ ? `https://github.com/organizations/${owner}/settings/apps/new?state=${state}`
43
+ : `https://github.com/settings/apps/new?state=${state}`;
44
+
45
+ const got = Promise.withResolvers<string>();
46
+ const server = Bun.serve({
47
+ port: 0,
48
+ hostname: "localhost",
49
+ fetch(req): Response {
50
+ const url = new URL(req.url);
51
+ if (url.pathname === "/")
52
+ return new Response(
53
+ formPage(
54
+ action,
55
+ appManifest(appName, `http://localhost:${url.port}/callback`),
56
+ ),
57
+ { headers: { "content-type": "text/html" } },
58
+ );
59
+ if (url.pathname !== "/callback")
60
+ return new Response("", { status: 404 });
61
+ const code = url.searchParams.get("code");
62
+ if (url.searchParams.get("state") !== state || !code)
63
+ return page("Unexpected request. Run `kanbot app create` again.");
64
+ got.resolve(code);
65
+ return page("App created. Go back to your terminal.");
66
+ },
67
+ });
68
+ const start = `http://localhost:${server.port}/`;
69
+ log.info(`Opening ${start} — confirm the app on GitHub.`);
70
+ openBrowser(start);
71
+ const timer = setTimeout(
72
+ () => got.reject(new Error("timed out waiting for GitHub")),
73
+ WAIT_MS,
74
+ );
75
+ let code: string;
76
+ try {
77
+ code = await got.promise;
78
+ } catch (e) {
79
+ throw new UserError("No app was created.", {
80
+ why: String(e),
81
+ fix: "kanbot app create",
82
+ docs: "identity#app-mode",
83
+ });
84
+ } finally {
85
+ clearTimeout(timer);
86
+ server.stop();
87
+ }
88
+
89
+ const app = await convertManifest(code);
90
+ const keyFile = appKeyPath(app.id);
91
+ await mkdir(dirname(keyFile), { recursive: true });
92
+ await Bun.write(keyFile, app.pem);
93
+ await chmod(keyFile, 0o600);
94
+ await saveConfig(root, {
95
+ identity: { mode: "app", app: { id: app.id, slug: app.slug } },
96
+ });
97
+ const install = `https://github.com/apps/${app.slug}/installations/new`;
98
+ log.info(`Created ${app.slug} (id ${app.id}). Key: ${keyFile}`);
99
+ log.info(`Now install it on ${config.repo}: ${install}`);
100
+ openBrowser(install);
101
+ }
102
+
43
103
  const create = defineCommand({
44
104
  meta: {
45
105
  name: "create",
@@ -53,74 +113,7 @@ const create = defineCommand({
53
113
  },
54
114
  },
55
115
  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);
116
+ await createApp(process.cwd(), args.name);
124
117
  console.log(
125
118
  "\nThen commit .kanbot/config.yml, run `kanbot doctor` and restart the daemon.",
126
119
  );
@@ -1,9 +1,16 @@
1
1
  import { defineCommand } from "citty";
2
- import { LABELS, loadConfig, saveConfig } from "../config.ts";
2
+ import { type Config, LABELS, loadConfig, saveConfig } from "../config.ts";
3
3
  import { UserError } from "../errors.ts";
4
4
  import { ensureCopilotRuleset, ensureLabel } from "../github/api.ts";
5
5
  import { Gh } from "../github/gh.ts";
6
- import { createBoard, loadBoard, missingColumns } from "../github/project.ts";
6
+ import {
7
+ type Board,
8
+ createBoard,
9
+ loadBoard,
10
+ missingColumns,
11
+ neededWorkflows,
12
+ workflowsUrl,
13
+ } from "../github/project.ts";
7
14
  import { log } from "../log.ts";
8
15
 
9
16
  export const REPO_LABELS: [name: string, color: string, description: string][] =
@@ -55,6 +62,76 @@ export async function ensureLabels(gh: Gh) {
55
62
  await ensureLabel(gh, name, color, description);
56
63
  }
57
64
 
65
+ /**
66
+ * Creates the board (or keeps the configured one), labels and Copilot
67
+ * ruleset, then prints the workflows to turn on. Returns the board, or null
68
+ * if creation was skipped.
69
+ */
70
+ export async function setupBoard(root: string, title?: string) {
71
+ const config = await loadConfig(root);
72
+ const gh = new Gh(config.repo);
73
+ if (config.project) {
74
+ log.info(
75
+ `Project already configured (${config.project.owner} #${config.project.number}); only ensuring labels.`,
76
+ );
77
+ } else {
78
+ const project = await createBoard(gh, config, title ?? gh.name);
79
+ if (!project) return null;
80
+ await saveConfig(root, {
81
+ project: { owner: project.owner, number: project.number },
82
+ });
83
+ log.info(`Created project ${project.url}`);
84
+ }
85
+ await ensureLabels(gh);
86
+ log.info(`Labels ensured: ${REPO_LABELS.map(([n]) => n).join(", ")}`);
87
+ if (config.review.copilot) {
88
+ await ensureCopilotRuleset(gh, config.review.copilotOnPush);
89
+ log.info("Copilot review ruleset ensured.");
90
+ }
91
+ const board = await loadBoard(gh, await loadConfig(root));
92
+ warnMissing(board);
93
+ printWorkflows(config, board);
94
+ return board;
95
+ }
96
+
97
+ /** Saves an existing project as the board and adds the labels. */
98
+ export async function linkBoard(root: string, owner: string, number: number) {
99
+ await saveConfig(root, { project: { owner, number } });
100
+ const config = await loadConfig(root);
101
+ const gh = new Gh(config.repo);
102
+ const board = await loadBoard(gh, config);
103
+ await ensureLabels(gh);
104
+ log.info(`Linked ${board.url}`);
105
+ warnMissing(board);
106
+ return board;
107
+ }
108
+
109
+ function warnMissing(board: Board) {
110
+ const missing = missingColumns(board);
111
+ if (missing.length)
112
+ log.warn(
113
+ `Board is missing columns: ${missing.join(", ")} — add them to the Status field.`,
114
+ );
115
+ }
116
+
117
+ export function printWorkflows(config: Config, board: Board) {
118
+ const steps = neededWorkflows(config).map(
119
+ (w) => ` • ${w.name.padEnd(21)} → ${w.set}`,
120
+ );
121
+ console.log(`
122
+ Turn on these built-in workflows. GitHub's API can't, so do it here:
123
+ ${workflowsUrl(board)}
124
+ ${steps.join("\n")}`);
125
+ }
126
+
127
+ /** Owner and number from a project URL like github.com/users/<owner>/projects/<n>. */
128
+ export function parseProjectUrl(url: string) {
129
+ const m = url.match(
130
+ /github\.com\/(?:users|orgs)\/([^/\s]+)\/projects\/(\d+)/,
131
+ );
132
+ return m ? { owner: m[1] as string, number: Number(m[2]) } : null;
133
+ }
134
+
58
135
  const create = defineCommand({
59
136
  meta: {
60
137
  name: "create",
@@ -68,40 +145,8 @@ const create = defineCommand({
68
145
  },
69
146
  },
70
147
  async run({ args }) {
71
- const root = process.cwd();
72
- const config = await loadConfig(root);
73
- const gh = new Gh(config.repo);
74
- if (config.project) {
75
- log.info(
76
- `Project already configured (${config.project.owner} #${config.project.number}); only ensuring labels.`,
77
- );
78
- } else {
79
- const project = await createBoard(gh, config, args.title ?? gh.name);
80
- if (!project) return;
81
- await saveConfig(root, {
82
- project: { owner: project.owner, number: project.number },
83
- });
84
- log.info(`Created project ${project.url}`);
85
- }
86
- await ensureLabels(gh);
87
- log.info(`Labels ensured: ${REPO_LABELS.map(([n]) => n).join(", ")}`);
88
- if (config.review.copilot) {
89
- await ensureCopilotRuleset(gh, config.review.copilotOnPush);
90
- log.info("Copilot review ruleset ensured.");
91
- }
92
- const board = await loadBoard(gh, await loadConfig(root));
93
- const missing = missingColumns(board);
94
- if (missing.length)
95
- log.warn(
96
- `Board is missing columns: ${missing.join(", ")} — add them to the Status field.`,
97
- );
98
- console.log(`
99
- Enable these built-in project workflows (Project → ⋯ → Workflows), they can't be set via the API:
100
- • Item closed → set Status: ${config.columns.done}
101
- • Pull request merged → set Status: ${config.columns.done}
102
- • Auto-add to project → filter is:issue repo:${config.repo} (new issues land in the board)
103
- • Item added to project → set Status: ${config.columns.ideas}
104
- ${board.url}`);
148
+ if (await setupBoard(process.cwd(), args.title))
149
+ console.log("Then run kanbot doctor.");
105
150
  },
106
151
  });
107
152
 
@@ -120,24 +165,15 @@ const link = defineCommand({
120
165
  number: { type: "string", required: true, description: "Project number" },
121
166
  },
122
167
  async run({ args }) {
123
- const root = process.cwd();
124
168
  const number = Number(args.number);
125
169
  if (!Number.isInteger(number))
126
170
  throw new UserError("--number must be a number.", {
127
171
  why: "It's the number at the end of the project URL.",
128
172
  docs: "getting-started#create-the-board",
129
173
  });
130
- await saveConfig(root, {
131
- project: { owner: args.owner, number },
132
- });
133
- const config = await loadConfig(root);
134
- const board = await loadBoard(new Gh(config.repo), config);
135
- const missing = missingColumns(board);
136
- log.info(
137
- missing.length
138
- ? `Linked, but missing columns: ${missing.join(", ")}`
139
- : `Linked ${board.url}`,
140
- );
174
+ const config = await loadConfig(process.cwd());
175
+ const board = await linkBoard(process.cwd(), args.owner, number);
176
+ printWorkflows(config, board);
141
177
  },
142
178
  });
143
179
 
@@ -15,7 +15,10 @@ import {
15
15
  boardWorkflows,
16
16
  loadBoard,
17
17
  missingColumns,
18
+ neededWorkflows,
19
+ workflowsUrl,
18
20
  } from "../github/project.ts";
21
+ import { LOGIN_FIX, providerLogin } from "../providers/login.ts";
19
22
  import {
20
23
  codexSupports,
21
24
  type McpServer,
@@ -171,27 +174,25 @@ export default defineCommand({
171
174
  return summary(checks);
172
175
  }
173
176
 
177
+ const login = await providerLogin(config.provider);
174
178
  if (config.provider === "claude") {
175
179
  section("Claude Code");
176
- const s = await cmd(["claude", "auth", "status"]);
177
- const sub = /"authMethod":\s*"claude\.ai"/.test(s.out);
178
180
  add({
179
181
  name: "claude CLI logged in with a subscription",
180
- ok: s.ok && sub,
181
- detail: sub ? undefined : "Not using a claude.ai login.",
182
+ ok: login.ok,
183
+ detail: login.installed ? login.detail : "claude is not installed.",
182
184
  why: "Workers run `claude`. An API key login bills per token instead of your plan.",
183
- fix: "claude auth login",
185
+ fix: LOGIN_FIX.claude,
184
186
  docs: "providers#claude-code",
185
187
  });
186
188
  } else {
187
189
  section("Codex");
188
- const s = await cmd(["codex", "login", "status"]);
189
190
  add({
190
191
  name: "codex CLI logged in with ChatGPT",
191
- ok: s.ok && /ChatGPT/i.test(s.out),
192
- detail: s.out.split("\n")[0],
192
+ ok: login.ok,
193
+ detail: login.installed ? login.detail : "codex is not installed.",
193
194
  why: "Workers run `codex` on your ChatGPT plan.",
194
- fix: "codex login",
195
+ fix: LOGIN_FIX.codex,
195
196
  docs: "providers#codex",
196
197
  });
197
198
  add({
@@ -402,16 +403,17 @@ export default defineCommand({
402
403
  });
403
404
  // Moving cards to Done on close/merge and adding new issues are left to GitHub.
404
405
  const workflows = await boardWorkflows(gh, board);
405
- for (const name of [
406
- "Item closed",
407
- "Pull request merged",
408
- "Auto-add to project",
409
- ]) {
406
+ for (const w of neededWorkflows(config)) {
407
+ const on = workflows.some((x) => x.name === w.name && x.enabled);
410
408
  add({
411
- name: `board workflow "${name}" enabled`,
412
- ok: workflows.some((w) => w.name === name && w.enabled),
413
- why: "kanbot leaves adding new issues and moving closed ones to Done to GitHub.",
414
- fix: `enable it in ${board.url} → ⋯ → Workflows`,
409
+ name: `board workflow "${w.name}" enabled`,
410
+ ok: on || !w.required,
411
+ warn: !on && !w.required,
412
+ why: w.required
413
+ ? "kanbot leaves adding new issues and moving closed ones to Done to GitHub."
414
+ : `Without it, new issues land with no Status and kanbot ignores them until you set one.`,
415
+ fix: `open ${workflowsUrl(board)} → "${w.name}" → ${w.set} → turn it on`,
416
+ docs: "getting-started#create-the-board",
415
417
  });
416
418
  }
417
419
  } catch (e) {