kanbot 0.4.0 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbot",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",
package/src/color.ts ADDED
@@ -0,0 +1,16 @@
1
+ // ANSI colors for TTY output. Off when piped or NO_COLOR is set; FORCE_COLOR turns them on.
2
+ const on =
3
+ !process.env.NO_COLOR &&
4
+ (!!process.env.FORCE_COLOR || !!process.stdout.isTTY);
5
+
6
+ const wrap = (open: number, close: number) => (s: string) =>
7
+ on ? `\x1b[${open}m${s}\x1b[${close}m` : s;
8
+
9
+ export const color = {
10
+ bold: wrap(1, 22),
11
+ dim: wrap(2, 22),
12
+ red: wrap(31, 39),
13
+ green: wrap(32, 39),
14
+ yellow: wrap(33, 39),
15
+ cyan: wrap(36, 39),
16
+ };
@@ -4,9 +4,10 @@ import { defineCommand } from "citty";
4
4
  import { loadAgents, missingSkills } from "../agents/load.ts";
5
5
  import { sync } from "../agents/sync.ts";
6
6
  import { capabilitySummary, findCapability } from "../capabilities.ts";
7
+ import { color } from "../color.ts";
7
8
  import { loadConfig, writeConfigSchema } from "../config.ts";
8
9
  import { git } from "../daemon/git.ts";
9
- import { describeError } from "../errors.ts";
10
+ import { DOCS_URL, describeError } from "../errors.ts";
10
11
  import { copilotReviewRule } from "../github/api.ts";
11
12
  import { appKeyPath, appTokens } from "../github/app.ts";
12
13
  import { Gh, run } from "../github/gh.ts";
@@ -29,7 +30,11 @@ type Check = {
29
30
  /** Shown with a warning mark; never fails doctor. */
30
31
  warn?: boolean;
31
32
  detail?: string;
33
+ /** Why it matters. Shown only when the check fails or warns. */
34
+ why?: string;
32
35
  fix?: string;
36
+ /** Docs page and anchor, e.g. "configuration#config-file". */
37
+ docs?: string;
33
38
  };
34
39
 
