kanbot 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -101,3 +101,13 @@ bun test
101
101
  pnpm check
102
102
  pnpm typecheck
103
103
  ```
104
+
105
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org). A `commit-msg` hook and CI lint them.
106
+
107
+ ### Releasing
108
+
109
+ 1. Merge or push conventional commits to `main`.
110
+ 2. A draft release updates with the next version and notes. `feat` bumps minor, `fix` patch. On 0.x, breaking changes bump minor.
111
+ 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
+
113
+ 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.1.0",
3
+ "version": "0.2.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
  }
@@ -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 new Error(`${file}: missing YAML frontmatter`);
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 new Error(`${file}: ${z.prettifyError(fm.error)}`);
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 Error("Duplicate agent names in .kanbot/agents");
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 new Error(`${a.file}: name "${a.name}" must match the filename`);
77
+ throw invalid(a.file, `name "${a.name}" must match the filename.`);
65
78
  }
66
79
  if (a.tier === "column" && !a.output) {
67
- throw new Error(`${a.file}: column agents must declare an output schema`);
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) throw new Error(`${a.file}: unknown subagent "${s}"`);
84
+ if (!sub)
85
+ throw invalid(a.file, `Unknown subagent "${s}" in \`subagents\`.`);
72
86
  if (sub.tier !== "sub") {
73
- throw new Error(`${a.file}: "${s}" is not a tier: sub agent`);
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`);
@@ -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 Error(
209
- ".claude/skills exists and is not a symlink. Move its skills into .agents/skills.",
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
- runMain(main);
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
+ }
@@ -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 Error(`Pass --shell ${SHELLS.join("|")}`);
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
  },
@@ -5,6 +5,7 @@ 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";
9
10
  import { Gh, run } from "../github/gh.ts";
10
11
  import {
@@ -27,6 +28,16 @@ async function cmd(args: string[]) {
27
28
  return { ok: code === 0, out: `${out}${err}`.trim() };
28
29
  }
29
30
 
31
+ /** Failure detail and fix for a check, from a thrown error. */
32
+ function explain(e: unknown, fallback?: string) {
33
+ const d = describeError(e);
34
+ const detail = [d.message, d.why].filter(Boolean).join("\n");
35
+ return {
36
+ detail: detail.replace(/\n/g, "\n "),
37
+ fix: d.fix ?? fallback,
38
+ };
39
+ }
40
+
30
41
  export default defineCommand({
31
42
  meta: {
32
43
  name: "doctor",
@@ -59,7 +70,7 @@ export default defineCommand({
59
70
  detail: `${config.repo}, provider ${config.provider}`,
60
71
  });
61
72
  } catch (e) {
62
- add({ name: "config", ok: false, detail: String(e), fix: "kanbot init" });
73
+ add({ name: "config", ok: false, ...explain(e, "kanbot init") });
63
74
  return process.exit(1);
64
75
  }
65
76
 
@@ -119,7 +130,7 @@ export default defineCommand({
119
130
  fix: "kanbot sync",
120
131
  });
121
132
  } catch (e) {
122
- add({ name: "agents", ok: false, detail: String(e) });
133
+ add({ name: "agents", ok: false, ...explain(e) });
123
134
  }
124
135
 
125
136
  const plan = await planUpgrade(root);
@@ -197,8 +208,7 @@ export default defineCommand({
197
208
  add({
198
209
  name: "project board",
199
210
  ok: false,
200
- detail: String(e),
201
- fix: "kanbot board create",
211
+ ...explain(e, "kanbot board create"),
202
212
  });
203
213
  }
204
214
 
@@ -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 Error("Run inside a git repository.");
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 Error("--provider must be claude or codex");
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 (!existsSync(configPath(root)) || args.force) {
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,
@@ -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 Bun.$`gh issue list --repo ${config.repo} --label ${LABELS.needsHuman} --json number,title`.text(),
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(
package/src/config.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { parse, parseDocument } from "yaml";
4
4
  import { z } from "zod";
5
+ import { UserError } from "./errors.ts";
5
6
 
6
7
  export const COLUMN_KEYS = [
7
8
  "ideas",
@@ -197,9 +198,35 @@ export async function writeConfigSchema(
197
198
  export async function loadConfig(root: string): Promise<Config> {
198
199
  const path = configPath(root);
199
200
  if (!existsSync(path)) {
200
- throw new Error(`No ${path}. Run \`kanbot init\` first.`);
201
+ throw new UserError("kanbot is not set up in this repo.", {
202
+ why: "No .kanbot/config.yml in this directory.",
203
+ fix: "kanbot init",
204
+ docs: "getting-started#set-up-a-repository",
205
+ });
201
206
  }