35
40
  async function cmd(args: string[]) {
@@ -45,13 +50,69 @@ async function cmd(args: string[]) {
45
50
  /** Failure detail and fix for a check, from a thrown error. */
46
51
  function explain(e: unknown, fallback?: string) {
47
52
  const d = describeError(e);
48
- const detail = [d.message, d.why].filter(Boolean).join("\n");
49
53
  return {
50
- detail: detail.replace(/\n/g, "\n "),
54
+ detail: d.message,
55
+ why: d.why,
51
56
  fix: d.fix ?? fallback,
57
+ docs: d.docs,
52
58
  };
53
59
  }
54
60
 
61
+ const indent = (s: string, pad: string) => s.replace(/^(?=.)/gm, pad);
62
+
63
+ function print(c: Check) {
64
+ const state = c.warn ? "warn" : c.ok ? "ok" : "fail";
65
+ const mark = {
66
+ ok: color.green("✔"),
67
+ warn: color.yellow("!"),
68
+ fail: color.red("✖"),
69
+ }[state];
70
+ if (state === "ok") {
71
+ const detail = c.detail ? color.dim(` — ${c.detail}`) : "";
72
+ console.log(` ${mark} ${c.name}${detail}`);
73
+ return;
74
+ }
75
+ const paint = state === "warn" ? color.yellow : color.red;
76
+ const lines = [` ${mark} ${paint(c.name)}`];
77
+ if (c.detail) lines.push(indent(c.detail, " "));
78
+ if (c.why) lines.push(color.dim(indent(c.why, " ")));
79
+ if (c.fix) lines.push(` ${color.bold("Fix:")} ${color.cyan(c.fix)}`);
80
+ if (c.docs)
81
+ lines.push(
82
+ ` ${color.bold("Docs:")} ${color.dim(`${DOCS_URL}/${c.docs}`)}`,
83
+ );
84
+ console.log(lines.join("\n"));
85
+ }
86
+
87
+ function section(title: string) {
88
+ console.log(`\n${color.bold(title)}`);
89
+ }
90
+
91
+ /** Print totals and next step, then exit 1 if anything failed. */
92
+ function summary(checks: Check[]) {
93
+ const failed = checks.filter((c) => !c.ok).length;
94
+ const warned = checks.filter((c) => c.ok && c.warn).length;
95
+ const passed = checks.length - failed - warned;
96
+ const parts = [color.green(`${passed} passed`)];
97
+ if (warned)
98
+ parts.push(
99
+ color.yellow(`${warned} ${warned === 1 ? "warning" : "warnings"}`),
100
+ );
101
+ if (failed) parts.push(color.red(`${failed} failed`));
102
+ console.log(`\n${parts.join(color.dim(" · "))}`);
103
+ if (failed) {
104
+ console.log(
105
+ `Fix the ${color.red("✖")} items above, top to bottom, then run ${color.cyan("kanbot doctor")} again.`,
106
+ );
107
+ process.exit(1);
108
+ }
109
+ if (warned)
110
+ console.log(
111
+ `Ready to run. The ${color.yellow("!")} items are worth a look, but won't stop kanbot.`,
112
+ );
113
+ else console.log(`All set. Start kanbot with ${color.cyan("kanbot run")}.`);
114
+ }
115
+
55
116
  export default defineCommand({
56
117
  meta: {
57
118
  name: "doctor",
@@ -62,19 +123,32 @@ export default defineCommand({
62
123
  const checks: Check[] = [];
63
124
  const add = (c: Check) => {
64
125
  checks.push(c);
65
- console.log(
66
- `${c.warn ? "!" : c.ok ? "✔" : "✖"} ${c.name}${c.detail ? ` — ${c.detail}` : ""}${!c.ok && c.fix ? `\n fix: ${c.fix}` : ""}`,
67
- );
126
+ print(c);
68
127
  };
69
128
 
129
+ console.log(
130
+ `${color.bold("kanbot doctor")} ${color.dim("checks that everything kanbot needs is in place.")}`,
131
+ );
132
+
133
+ section("GitHub CLI");
70
134
  const auth = await cmd(["gh", "auth", "status"]);
71
- add({ name: "gh logged in", ok: auth.ok, fix: "gh auth login" });
135
+ add({
136
+ name: "gh logged in",
137
+ ok: auth.ok,
138
+ why: "kanbot talks to GitHub through gh.",
139
+ fix: "gh auth login",
140
+ docs: "getting-started#requirements",
141
+ });
72
142
  add({
73
143
  name: "gh token has project scope",
74
144
  ok: /'project'/.test(auth.out),
145
+ why: "Needed to read and move cards on the project board.",
75
146
  fix: "gh auth refresh -s project",
147
+ docs: "getting-started#requirements",
76
148
  });
77
149
 
150
+ section("Config");
151
+
78
152
  let config: Awaited<ReturnType<typeof loadConfig>>;
79
153
  try {
80
154
  config = await loadConfig(root);
@@ -85,27 +159,34 @@ export default defineCommand({
85
159
  });
86
160
  } catch (e) {
87
161
  add({ name: "config", ok: false, ...explain(e, "kanbot init") });
88
- return process.exit(1);
162
+ console.log(
163
+ color.dim("\n The other checks need a config, so doctor stops here."),
164
+ );
165
+ return summary(checks);
89
166
  }
90
167
 
91
168
  if (config.provider === "claude") {
169
+ section("Claude Code");
92
170
  const s = await cmd(["claude", "auth", "status"]);
93
171
  const sub = /"authMethod":\s*"claude\.ai"/.test(s.out);
94
172
  add({
95
173
  name: "claude CLI logged in with a subscription",
96
174
  ok: s.ok && sub,
97
- detail: sub
98
- ? undefined
99
- : "not using a claude.ai login (would bill API usage)",
175
+ detail: sub ? undefined : "Not using a claude.ai login.",
176
+ why: "Workers run `claude`. An API key login bills per token instead of your plan.",
100
177
  fix: "claude auth login",
178
+ docs: "providers#claude-code",
101
179
  });
102
180
  } else {
181
+ section("Codex");
103
182
  const s = await cmd(["codex", "login", "status"]);
104
183
  add({
105
184
  name: "codex CLI logged in with ChatGPT",
106
185
  ok: s.ok && /ChatGPT/i.test(s.out),
107
186
  detail: s.out.split("\n")[0],
187
+ why: "Workers run `codex` on your ChatGPT plan.",
108
188
  fix: "codex login",
189
+ docs: "providers#codex",
109
190
  });
110
191
  add({
111
192
  name: "codex workers ignore ~/.codex/config.toml",
@@ -115,6 +196,7 @@ export default defineCommand({
115
196
  });
116
197
  }
117
198
 
199
+ section("Agents and skills");
118
200
  try {
119
201
  const agents = await loadAgents(root);
120
202
  add({
@@ -137,6 +219,7 @@ export default defineCommand({
137
219
  name: "agents referenced by config exist",
138
220
  ok: !missing.length,
139
221
  detail: missing.join(", ") || undefined,
222
+ why: "Columns hand work to these agents, but .kanbot/agents has no file for them.",
140
223
  fix: "kanbot upgrade",
141
224
  });
142
225
  if (config.implementers)
@@ -153,6 +236,7 @@ export default defineCommand({
153
236
  name: "skills referenced by agents exist",
154
237
  ok: !noSkill.length,
155
238
  detail: noSkill.join(", ") || undefined,
239
+ why: "Agents list these skills, but .agents/skills has none by that name.",
156
240
  fix: "add them to .agents/skills or remove them from the agent's `skills`",
157
241
  });
158
242
  add({
@@ -171,6 +255,9 @@ export default defineCommand({
171
255
  detail: found
172
256
  ? `${found} agent, works on ${found} only`
173
257
  : "not found",
258
+ why: found
259
+ ? undefined
260
+ : "Jobs that need this capability can't load it.",
174
261
  fix: "add the skill to .agents/skills or the agent to .kanbot/agents",
175
262
  });
176
263
  }
@@ -179,12 +266,15 @@ export default defineCommand({
179
266
  name: "generated agent files up to date",
180
267
  ok: !stale.length,
181
268
  detail: stale.join(", ") || undefined,
269
+ why: "Workers read .claude/agents and .codex/agents, which are generated from .kanbot/agents.",
182
270
  fix: "kanbot sync",
183
271
  });
184
272
  add({
185
273
  name: "config schema up to date",
186
274
  ok: !(await writeConfigSchema(root, { check: true })),
275
+ why: "Editors use it for autocomplete and errors in config.yml.",
187
276
  fix: "kanbot sync",
277
+ docs: "configuration",
188
278
  });
189
279
  } catch (e) {
190
280
  add({ name: "agents", ok: false, ...explain(e) });
@@ -208,6 +298,7 @@ export default defineCommand({
208
298
  .join("; "),
209
299
  });
210
300
 
301
+ section("Templates");
211
302
  const plan = await planUpgrade(root);
212
303
  const behind = plan.changes.filter((c) =>
213
304
  ["add", "update", "conflict"].includes(c.action),
@@ -218,16 +309,21 @@ export default defineCommand({
218
309
  detail: behind.length
219
310
  ? `${behind.length} file(s) differ from this kanbot version`
220
311
  : undefined,
312
+ why: "You get fixes to agents, skills and workflows by upgrading.",
221
313
  fix: "kanbot upgrade",
314
+ docs: "upgrading",
222
315
  });
223
316
  const merges = await pendingMerges(root);
224
317
  add({
225
318
  name: `no unmerged ${NEW_SUFFIX} files`,
226
319
  ok: !merges.length,
227
320
  detail: merges.join(", ") || undefined,
321
+ why: "upgrade put the new version next to files you changed.",
322
+ docs: "upgrading",
228
323
  fix: `merge each into the file next to it and delete it (kanbot-admin skill can help)`,
229
324
  });
230
325
 
326
+ section(`Pushed to origin/${config.baseBranch}`);
231
327
  await git(root, "fetch", "origin", config.baseBranch).catch(() => {});
232
328
  const tree = await git(
233
329
  root,
@@ -245,15 +341,18 @@ export default defineCommand({
245
341
  add({
246
342
  name: `${path} committed on origin/${config.baseBranch}`,
247
343
  ok: tree.split("\n").some((f) => f.startsWith(path)),
248
- detail: "worktrees are created from the base branch",
344
+ why: "Workers run in worktrees made from the base branch, so they only see what is pushed.",
249
345
  fix: "commit and push the kanbot files",
250
346
  });
251
347
  }
252
348
  add({
253
349
  name: ".github/workflows/kanbot-merged.yml",
254
350
  ok: existsSync(join(root, ".github/workflows/kanbot-merged.yml")),
351
+ why: "Closes the other approaches' PRs when one is merged.",
352
+ fix: "kanbot upgrade",
255
353
  });
256
354
 
355
+ section("Project board");
257
356
  const gh = new Gh(config.repo);
258
357
  try {
259
358
  const board = await loadBoard(gh, config);
@@ -264,7 +363,11 @@ export default defineCommand({
264
363
  detail: missing.length
265
364
  ? `missing columns: ${missing.join(", ")}`
266
365
  : board.url,
366
+ why: missing.length
367
+ ? "kanbot moves cards by the column names in config.yml."
368
+ : undefined,
267
369
  fix: "kanbot board create",
370
+ docs: "getting-started#create-the-board",
268
371
  });
269
372
  // Moving cards to Done on close/merge and adding new issues are left to GitHub.
270
373
  const workflows = await boardWorkflows(gh, board);
@@ -276,6 +379,7 @@ export default defineCommand({
276
379
  add({
277
380
  name: `board workflow "${name}" enabled`,
278
381
  ok: workflows.some((w) => w.name === name && w.enabled),
382
+ why: "kanbot leaves adding new issues and moving closed ones to Done to GitHub.",
279
383
  fix: `enable it in ${board.url} → ⋯ → Workflows`,
280
384
  });
281
385
  }
@@ -307,16 +411,21 @@ export default defineCommand({
307
411
  add({
308
412
  name: "labels",
309
413
  ok: !missingLabels.length,
310
- detail: missingLabels.join(", ") || undefined,
414
+ detail: missingLabels.length
415
+ ? `missing: ${missingLabels.join(", ")}`
416
+ : undefined,
417
+ why: "kanbot tags issues and PRs with these labels.",
311
418
  fix: "kanbot board create",
312
419
  });
313
420
 
314
421
  const { mode, app } = config.identity;
315
422
  if (mode === "app") {
423
+ section("GitHub App");
316
424
  add({
317
425
  name: "kanbot app set",
318
426
  ok: app !== null,
319
427
  detail: app ? `${app.slug} (id ${app.id})` : undefined,
428
+ why: "identity.mode is app, so kanbot acts as a GitHub App.",
320
429
  fix: "kanbot app create",
321
430
  });
322
431
  if (app) {
@@ -325,6 +434,7 @@ export default defineCommand({
325
434
  name: "kanbot app private key",
326
435
  ok: existsSync(key),
327
436
  detail: key,
437
+ why: "kanbot signs in as the app with this key.",
328
438
  fix: "kanbot app create, or copy the key from the machine that created the app",
329
439
  });
330
440
  if (existsSync(key)) {
@@ -346,6 +456,7 @@ export default defineCommand({
346
456
  }
347
457
 
348
458
  if (config.review.copilot) {
459
+ section("Copilot review");
349
460
  const rule = await copilotReviewRule(gh, config.baseBranch).catch(
350
461
  () => null,
351
462
  );
@@ -356,14 +467,16 @@ export default defineCommand({
356
467
  ? `re-review on push: ${rule.parameters?.review_on_push ? "on" : "off"}`
357
468
  : "review.copilot is on, but no ruleset requests Copilot review",
358
469
  fix: "kanbot board create",
470
+ docs: "copilot-review",
359
471
  });
360
472
  add({
361
473
  name: ".github/copilot-instructions.md committed",
474
+ why: "Copilot reads it to review the way your team wants.",
362
475
  ok: tree.split("\n").includes(".github/copilot-instructions.md"),
363
476
  fix: "kanbot sync, then commit and push",
364
477
  });
365
478
  }
366
479
 
367
- if (checks.some((c) => !c.ok)) process.exit(1);
480
+ summary(checks);
368
481
  },
369
482
  });