202
- return ConfigSchema.parse(parse(await Bun.file(path).text()) ?? {});
207
+ let raw: unknown;
208
+ try {
209
+ raw = parse(await Bun.file(path).text());
210
+ } catch (e) {
211
+ throw new UserError(
212
+ ".kanbot/config.yml is not valid YAML.",
213
+ {
214
+ why: (e as Error).message.trim(),
215
+ fix: "Fix the syntax at the line shown above.",
216
+ docs: "configuration#config-file",
217
+ },
218
+ { cause: e },
219
+ );
220
+ }
221
+ const config = ConfigSchema.safeParse(raw ?? {});
222
+ if (!config.success) {
223
+ throw new UserError(".kanbot/config.yml has invalid values.", {
224
+ why: z.prettifyError(config.error),
225
+ fix: "Fix the fields above. .kanbot/config.schema.json lists allowed values.",
226
+ docs: "configuration#config-file",
227
+ });
228
+ }
229
+ return config.data;
203
230
  }
204
231
 
205
232
  /** Sets top-level keys, keeping the user's comments and formatting. */
@@ -1,5 +1,6 @@
1
1
  import { existsSync, rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { UserError } from "../errors.ts";
3
4
 
4
5
  /** One daemon per repo. Stale locks (dead pid) are taken over. */
5
6
  export async function acquireLock(root: string): Promise<() => void> {
@@ -7,7 +8,11 @@ export async function acquireLock(root: string): Promise<() => void> {
7
8
  if (existsSync(file)) {
8
9
  const pid = Number(await Bun.file(file).text());
9
10
  if (pid && isAlive(pid)) {
10
- throw new Error(`kanbot is already running for this repo (pid ${pid})`);
11
+ throw new UserError("kanbot is already running for this repo.", {
12
+ why: `Process ${pid} holds .kanbot/daemon.pid. Only one daemon runs per repo.`,
13
+ fix: `Stop it with \`kill ${pid}\`, or check it with \`kanbot status\`.`,
14
+ docs: "usage#kanbot-run",
15
+ });
11
16
  }
12
17
  }
13
18
  await Bun.write(file, String(process.pid));
@@ -1,6 +1,7 @@
1
1
  import { loadAgents } from "../agents/load.ts";
2
2
  import { sync } from "../agents/sync.ts";
3
3
  import { type Config, loadConfig } from "../config.ts";
4
+ import { UserError } from "../errors.ts";
4
5
  import { Gh } from "../github/gh.ts";
5
6
  import { loadBoard, missingColumns } from "../github/project.ts";
6
7
  import { Trust } from "../github/trust.ts";
@@ -45,7 +46,11 @@ export async function runDaemon(root: string, opts: RunOptions) {
45
46
  const board = await loadBoard(gh, config);
46
47
  const missing = missingColumns(board);
47
48
  if (missing.length)
48
- throw new Error(`Board is missing columns: ${missing.join(", ")}`);
49
+ throw new UserError("The project board is missing columns.", {
50
+ why: `No Status option for: ${missing.map((k) => config.columns[k]).join(", ")}.`,
51
+ fix: "kanbot board create adds them. Or match `columns` in .kanbot/config.yml to your board.",
52
+ docs: "configuration#columns",
53
+ });
49
54
 
50
55
  const release = opts.dryRun ? () => {} : await acquireLock(root);
51
56
  const state = await new State(root).load();
package/src/errors.ts ADDED
@@ -0,0 +1,171 @@
1
+ export const DOCS_URL = "https://kanbot.mib.im";
2
+ const ISSUES_URL = "https://github.com/mikaelbr/kanbot/issues";
3
+
4
+ export type ErrorHelp = {
5
+ /** Why it failed: the underlying error or the rule that was broken. */
6
+ why?: string;
7
+ /** What to do about it, usually a command. */
8
+ fix?: string;
9
+ /** Docs page and anchor, e.g. "configuration#config-file". */
10
+ docs?: string;
11
+ /** How to learn more. Defaults to `kanbot doctor`; "" hides it. */
12
+ more?: string;
13
+ };
14
+
15
+ /** An expected failure. Printed without a stack trace. */
16
+ export class UserError extends Error {
17
+ constructor(
18
+ message: string,
19
+ readonly help: ErrorHelp = {},
20
+ options?: ErrorOptions,
21
+ ) {
22
+ super(message, options);
23
+ this.name = "UserError";
24
+ }
25
+ }
26
+
27
+ const INSTALL: Record<string, ErrorHelp> = {
28
+ gh: {
29
+ fix: "Install the GitHub CLI from https://cli.github.com, then run `gh auth login`.",
30
+ docs: "getting-started#requirements",
31
+ },
32
+ git: {
33
+ fix: "Install git from https://git-scm.com/downloads.",
34
+ docs: "getting-started#requirements",
35
+ },
36
+ claude: {
37
+ fix: "Install Claude Code, then run `claude auth login`.",
38
+ docs: "providers#claude-code",
39
+ },
40
+ codex: {
41
+ fix: "Install Codex (npm i -g @openai/codex), then run `codex login`.",
42
+ docs: "providers#codex",
43
+ },
44
+ };
45
+
46
+ /** Known failures from gh, git and the GitHub API, matched on message. */
47
+ const KNOWN: [RegExp, string, ErrorHelp][] = [
48
+ [
49
+ /gh auth login|not logged in|HTTP 401|Bad credentials/i,
50
+ "GitHub CLI is not logged in.",
51
+ { fix: "gh auth login", docs: "getting-started#requirements" },
52
+ ],
53
+ [
54
+ /INSUFFICIENT_SCOPES|required scopes|scope.*project/i,
55
+ "GitHub token is missing the `project` scope.",
56
+ { fix: "gh auth refresh -s project", docs: "getting-started#requirements" },
57
+ ],
58
+ [
59
+ /rate limit/i,
60
+ "GitHub API rate limit reached.",
61
+ { fix: "Wait a few minutes and try again." },
62
+ ],
63
+ [
64
+ /Could not resolve to a ProjectV2/i,
65
+ "Project board not found.",
66
+ {
67
+ fix: "Check `project` in .kanbot/config.yml, or run `kanbot board create`.",
68
+ docs: "getting-started#create-the-board",
69
+ },
70
+ ],
71
+ [
72
+ /Could not resolve to a Repository|HTTP 404/i,
73
+ "GitHub repository not found, or gh has no access to it.",
74
+ {
75
+ fix: "Check `repo` in .kanbot/config.yml and run `gh repo view <owner/name>`.",
76
+ docs: "configuration#config-file",
77
+ },
78
+ ],
79
+ [
80
+ /not a git repository/i,
81
+ "Not inside a git repository.",
82
+ { fix: "cd into your repo, or run `git init`." },
83
+ ],
84
+ [
85
+ /no git remotes|none of the git remotes/i,
86
+ "This repo has no GitHub remote.",
87
+ {
88
+ fix: "git remote add origin <url> && git push -u origin HEAD",
89
+ docs: "getting-started#set-up-a-repository",
90
+ },
91
+ ],
92
+ [
93
+ /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|dial tcp|error connecting/i,
94
+ "Could not reach GitHub.",
95
+ { fix: "Check your network connection and try again." },
96
+ ],
97
+ ];
98
+
99
+ export type Described = ErrorHelp & { message: string; expected: boolean };
100
+
101
+ export function describeError(e: unknown, command = ""): Described {
102
+ if (e instanceof UserError)
103
+ return { message: e.message, ...e.help, expected: true };
104
+ if (!(e instanceof Error)) return { message: String(e), expected: false };
105
+ const raw = e.message.trim();
106
+
107
+ // citty argument errors: unknown command, bad flag value, …
108
+ if (e.name === "CLIError") {
109
+ const unknown = (e as { code?: string }).code === "E_UNKNOWN_COMMAND";
110
+ return {
111
+ message: Bun.stripANSI(raw),
112
+ fix: `Run \`kanbot ${unknown ? "" : `${command} `}--help\` to see valid options.`,
113
+ docs: "usage",
114
+ more: "",
115
+ expected: true,
116
+ };
117
+ }
118
+
119
+ const bin = raw.match(/Executable not found in \$PATH: "(.+)"/)?.[1];
120
+ if (bin)
121
+ return {
122
+ message: `\`${bin}\` is not installed or not on PATH.`,
123
+ why: raw,
124
+ ...INSTALL[bin],
125
+ expected: true,
126
+ };
127
+
128
+ const known = KNOWN.find(([re]) => re.test(raw));
129
+ if (known)
130
+ return { message: known[1], why: raw, ...known[2], expected: true };
131
+ const tool = raw.match(/^(gh|git) .* failed/)?.[1];
132
+ if (tool)
133
+ return {
134
+ message: `A \`${tool}\` command failed.`,
135
+ why: raw,
136
+ fix:
137
+ tool === "gh"
138
+ ? "Read the gh error above. `gh auth status` shows login and scopes."
139
+ : "Read the git error above. `git status` shows the repo state.",
140
+ expected: true,
141
+ };
142
+ return { message: raw, expected: false };
143
+ }
144
+
145
+ const indent = (s: string) => s.replace(/^(?=.)/gm, " ");
146
+
147
+ export function formatError(
148
+ e: unknown,
149
+ opts: { debug?: boolean; command?: string } = {},
150
+ ): string {
151
+ const d = describeError(e, opts.command);
152
+ const lines = [`✖ ${d.message}`];
153
+ if (d.why) lines.push("", indent(d.why));
154
+ lines.push("");
155
+ if (d.fix) lines.push(` Fix: ${d.fix}`);
156
+ if (d.docs) lines.push(` Docs: ${DOCS_URL}/${d.docs}`);
157
+ if (opts.debug && e instanceof Error && e.stack) {
158
+ lines.push("", e.stack);
159
+ if (e.cause instanceof Error && e.cause.stack)
160
+ lines.push("", `Caused by: ${e.cause.stack}`);
161
+ } else if (d.expected) {
162
+ const more = d.more ?? "Run `kanbot doctor` to check your setup.";
163
+ if (more) lines.push(` More: ${more}`);
164
+ } else {
165
+ lines.push(
166
+ " More: rerun with KANBOT_DEBUG=1 for a stack trace.",
167
+ ` If it looks like a bug, report it at ${ISSUES_URL}`,
168
+ );
169
+ }
170
+ return lines.join("\n");
171
+ }
@@ -1,4 +1,5 @@
1
1
  import { COLUMN_KEYS, type ColumnKey, type Config } from "../config.ts";
2
+ import { UserError } from "../errors.ts";
2
3
  import type { Gh } from "./gh.ts";
3
4
 
4
5
  export type Board = {
@@ -60,7 +61,11 @@ function toBoard(p: RawProject, config: Config): Board {
60
61
 
61
62
  export async function loadBoard(gh: Gh, config: Config): Promise<Board> {
62
63
  if (!config.project) {
63
- throw new Error("No project configured. Run `kanbot board create`.");
64
+ throw new UserError("No project board configured.", {
65
+ why: "`project` is not set in .kanbot/config.yml.",
66
+ fix: "kanbot board create (or kanbot board link --owner <login> --number <n>)",
67
+ docs: "getting-started#create-the-board",
68
+ });
64
69
  }
65
70
  const data = await gh.query<{
66
71
  repositoryOwner: { projectV2: RawProject | null } | null;
@@ -74,8 +79,13 @@ export async function loadBoard(gh: Gh, config: Config): Promise<Board> {
74
79
  );
75
80
  const p = data.repositoryOwner?.projectV2;
76
81
  if (!p) {
77
- throw new Error(
78
- `Project ${config.project.owner}#${config.project.number} not found (does gh have the "project" scope? run: gh auth refresh -s project)`,
82
+ throw new UserError(
83
+ `Project ${config.project.owner}#${config.project.number} not found.`,
84
+ {
85
+ why: "It does not exist, or your gh token lacks the `project` scope.",
86
+ fix: "gh auth refresh -s project, then check `project` in .kanbot/config.yml",
87
+ docs: "getting-started#create-the-board",
88
+ },
79
89
  );
80
90
  }
81
91
  return toBoard(p, config);