kandown 0.32.0 → 0.32.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/bin/kandown.js CHANGED
@@ -1,3555 +1,1080 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * @file Kandown CLI entrypoint
4
- * @description Implements `kandown`, `kandown board`, `kandown init`,
5
- * `kandown update`, and `kandown settings` for serving the local web UI,
6
- * launching the terminal board, installing templates, and managing project
7
- * configuration.
8
- *
9
- * 📖 The CLI is intentionally dependency-light: it copies built artifacts and
10
- * markdown templates, then wires agent docs into the host project when possible.
11
- *
12
- * @functions
13
- * → help — prints CLI usage
14
- * → copyRecursive — copies template directories
15
- * → findAgentsFile — finds existing AI-agent instruction files
16
- * → appendAgentReference — injects a Kandown task-management reference
17
- * → createAgentsFileIfMissing — creates AGENTS.md when none exists
18
- * → parseArgs — parses shared CLI flags
19
- * → resolveKandownBin — resolves the global kandown binary path for respawn
20
- * → semverGt — compares two semver strings
21
- * → checkForUpdate — non-blocking auto-updater with lock file and graceful fallback
22
- * → getProjectRoot — returns the project root (parent of .kandown/)
23
- * → getTasksDir — returns the project-root tasks/ path
24
- * → migrateTasksToTopLevel — silently moves legacy .kandown/tasks/ → ./tasks/
25
- * → cmdInit — installs `.kandown` and top-level `tasks/`
26
- * → cmdUpdate — refreshes installed kandown.html
27
- * → injectServerRoot — injects the CLI server root into single-file HTML
28
- * → createServeServer — creates the local zero-dependency HTTP server
29
- * → readDaemonMetadata — reads per-project daemon status metadata
30
- * → startDaemon — starts/reconnects the per-project web daemon
31
- * → stopDaemon — stops the per-project web daemon
32
- * → cmdDaemon — daemon lifecycle command router
33
- * → cmdServe — starts/reconnects the daemon, opens web UI, and launches the board TUI
34
- * → main — dispatches CLI commands
35
- *
36
- * @exports none
37
- */
38
- /* eslint-disable no-console */
39
- // 📖 DEV=false prevents Ink from loading react-devtools-core (CJS-only, breaks ESM).
40
- // Must be set BEFORE any imports because ESM hoists all import statements.
41
- process.env.DEV = 'false';
42
- // 📖 Polyfill browser globals that some bundled modules expect.
43
- if (typeof globalThis.self === 'undefined') Object.defineProperty(globalThis, 'self', { value: globalThis });
44
- if (typeof globalThis.window === 'undefined') Object.defineProperty(globalThis, 'window', { value: globalThis });
45
- // 📖 Make require() available in this ESM module so bundled __require() shims work.
46
- // tsup's __require checks `typeof require !== "undefined"` — this makes it truthy.
47
- import { createRequire } from 'node:module';
48
- globalThis.require = createRequire(import.meta.url);
49
2
 
50
- import { fileURLToPath } from 'node:url';
51
- import { createServer } from 'node:http';
52
- import { dirname, join, resolve } from 'node:path';
53
- import { homedir } from 'node:os';
54
- import {
55
- existsSync,
56
- mkdirSync,
57
- copyFileSync,
58
- readFileSync,
59
- writeFileSync,
60
- readdirSync,
61
- statSync,
62
- unlinkSync,
63
- renameSync,
64
- rmSync,
65
- openSync,
66
- writeSync,
67
- closeSync,
68
- } from 'node:fs';
69
- import { spawn, execSync } from 'node:child_process';
70
- import { createConnection } from 'node:net';
71
- import { randomBytes } from 'node:crypto';
72
- import { watch as watchFs } from 'chokidar';
3
+ // src/cli/cli.ts
4
+ import { existsSync as existsSync5, readFileSync as readFileSync5, copyFileSync } from "fs";
5
+ import { join as join5, resolve as resolve2, basename } from "path";
6
+ import { spawn as spawn3 } from "child_process";
73
7
 
74
- // 📖 Global safety net: a stray exception or unhandled rejection prints a clean
75
- // one-liner instead of a raw stack trace. The daemon (KANDOWN_DAEMON=1) logs
76
- // and keeps serving a single bad request must not take the web UI down.
77
- // Set KANDOWN_DEBUG=1 to get the full stack.
78
- function handleFatal(kind, e) {
79
- const msg = e instanceof Error ? e.message : String(e);
80
- console.error(`\x1b[31m✗\x1b[0m kandown ${kind}: ${msg}`);
81
- if (process.env.KANDOWN_DEBUG && e instanceof Error) console.error(e.stack);
82
- if (process.env.KANDOWN_DAEMON !== '1') process.exit(1);
83
- }
84
- process.on('uncaughtException', (e) => handleFatal('crashed', e));
85
- process.on('unhandledRejection', (e) => handleFatal('internal error', e));
86
-
87
- const __filename = fileURLToPath(import.meta.url);
88
- const __dirname = dirname(__filename);
89
- const PKG_ROOT = resolve(__dirname, '..');
90
- // 📖 Default localhost range for the zero-config `kandown` web UI server.
91
- // 📖 Single source of truth for the daemon port range. Each kandown project
92
- // gets its own daemon on the first free port in this range, so multiple
93
- // projects can run in parallel (A=2048, B=2050, C=2051, ...).
94
- const START_PORT_RANGE = 2048;
95
- const END_PORT_RANGE = 2150;
96
- const DAEMON_FILE = 'daemon.json';
8
+ // src/cli/lib/updater.ts
9
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, statSync } from "fs";
10
+ import { join, resolve } from "path";
11
+ import { spawn, execSync } from "child_process";
12
+ import { homedir } from "os";
97
13
 
98
- /**
99
- * 📖 Ports that Chromium/Firefox/Safari refuse to load (net::ERR_UNSAFE_PORT).
100
- * These are reserved for well-known services (NFS, ssh, smtp, X11, ...).
101
- * Browsers block navigation to them with no recourse, so a daemon listening
102
- * on one of these appears dead in the browser even though it serves fine via
103
- * curl. We MUST skip them when allocating ports. The most common victim in
104
- * our 2048+ range is 2049 (NFS), which silently broke the 2nd concurrent
105
- * kandown project. Sourced from Chromium's net/base/port_util.cc restricted
106
- * ports list (stable, updated rarely).
107
- */
108
- const BROWSER_UNSAFE_PORTS = new Set([
109
- 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77,
110
- 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123,
111
- 135, 139, 143, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532,
112
- 540, 548, 554, 556, 563, 587, 601, 636, 993, 995, 1720, 1723, 2049, 3659,
113
- 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 10080,
114
- ]);
115
- function isBrowserUnsafePort(port) {
116
- return BROWSER_UNSAFE_PORTS.has(port);
117
- }
14
+ // src/lib/version.ts
15
+ var KANDOWN_VERSION = "0.32.1";
118
16
 
119
- // 📖 Get current CLI version from package.json at PKG_ROOT
17
+ // src/cli/lib/updater.ts
18
+ var PKG_ROOT = resolve(import.meta.url ? new URL("../../..", import.meta.url).pathname : process.cwd());
19
+ var UPDATE_CHECK_CACHE = join(PKG_ROOT, ".update-check.json");
20
+ var UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1e3;
120
21
  function getCurrentVersion() {
121
22
  try {
122
- const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
123
- return pkg.version;
124
- } catch { return null; }
23
+ const pkgPath = join(PKG_ROOT, "package.json");
24
+ if (existsSync(pkgPath)) {
25
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
26
+ if (pkg.version) return pkg.version;
27
+ }
28
+ } catch {
29
+ }
30
+ return KANDOWN_VERSION || "0.32.0";
31
+ }
32
+ function semverGt(a, b) {
33
+ const parse = (v) => {
34
+ const [core, ...pre] = String(v).replace(/^v/, "").split("-");
35
+ return { nums: core.split(".").map((n) => Number(n) || 0), pre: pre.length > 0 };
36
+ };
37
+ const pa = parse(a);
38
+ const pb = parse(b);
39
+ for (let i = 0; i < 3; i++) {
40
+ if ((pa.nums[i] || 0) > (pb.nums[i] || 0)) return 1;
41
+ if ((pa.nums[i] || 0) < (pb.nums[i] || 0)) return -1;
42
+ }
43
+ if (!pa.pre && pb.pre) return 1;
44
+ if (pa.pre && !pb.pre) return -1;
45
+ return 0;
125
46
  }
126
-
127
- /**
128
- * 📖 Resolves the kandown binary path for respawning after an update.
129
- * Tries, in order: npm global bin → pnpm global bin → process.execPath fallback.
130
- * @returns {string|null} Absolute path to the kandown binary, or null.
131
- */
132
47
  function resolveKandownBin() {
133
48
  try {
134
- const whichBin = String(execSync('which kandown 2>/dev/null || command -v kandown 2>/dev/null', {
135
- timeout: 3000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
49
+ const whichBin = String(execSync("which kandown 2>/dev/null || command -v kandown 2>/dev/null", {
50
+ timeout: 3e3,
51
+ encoding: "utf8",
52
+ stdio: ["pipe", "pipe", "pipe"]
136
53
  })).trim();
137
54
  if (whichBin && existsSync(whichBin)) return whichBin;
138
- } catch { /* ignore */ }
139
- const localBin = join(homedir(), '.local', 'bin', 'kandown');
55
+ } catch {
56
+ }
57
+ const localBin = join(homedir(), ".local", "bin", "kandown");
140
58
  if (existsSync(localBin)) return localBin;
141
59
  try {
142
- const npmBin = String(execSync('npm config get prefix 2>/dev/null', {
143
- timeout: 3000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
60
+ const npmBin = String(execSync("npm config get prefix 2>/dev/null", {
61
+ timeout: 3e3,
62
+ encoding: "utf8",
63
+ stdio: ["pipe", "pipe", "pipe"]
144
64
  })).trim();
145
- if (existsSync(join(npmBin, 'bin', 'kandown'))) return join(npmBin, 'bin', 'kandown');
146
- if (existsSync(join(npmBin, 'kandown'))) return join(npmBin, 'kandown');
147
- } catch { /* npm not available */ }
65
+ if (existsSync(join(npmBin, "bin", "kandown"))) return join(npmBin, "bin", "kandown");
66
+ if (existsSync(join(npmBin, "kandown"))) return join(npmBin, "kandown");
67
+ } catch {
68
+ }
148
69
  try {
149
- const pnpmBin = String(execSync('pnpm config get prefix 2>/dev/null', {
150
- timeout: 3000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
70
+ const pnpmBin = String(execSync("pnpm config get prefix 2>/dev/null", {
71
+ timeout: 3e3,
72
+ encoding: "utf8",
73
+ stdio: ["pipe", "pipe", "pipe"]
151
74
  })).trim();
152
- if (existsSync(join(pnpmBin, 'bin', 'kandown'))) return join(pnpmBin, 'bin', 'kandown');
153
- if (existsSync(join(pnpmBin, 'kandown'))) return join(pnpmBin, 'kandown');
154
- } catch { /* pnpm not available */ }
75
+ if (existsSync(join(pnpmBin, "bin", "kandown"))) return join(pnpmBin, "bin", "kandown");
76
+ if (existsSync(join(pnpmBin, "kandown"))) return join(pnpmBin, "kandown");
77
+ } catch {
78
+ }
155
79
  return null;
156
80
  }
157
-
158
81
  async function readInstalledKandownVersion(targetVersion) {
159
82
  const localVersion = getCurrentVersion();
160
83
  if (localVersion && semverGt(localVersion, targetVersion) >= 0) return localVersion;
161
-
162
84
  const bin = resolveKandownBin();
163
85
  if (!bin) return localVersion;
164
-
165
86
  return await new Promise((resolveVersion) => {
166
- const child = spawn(bin, ['--version'], {
167
- timeout: 5000,
168
- stdio: ['pipe', 'pipe', 'pipe'],
169
- env: { ...process.env, KANDOWN_NO_UPDATE: '1' },
170
- detached: false,
87
+ const child = spawn(bin, ["--version"], {
88
+ timeout: 5e3,
89
+ stdio: ["pipe", "pipe", "pipe"],
90
+ env: { ...process.env, KANDOWN_NO_UPDATE: "1" },
91
+ detached: false
92
+ });
93
+ let stdout = "";
94
+ child.stdout.on("data", (d) => {
95
+ stdout += d;
171
96
  });
172
- let stdout = '';
173
- child.stdout.on('data', (d) => { stdout += d; });
174
- child.stderr.on('data', () => {});
175
- child.on('error', () => resolveVersion(localVersion));
176
- child.on('close', (code) => {
97
+ child.stderr.on("data", () => {
98
+ });
99
+ child.on("error", () => resolveVersion(localVersion));
100
+ child.on("close", (code) => {
177
101
  if (code !== 0) return resolveVersion(localVersion);
178
102
  const match = stdout.trim().match(/v?(\d+\.\d+\.\d+(?:-[\w.-]+)?)/);
179
103
  resolveVersion(match ? match[1] : localVersion);
180
104
  });
181
105
  });
182
106
  }
183
-
184
- /**
185
- * 📖 Compares two semver strings (major.minor.patch, optional -prerelease).
186
- * Prerelease-safe: "0.18.0-beta.1" no longer parses as NaN — the numeric
187
- * triple is compared first, and on a tie a release outranks a prerelease.
188
- * @returns {number} 1 if a > b, -1 if a < b, 0 if equal.
189
- */
190
- function semverGt(a, b) {
191
- const parse = (v) => {
192
- const [core, ...pre] = String(v).replace(/^v/, '').split('-');
193
- return { nums: core.split('.').map(n => Number(n) || 0), pre: pre.length > 0 };
194
- };
195
- const pa = parse(a);
196
- const pb = parse(b);
197
- for (let i = 0; i < 3; i++) {
198
- if ((pa.nums[i] || 0) > (pb.nums[i] || 0)) return 1;
199
- if ((pa.nums[i] || 0) < (pb.nums[i] || 0)) return -1;
200
- }
201
- if (!pa.pre && pb.pre) return 1;
202
- if (pa.pre && !pb.pre) return -1;
203
- return 0;
204
- }
205
-
206
- /**
207
- * 📖 Update-check throttle: remembers the last successful registry check in a
208
- * small cache file next to the package. The network check runs at most once
209
- * per 24h — `kandown` stays fast (and fully offline-silent) the rest of the
210
- * time. Cache write failures are ignored (read-only installs just check more
211
- * often, which is the pre-throttle behavior).
212
- */
213
- const UPDATE_CHECK_CACHE = join(PKG_ROOT, '.update-check.json');
214
- const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
215
-
216
107
  function updateCheckedRecently() {
217
108
  try {
218
- const raw = JSON.parse(readFileSync(UPDATE_CHECK_CACHE, 'utf8'));
109
+ const raw = JSON.parse(readFileSync(UPDATE_CHECK_CACHE, "utf8"));
219
110
  return Number.isFinite(raw?.lastCheck) && Date.now() - raw.lastCheck < UPDATE_CHECK_INTERVAL_MS;
220
111
  } catch {
221
112
  return false;
222
113
  }
223
114
  }
224
-
225
115
  function rememberUpdateCheck() {
226
116
  try {
227
- writeFileSync(UPDATE_CHECK_CACHE, JSON.stringify({ lastCheck: Date.now() }), 'utf8');
228
- } catch { /* read-only install — check again next time */ }
117
+ writeFileSync(UPDATE_CHECK_CACHE, JSON.stringify({ lastCheck: Date.now() }), "utf8");
118
+ } catch {
119
+ }
229
120
  }
230
121
  async function performGlobalPackageUpdate(packageSpec) {
231
122
  const cleanEnv = { ...process.env };
232
123
  for (const k of Object.keys(cleanEnv)) {
233
- if (k.startsWith('npm_config_') || k.startsWith('npm_') || k === 'INIT_CWD') {
124
+ if (k.startsWith("npm_config_") || k.startsWith("npm_") || k === "INIT_CWD") {
234
125
  delete cleanEnv[k];
235
126
  }
236
127
  }
237
-
238
128
  const tryPkgCmd = (cmd, args) => {
239
129
  return new Promise((res) => {
240
130
  const child = spawn(cmd, args, {
241
- timeout: 60000,
242
- stdio: ['pipe', 'pipe', 'pipe'],
131
+ timeout: 6e4,
132
+ stdio: ["pipe", "pipe", "pipe"],
243
133
  env: cleanEnv,
244
- detached: false,
134
+ detached: false
135
+ });
136
+ child.stderr.on("data", () => {
245
137
  });
246
- child.stderr.on('data', () => {});
247
- child.stdout.on('data', () => {});
248
- child.on('error', () => res(false));
249
- child.on('close', (code) => res(code === 0));
138
+ child.stdout.on("data", () => {
139
+ });
140
+ child.on("error", () => res(false));
141
+ child.on("close", (code) => res(code === 0));
250
142
  });
251
143
  };
252
-
253
- if (await tryPkgCmd('pnpm', ['add', '-g', packageSpec])) return true;
254
- if (await tryPkgCmd('npm', ['install', '-g', packageSpec])) return true;
255
- if (await tryPkgCmd('yarn', ['global', 'add', packageSpec])) return true;
256
- return await tryPkgCmd('bun', ['add', '-g', packageSpec]);
144
+ const currentBin = resolveKandownBin() || "";
145
+ const isPnpmInstall = currentBin.includes("pnpm");
146
+ if (isPnpmInstall) {
147
+ if (await tryPkgCmd("pnpm", ["add", "-g", packageSpec])) return true;
148
+ if (await tryPkgCmd("npm", ["install", "-g", packageSpec, "--force"])) return true;
149
+ } else {
150
+ if (await tryPkgCmd("npm", ["install", "-g", packageSpec, "--force"])) return true;
151
+ if (await tryPkgCmd("pnpm", ["add", "-g", packageSpec])) return true;
152
+ }
153
+ if (await tryPkgCmd("yarn", ["global", "add", packageSpec])) return true;
154
+ return await tryPkgCmd("bun", ["add", "-g", packageSpec]);
257
155
  }
258
-
259
- /**
260
- * 📖 Check npm for a newer version and auto-update if outdated.
261
- *
262
- * Design principles:
263
- * - 🚀 Non-blocking: spawns npm check as a background child process.
264
- * - 🔒 Lock file: prevents concurrent update races when multiple kandown
265
- * instances start simultaneously.
266
- * - 🛡️ Resilient: if the update fails for any reason (network, permissions,
267
- * npm registry downtime), the current version continues normally.
268
- * - 🔄 Respawn: after a successful update, re-spawns the CLI with the same
269
- * arguments so the user gets the new version immediately.
270
- * - 📦 Package manager agnostic: tries npm, then pnpm, for both the update
271
- * and the binary resolution.
272
- *
273
- * Only activates when running from an installed npm package
274
- * (not local dev source, where `src/` exists in PKG_ROOT).
275
- */
276
156
  async function checkForUpdate(argv = process.argv) {
277
- // 📖 Local dev skip entirely
278
- if (existsSync(join(PKG_ROOT, 'src'))) return;
279
-
280
- // 📖 Opt-out for CI / controlled environments.
281
- if (process.env.KANDOWN_NO_UPDATE === '1') return;
282
-
283
- // 📖 Script context (piped/captured output): never surprise-update — a
284
- // respawn mid-pipeline would interleave output from two versions.
157
+ if (existsSync(join(PKG_ROOT, "src")) && !process.env.KANDOWN_TEST_UPDATE) return;
158
+ if (process.env.KANDOWN_NO_UPDATE === "1") return;
285
159
  if (!process.stdout.isTTY) return;
286
-
287
- // 📖 Throttle: the registry round-trip runs at most once per 24h, so the
288
- // command stays instant (and silent offline) the rest of the time.
289
160
  if (updateCheckedRecently()) return;
290
-
291
161
  const current = getCurrentVersion();
292
162
  if (!current) return;
293
-
294
- // 📖 Skip if a lock file exists — another kandown instance is already updating.
295
- // The lock auto-expires after 60 seconds to handle stale locks from crashed processes.
296
- const lockFile = join(PKG_ROOT, '.update.lock');
163
+ const lockFile = join(PKG_ROOT, ".update.lock");
297
164
  const now = Date.now();
298
165
  try {
299
166
  if (existsSync(lockFile)) {
300
167
  const lockAge = now - statSync(lockFile).mtimeMs;
301
- if (lockAge < 60_000) return; // another process is handling the update
302
- unlinkSync(lockFile); // stale lock — remove it
168
+ if (lockAge < 6e4) return;
169
+ unlinkSync(lockFile);
303
170
  }
304
- } catch { /* ignore lock errors */ }
305
-
306
- // 📖 Step 1: Check latest version on npm registry (non-blocking).
307
- // We use `npm view` in a spawned child process with a short timeout.
308
- const latest = await new Promise((resolve) => {
309
- const child = spawn('npm', ['view', 'kandown', 'version'], {
310
- timeout: 6000,
311
- stdio: ['pipe', 'pipe', 'pipe'],
171
+ } catch {
172
+ }
173
+ const latest = await new Promise((resolve3) => {
174
+ const child2 = spawn("npm", ["view", "kandown", "version"], {
175
+ timeout: 6e3,
176
+ stdio: ["pipe", "pipe", "pipe"],
312
177
  env: { ...process.env },
313
- // 📖 Detach so we can kill cleanly on timeout
314
- detached: false,
178
+ detached: false
179
+ });
180
+ let stdout = "";
181
+ child2.stdout.on("data", (d) => {
182
+ stdout += d;
315
183
  });
316
- let stdout = '';
317
- child.stdout.on('data', (d) => { stdout += d; });
318
- child.stderr.on('data', () => {}); // silence stderr
319
- child.on('error', () => resolve(null));
320
- child.on('close', (code) => {
321
- if (code !== 0) return resolve(null);
322
- const v = stdout.trim().replace(/^"|"$/g, '');
323
- resolve(v || null);
184
+ child2.stderr.on("data", () => {
185
+ });
186
+ child2.on("error", () => resolve3(null));
187
+ child2.on("close", (code) => {
188
+ if (code !== 0) return resolve3(null);
189
+ const v = stdout.trim().replace(/^"|"$/g, "");
190
+ resolve3(v || null);
324
191
  });
325
192
  });
326
-
327
- if (!latest) return; // offline / registry-down — retry on the next interactive run
328
-
193
+ if (!latest) return;
329
194
  if (semverGt(current, latest) >= 0) {
330
- // 📖 Up-to-date registry answers can be throttled. Failed update attempts
331
- // below are intentionally NOT cached, otherwise one broken install would
332
- // silence auto-update retries for 24h across every open project.
333
195
  rememberUpdateCheck();
334
196
  return;
335
197
  }
336
-
337
- tuiDone('⚡', `Update available: ${c.dim}kandown ${current}${c.reset} → ${c.green}${latest}${c.reset}`);
338
-
339
- // 📖 Step 2: Create lock file to prevent concurrent updates.
340
- try { writeFileSync(lockFile, `${process.pid}\n${now}`, 'utf8'); } catch { /* ignore */ }
341
-
342
- // 📖 Step 3: Run the update via npm or pnpm with animated progress.
343
- tuiProgress(`Updating to ${latest}…`, 25);
198
+ console.log(`\u26A1 Update available: kandown ${current} \u2192 ${latest}`);
199
+ try {
200
+ writeFileSync(lockFile, `${process.pid}
201
+ ${now}`, "utf8");
202
+ } catch {
203
+ }
344
204
  const updateOk = await performGlobalPackageUpdate(`kandown@${latest}`);
345
-
346
- // 📖 Clean up lock file regardless of outcome.
347
- try { if (existsSync(lockFile)) unlinkSync(lockFile); } catch { /* ignore */ }
348
-
205
+ try {
206
+ if (existsSync(lockFile)) unlinkSync(lockFile);
207
+ } catch {
208
+ }
349
209
  if (!updateOk) {
350
- tuiDone('✗', `${c.yellow}Auto-update failed${c.reset} continuing with current version`);
351
- log(` Run ${c.cyan}pnpm add -g kandown@latest${c.reset} or ${c.cyan}npm install -g kandown@latest${c.reset} to upgrade manually`);
352
- log('');
210
+ console.log(`\u2717 Auto-update failed \u2014 continuing with current version`);
353
211
  return;
354
212
  }
355
-
356
- // 📖 Step 4: Verify the installed CLI, not just the npm registry, because
357
- // `npm view kandown version` only proves a version exists remotely.
358
213
  const postVersion = await readInstalledKandownVersion(latest);
359
-
360
- if (!postVersion || semverGt(postVersion, latest) < 0) {
361
- tuiDone('✗', `${c.yellow}Update did not apply${c.reset} — continuing with current version`);
362
- log(` Run ${c.cyan}npm install -g kandown${c.reset} to upgrade manually`);
363
- log('');
364
- return;
365
- }
366
-
214
+ if (!postVersion || semverGt(postVersion, latest) < 0) return;
367
215
  rememberUpdateCheck();
368
-
369
- // 📖 If the running package directory has been replaced in-place (normal
370
- // global install), refresh every currently open project daemon, not only the
371
- // project that triggered the update. This keeps two simultaneously open
372
- // Kandown boards from getting stuck on different kandown.html versions.
373
- const localVersionAfterInstall = getCurrentVersion();
374
- if (localVersionAfterInstall && semverGt(localVersionAfterInstall, latest) >= 0) {
375
- const refreshed = await refreshRunningProjectHtml();
376
- if (refreshed > 0) info(`Refreshed ${refreshed} open project${refreshed === 1 ? '' : 's'}`);
377
- }
378
-
379
- tuiDone('✓', `${c.green}Updated to v${postVersion}${c.reset} — restarting…`);
380
- printVersionChangelog(postVersion);
381
- printBreakingChangeNotices(current, postVersion);
382
- log('');
383
-
384
- // 📖 Step 5: Respawn with the new version.
385
- // Pass --no-update-check to the child so it doesn't try to update again.
216
+ console.log(`\u2713 Updated to v${postVersion} \u2014 restarting\u2026`);
386
217
  const bin = resolveKandownBin();
387
- const childArgs = ['--no-update-check', ...argv.slice(2)];
388
-
389
- if (bin) {
390
- const child = spawn(bin, childArgs, {
391
- detached: true,
392
- stdio: 'inherit',
393
- env: { ...process.env },
394
- });
395
- child.unref();
396
- } else {
397
- // 📖 Fallback: re-use the current binary path (works for npx).
398
- const child = spawn(process.argv[0], [process.argv[1], ...childArgs], {
399
- detached: true,
400
- stdio: 'inherit',
401
- env: { ...process.env },
402
- });
403
- child.unref();
404
- }
405
-
218
+ const childArgs = ["--no-update-check", ...argv.slice(2)];
219
+ const child = spawn(bin || process.argv[0], bin ? childArgs : [process.argv[1], ...childArgs], {
220
+ detached: true,
221
+ stdio: "inherit",
222
+ env: { ...process.env }
223
+ });
224
+ child.unref();
406
225
  process.exit(0);
407
226
  }
408
227
 
409
- const c = {
410
- reset: '\x1b[0m',
411
- bold: '\x1b[1m',
412
- dim: '\x1b[2m',
413
- green: '\x1b[32m',
414
- yellow: '\x1b[33m',
415
- blue: '\x1b[34m',
416
- red: '\x1b[31m',
417
- cyan: '\x1b[36m',
418
- };
419
-
420
- /**
421
- * 📖 One-time migration notices shown right after a successful auto-update,
422
- * when the jump crosses a release that changed user-facing behavior (i.e.
423
- * `fromVersion < notice.version <= toVersion`). Keeps upgraders from being
424
- * silently surprised by a removed command or a changed convention — full
425
- * details always live in CHANGELOG.md. Add an entry here for any future
426
- * breaking release; harmless no-op for anyone already past that version.
427
- */
428
- const BREAKING_CHANGE_NOTICES = [
429
- {
430
- version: '0.18.0',
431
- lines: [
432
- `${c.yellow}⚠ Breaking change in v0.18.0:${c.reset}`,
433
- ` ${c.dim}•${c.reset} ${c.cyan}kandown shell <cmd>${c.reset} is gone — commands are now top-level: ${c.cyan}kandown list/show/create/move/assign/commit${c.reset}`,
434
- ` ${c.dim}•${c.reset} ${c.cyan}kandown init${c.reset} now injects one line into AGENTS.md/CLAUDE.md pointing agents at ${c.cyan}kandown work${c.reset} — existing projects keep their old block until you re-run ${c.cyan}kandown init${c.reset}`,
435
- ` ${c.dim}•${c.reset} New: ${c.cyan}kandown work${c.reset} prints the agent rules + a live board digest — the recommended way to brief an AI agent`,
436
- ` ${c.dim}Full changelog:${c.reset} https://github.com/vava-nessa/kandown/blob/main/CHANGELOG.md`,
437
- ],
438
- },
439
- ];
228
+ // src/cli/lib/board-reader.ts
229
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3, mkdirSync, unlinkSync as unlinkSync3 } from "fs";
230
+ import { dirname, join as join3 } from "path";
231
+ import { fileURLToPath } from "url";
232
+ import { homedir as homedir2 } from "os";
233
+ import { execFileSync } from "child_process";
440
234
 
441
- function printBreakingChangeNotices(fromVersion, toVersion) {
442
- for (const notice of BREAKING_CHANGE_NOTICES) {
443
- if (semverGt(fromVersion, notice.version) < 0 && semverGt(toVersion, notice.version) >= 0) {
444
- log('');
445
- for (const line of notice.lines) log(line);
235
+ // src/cli/lib/atomic-write.ts
236
+ import { renameSync, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "fs";
237
+ function atomicWriteFileSync(path, content) {
238
+ const tmp = `${path}.${process.pid}.tmp`;
239
+ try {
240
+ writeFileSync2(tmp, content, "utf8");
241
+ renameSync(tmp, path);
242
+ } catch (e) {
243
+ try {
244
+ unlinkSync2(tmp);
245
+ } catch {
446
246
  }
247
+ throw e;
447
248
  }
448
249
  }
449
250
 
450
- /**
451
- * 📖 Version-seen tracker: catches breaking-change notices for upgrades that
452
- * DON'T go through checkForUpdate's own auto-update flow — a manual
453
- * `npm install -g kandown`, pnpm/yarn/bun global installs, or any future
454
- * package manager. Records the last version this install printed notices for
455
- * in a small cache file next to the package; on the next interactive launch,
456
- * any BREAKING_CHANGE_NOTICES entry crossed since then is shown once.
457
- * TTY-only and skipped for scripted commands — never fires in a script/CI.
458
- * First run after this mechanism ships has no prior cache, so it seeds
459
- * silently rather than guessing; the checkForUpdate path covers that specific
460
- * transition for auto-updaters, and every version from here on is covered.
461
- */
462
- function getChangelogForVersion(version) {
463
- const changelogPath = join(PKG_ROOT, 'CHANGELOG.md');
464
- if (!existsSync(changelogPath)) return null;
465
- try {
466
- const text = readFileSync(changelogPath, 'utf8');
467
- const escapedVer = version.replace(/\./g, '\\.');
468
- const regex = new RegExp(`##\\s+${escapedVer}[\\s\\S]*?(?=\\n##\\s+|$)`, 'i');
469
- const match = text.match(regex);
470
- if (!match) return null;
471
- return match[0].trim();
472
- } catch {
473
- return null;
474
- }
475
- }
251
+ // src/lib/types.ts
252
+ var DEFAULT_COLUMNS = ["Backlog", "Todo", "In Progress", "Review", "Done"];
476
253
 
477
- function printVersionChangelog(version) {
478
- const section = getChangelogForVersion(version);
479
- if (!section) return;
480
- const lines = section.split('\n');
481
- log('');
482
- log(` ${c.bold}${c.cyan}${lines[0].replace(/^##\s+/, '📋 Release ')}${c.reset}`);
483
- log(` ${c.dim}${'─'.repeat(55)}${c.reset}`);
484
- for (let i = 1; i < lines.length; i++) {
485
- const line = lines[i];
486
- if (!line.trim()) continue;
487
- if (line.startsWith('- **')) {
488
- log(` ${c.green}•${c.reset} ${line.slice(2)}`);
254
+ // src/lib/parser.ts
255
+ function parseSimpleYaml(yaml) {
256
+ const obj = {};
257
+ if (!yaml || typeof yaml !== "string") return obj;
258
+ const lines = yaml.split("\n");
259
+ for (let i = 0; i < lines.length; i++) {
260
+ const line = lines[i] ?? "";
261
+ const m = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
262
+ if (!m) continue;
263
+ const key = m[1];
264
+ if (!key) continue;
265
+ let val = m[2]?.trim() ?? "";
266
+ if (val === "|") {
267
+ const block = [];
268
+ i++;
269
+ while (i < lines.length && (/^\s+/.test(lines[i] ?? "") || (lines[i] ?? "") === "")) {
270
+ block.push((lines[i] ?? "").replace(/^ /, ""));
271
+ i++;
272
+ }
273
+ i--;
274
+ obj[key] = block.join("\n").trimEnd();
275
+ continue;
276
+ }
277
+ if (typeof val !== "string") val = "";
278
+ if (val.startsWith("[") && val.endsWith("]")) {
279
+ const arr = val.slice(1, -1).split(",").map((s) => s && typeof s === "string" ? s.trim().replace(/^["']|["']$/g, "") : "").filter(Boolean);
280
+ obj[key] = arr;
489
281
  } else {
490
- log(` ${c.dim}${line}${c.reset}`);
282
+ obj[key] = typeof val === "string" ? val.replace(/^["']|["']$/g, "") : val;
491
283
  }
492
284
  }
493
- log('');
285
+ return obj;
494
286
  }
495
-
496
- const VERSION_SEEN_CACHE = join(PKG_ROOT, '.version-seen.json');
497
-
498
- function checkVersionSeenNotices() {
499
- if (!process.stdout.isTTY) return;
500
- const current = getCurrentVersion();
501
- if (!current) return;
502
-
503
- let lastSeen = null;
504
- try {
505
- const raw = JSON.parse(readFileSync(VERSION_SEEN_CACHE, 'utf8'));
506
- if (typeof raw?.lastSeen === 'string') lastSeen = raw.lastSeen;
507
- } catch { /* first run, or corrupted cache — treat as unknown */ }
508
-
509
- if (lastSeen !== current) {
510
- printVersionChangelog(current);
511
- if (lastSeen) printBreakingChangeNotices(lastSeen, current);
512
- try { writeFileSync(VERSION_SEEN_CACHE, JSON.stringify({ lastSeen: current })); } catch { /* best-effort */ }
287
+ function parseTaskFile(md) {
288
+ if (!md || typeof md !== "string") {
289
+ return { frontmatter: { id: "", title: "" }, body: "" };
513
290
  }
291
+ const lines = md.split("\n");
292
+ if (lines[0] && lines[0].trim() === "---") {
293
+ const fmLines = [];
294
+ let i = 1;
295
+ while (i < lines.length && lines[i].trim() !== "---") {
296
+ fmLines.push(lines[i]);
297
+ i++;
298
+ }
299
+ const body = lines.slice(i + 1).join("\n").trimStart();
300
+ const fm = parseSimpleYaml(fmLines.join("\n"));
301
+ return { frontmatter: fm, body };
302
+ }
303
+ return { frontmatter: { id: "", title: "" }, body: md };
304
+ }
305
+ function normalizeStatus(status) {
306
+ const value = typeof status === "string" ? status.trim() : "";
307
+ return value || "Backlog";
308
+ }
309
+ function normalizePriority(priority) {
310
+ if (typeof priority !== "string") return null;
311
+ const value = priority.toUpperCase();
312
+ return /^(P1|P2|P3|P4)$/.test(value) ? value : null;
313
+ }
314
+ function normalizeOwnerType(ownerType) {
315
+ if (typeof ownerType !== "string") return "";
316
+ const value = ownerType.toLowerCase();
317
+ return value === "human" || value === "ai" ? value : "";
318
+ }
319
+ function taskOrder(task) {
320
+ const value = task.frontmatter.order;
321
+ if (typeof value === "number" && Number.isFinite(value)) return value;
322
+ if (typeof value === "string" && value.trim()) {
323
+ const parsed = Number(value);
324
+ if (Number.isFinite(parsed)) return parsed;
325
+ }
326
+ return Number.MAX_SAFE_INTEGER;
327
+ }
328
+ function taskToBoardTask(task) {
329
+ const { frontmatter, body } = task;
330
+ const { subtasks } = extractSubtasks(body);
331
+ const done = subtasks.filter((s) => s.done).length;
332
+ const total = subtasks.length;
333
+ const status = normalizeStatus(frontmatter.status);
334
+ const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags.filter((tag) => typeof tag === "string" && tag.trim().length > 0) : [];
335
+ const { id: _id, title: _title, status: _status, order: _order, created: _created, archived: _archived, report: _report, ...metadata } = frontmatter;
336
+ return {
337
+ id: frontmatter.id || "",
338
+ title: frontmatter.title || frontmatter.id || "Untitled task",
339
+ checked: /done|termin|closed|complet/i.test(status),
340
+ tags,
341
+ assignee: typeof frontmatter.assignee === "string" && frontmatter.assignee ? frontmatter.assignee : null,
342
+ priority: normalizePriority(frontmatter.priority),
343
+ ownerType: normalizeOwnerType(frontmatter.ownerType),
344
+ progress: total > 0 ? { done, total } : null,
345
+ dependsOn: Array.isArray(frontmatter.depends_on) ? frontmatter.depends_on.filter((d) => typeof d === "string" && d.trim().length > 0) : [],
346
+ frontmatter: metadata
347
+ };
514
348
  }
515
-
516
- /**
517
- * 📖 Atomic write (M6): write to a sibling temp file then rename over the
518
- * target. A crash/kill mid-write can no longer leave a truncated task file or
519
- * a corrupted kandown.json — rename is atomic on the same filesystem.
520
- */
521
- function atomicWriteFileSync(path, content) {
522
- const tmp = `${path}.${process.pid}.tmp`;
349
+ function buildColumnsFromTasks(tasks, configuredColumns = DEFAULT_COLUMNS) {
350
+ const columnNames = configuredColumns.length > 0 ? configuredColumns : DEFAULT_COLUMNS;
351
+ const columnsByName = /* @__PURE__ */ new Map();
352
+ const configured = columnNames.map((name) => ({ name, tasks: [] }));
353
+ for (const column of configured) columnsByName.set(column.name.toLowerCase(), column);
354
+ const unknownColumns = [];
355
+ const sortedTasks = [...tasks].filter((task) => Boolean(task.frontmatter.id)).filter((task) => !isArchived(task)).sort((a, b) => {
356
+ const byOrder = taskOrder(a) - taskOrder(b);
357
+ if (byOrder !== 0) return byOrder;
358
+ return a.frontmatter.id.localeCompare(b.frontmatter.id, void 0, { numeric: true });
359
+ });
360
+ for (const task of sortedTasks) {
361
+ const status = normalizeStatus(task.frontmatter.status);
362
+ let column = columnsByName.get(status.toLowerCase());
363
+ if (!column) {
364
+ column = { name: status, tasks: [] };
365
+ columnsByName.set(status.toLowerCase(), column);
366
+ unknownColumns.push(column);
367
+ }
368
+ column.tasks.push(taskToBoardTask(task));
369
+ }
370
+ return [...unknownColumns, ...configured];
371
+ }
372
+ function isArchived(task) {
373
+ return String(task.frontmatter.archived) === "true";
374
+ }
375
+ function extractSubtasks(body) {
376
+ const subtasks = [];
377
+ if (!body || typeof body !== "string") return { subtasks, bodyWithoutSubtasks: body ?? "" };
378
+ const lines = body.split("\n");
379
+ const kept = [];
380
+ let inSubtaskSection = false;
381
+ for (const line of lines) {
382
+ if (/^#{1,6}\s+(subtasks?|sous[- ]t[âa]ches?|crit[èe]res?)/i.test(line)) {
383
+ inSubtaskSection = true;
384
+ kept.push(line);
385
+ continue;
386
+ }
387
+ if (/^#{1,6}\s+/.test(line) && inSubtaskSection) {
388
+ inSubtaskSection = false;
389
+ kept.push(line);
390
+ continue;
391
+ }
392
+ const m = line.match(/^\s*-\s+\[([ xX])\]\s+(.+)$/);
393
+ if (m && inSubtaskSection) {
394
+ const text = m[2]?.trim() ?? "";
395
+ subtasks.push({ done: (m[1]?.toLowerCase() ?? "") === "x", text });
396
+ continue;
397
+ }
398
+ const descMatch = line.match(/^\s*\[DESC\]\s*(.*)$/);
399
+ if (descMatch && subtasks.length > 0) {
400
+ subtasks[subtasks.length - 1].description = descMatch[1];
401
+ continue;
402
+ }
403
+ const reportMatch = line.match(/^\s*\[REPORT\]\s*(.*)$/);
404
+ if (reportMatch && subtasks.length > 0) {
405
+ subtasks[subtasks.length - 1].report = reportMatch[1];
406
+ continue;
407
+ }
408
+ const legacyDescMatch = line.match(/^\s+description:\s*(.+)$/);
409
+ if (legacyDescMatch && inSubtaskSection && subtasks.length > 0) {
410
+ subtasks[subtasks.length - 1].description = legacyDescMatch[1].trim();
411
+ continue;
412
+ }
413
+ const legacyReportMatch = line.match(/^\s+report:\s*(.+)$/);
414
+ if (legacyReportMatch && inSubtaskSection && subtasks.length > 0) {
415
+ subtasks[subtasks.length - 1].report = legacyReportMatch[1].trim();
416
+ continue;
417
+ }
418
+ kept.push(line);
419
+ }
420
+ return { subtasks, bodyWithoutSubtasks: kept.join("\n") };
421
+ }
422
+
423
+ // src/lib/serializer.ts
424
+ function serializeTaskFile(frontmatter, body) {
425
+ const lines = ["---"];
426
+ if (frontmatter && typeof frontmatter === "object") {
427
+ for (const [k, v] of Object.entries(frontmatter)) {
428
+ if (v === null || v === void 0 || v === "") continue;
429
+ if (Array.isArray(v)) {
430
+ if (v.length === 0) continue;
431
+ lines.push(`${k}: [${v.join(", ")}]`);
432
+ } else if (typeof v === "string" && v.includes("\n")) {
433
+ lines.push(`${k}: |`);
434
+ lines.push(...v.split("\n").map((line) => line === "" ? "" : ` ${line}`));
435
+ } else if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
436
+ lines.push(`${k}: ${v}`);
437
+ }
438
+ }
439
+ }
440
+ lines.push("---");
441
+ lines.push("");
442
+ lines.push((body ?? "").trim());
443
+ lines.push("");
444
+ return lines.join("\n");
445
+ }
446
+
447
+ // src/cli/lib/config.ts
448
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
449
+ import { join as join2 } from "path";
450
+ var DEFAULT_CONFIG = {
451
+ ui: { language: "en", theme: "auto", skin: "kandown", font: "inter" },
452
+ agent: { suggestFollowUp: false, maxSuggestions: 3 },
453
+ board: {
454
+ columns: ["Backlog", "Todo", "In Progress", "Review", "Done"],
455
+ defaultPriority: "P3",
456
+ defaultOwnerType: "human",
457
+ stackDefaultState: "collapsed"
458
+ },
459
+ fields: {
460
+ priority: false,
461
+ assignee: false,
462
+ tags: false,
463
+ dueDate: false,
464
+ ownerType: false,
465
+ tools: false
466
+ },
467
+ notifications: {
468
+ browser: false,
469
+ sound: false,
470
+ soundId: "soft",
471
+ statusChanges: true,
472
+ taskEdits: true,
473
+ subtaskCompletions: true,
474
+ editDebounceMs: 2e3
475
+ }
476
+ };
477
+ function loadConfig(kandownDir) {
478
+ const configPath = join2(kandownDir, "kandown.json");
479
+ if (!existsSync2(configPath)) return structuredClone(DEFAULT_CONFIG);
480
+ let raw;
523
481
  try {
524
- writeFileSync(tmp, content, 'utf8');
525
- renameSync(tmp, path);
482
+ raw = JSON.parse(readFileSync2(configPath, "utf8"));
526
483
  } catch (e) {
527
- try { unlinkSync(tmp); } catch { /* nothing to clean */ }
528
- throw e;
484
+ const err2 = e;
485
+ if (err2.code === "ENOENT") return structuredClone(DEFAULT_CONFIG);
486
+ console.warn(`[kandown] kandown.json is corrupted, using defaults: ${e.message}`);
487
+ return structuredClone(DEFAULT_CONFIG);
488
+ }
489
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
490
+ console.warn("[kandown] kandown.json must be a JSON object, using defaults.");
491
+ return structuredClone(DEFAULT_CONFIG);
492
+ }
493
+ const obj = raw;
494
+ const safeObj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
495
+ const boardRaw = safeObj(obj.board);
496
+ const merged = {
497
+ ui: { ...DEFAULT_CONFIG.ui, ...safeObj(obj.ui) },
498
+ agent: { ...DEFAULT_CONFIG.agent, ...safeObj(obj.agent) },
499
+ board: {
500
+ ...DEFAULT_CONFIG.board,
501
+ ...boardRaw,
502
+ columns: Array.isArray(boardRaw.columns) && boardRaw.columns.length > 0 ? boardRaw.columns.filter((name) => typeof name === "string" && name.trim().length > 0) : DEFAULT_CONFIG.board.columns
503
+ },
504
+ fields: { ...DEFAULT_CONFIG.fields, ...safeObj(obj.fields) },
505
+ notifications: { ...DEFAULT_CONFIG.notifications, ...safeObj(obj.notifications) }
506
+ };
507
+ if (obj.agents && typeof obj.agents === "object") {
508
+ merged.agents = obj.agents;
529
509
  }
510
+ return merged;
530
511
  }
531
512
 
532
- // 📖 Output contract (M2): stdout carries DATA ONLY (ids, JSON, tables, help)
533
- // so `$(kandown create ...)` and `| jq` pipelines stay clean. Every
534
- // decoration — status lines, warnings, errors, progress — goes to stderr.
535
- /** Data output → stdout. Use ONLY for content the command was asked to produce. */
536
- const out = (msg) => console.log(msg);
537
- /** Decoration output → stderr (status, hints, banners). */
538
- const log = (msg) => console.error(msg);
539
- const success = (msg) => log(`${c.green}✓${c.reset} ${msg}`);
540
- const info = (msg) => log(`${c.cyan}→${c.reset} ${msg}`);
541
- const warn = (msg) => log(`${c.yellow}⚠${c.reset} ${msg}`);
542
- const err = (msg) => log(`${c.red}✗${c.reset} ${msg}`);
543
-
544
- // ─── TUI spinner & progress bar ─────────────────────────────────────────────
545
- // 📖 Lightweight terminal animation helpers for the auto-updater.
546
- // Uses ANSI escape codes (carriage return + clear line) to redraw in-place.
547
- // Only animate when stdout is a TTY; otherwise fall back to plain text.
548
-
549
- const _SP_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
550
- let _spTimer = null;
551
- let _spIdx = 0;
552
- // 📖 Animations are decorations → stderr, and only when stderr is a TTY.
553
- const _isTTY = process.stderr.isTTY;
554
-
555
- function _tuiClear() {
556
- process.stderr.write('\r\x1b[K');
557
- }
558
-
559
- /**
560
- * 📖 Start a time-estimated progress bar + spinner.
561
- * Shows a filling bar with percentage based on elapsed time.
562
- * Caps at 95% until the caller calls tuiDone().
563
- */
564
- function tuiProgress(text, estimateSec = 25, barWidth = 15) {
565
- if (_spTimer) clearInterval(_spTimer);
566
- _spIdx = 0;
567
- _tuiClear();
568
- if (!_isTTY) { process.stderr.write(` ${text}\n`); return; }
569
- const start = Date.now();
570
- _spTimer = setInterval(() => {
571
- const elapsed = (Date.now() - start) / 1000;
572
- const pct = Math.min(Math.round((elapsed / estimateSec) * 100), 95);
573
- const filled = Math.round(barWidth * pct / 100);
574
- const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
575
- process.stderr.write(`\r\x1b[K${_SP_FRAMES[_spIdx % _SP_FRAMES.length]} ${text} ${bar} ${pct}%`);
576
- _spIdx++;
577
- }, 120);
513
+ // src/cli/lib/board-reader.ts
514
+ function getProjectRoot(kandownDir) {
515
+ return dirname(kandownDir);
578
516
  }
579
-
580
- /** Stop the current animation and print a final status line. */
581
- function tuiDone(symbol, text) {
582
- if (_spTimer) { clearInterval(_spTimer); _spTimer = null; }
583
- if (_isTTY) {
584
- _tuiClear();
585
- process.stderr.write(`${symbol} ${text}\n`);
586
- } else {
587
- log(`${symbol} ${text}`);
588
- }
517
+ function getTasksDir(kandownDir) {
518
+ return join3(getProjectRoot(kandownDir), "tasks");
589
519
  }
590
-
591
- function help() {
592
- const v = getCurrentVersion() ?? '?';
593
- out(`
594
- ${c.bold}kandown${c.reset} ${c.dim}· file-based kanban backed by markdown${c.reset}
595
- ${c.dim}v${v}${c.reset}
596
-
597
- ${c.bold}Usage:${c.reset}
598
- npx kandown [command]
599
-
600
- ${c.bold}Commands:${c.reset}
601
- ${c.cyan}(none)${c.reset} Start local web UI server + open the board TUI
602
- ${c.cyan}board${c.reset} Open the interactive kanban board in the terminal
603
- ${c.cyan}init${c.reset} Initialize .kandown/ in the current directory
604
- ${c.cyan}settings${c.reset} Open the settings TUI
605
- ${c.cyan}daemon${c.reset} Manage web daemons (start|stop|status|refresh-all)
606
- ${c.cyan}update${c.reset} Update kandown.html to the latest version
607
- ${c.cyan}list${c.reset} List tasks ${c.dim}(-s status, -a assignee, -t tag, -p priority, --json)${c.reset}
608
- ${c.cyan}show${c.reset} Print a task's raw file content
609
- ${c.cyan}create${c.reset} Create a task ${c.dim}(-p priority, -a assignee, -t tag, --to status)${c.reset}
610
- ${c.cyan}move${c.reset} Move a task to a column (or "archived")
611
- ${c.cyan}assign${c.reset} Assign / unassign a task
612
- ${c.cyan}commit${c.reset} git add + commit the task files
613
- ${c.cyan}tasks${c.reset} Full help for the commands above
614
- ${c.cyan}work${c.reset} ${c.bold}For AI agents:${c.reset} print the agent rules + live board digest
615
- ${c.cyan}help${c.reset} Show this help
616
-
617
- ${c.bold}Options:${c.reset}
618
- ${c.cyan}--port <n>${c.reset} Preferred local HTTP port for ${c.cyan}kandown${c.reset} (default: ${START_PORT_RANGE}, auto-increments to ${END_PORT_RANGE})
619
-
620
- ${c.bold}Examples:${c.reset}
621
- ${c.dim}$${c.reset} npx kandown ${c.dim}# local web server + board TUI${c.reset}
622
- ${c.dim}$${c.reset} npx kandown --port 3000 ${c.dim}# use a specific web UI port${c.reset}
623
- ${c.dim}$${c.reset} npx kandown board ${c.dim}# board TUI only${c.reset}
624
- ${c.dim}$${c.reset} npx kandown daemon stop ${c.dim}# stop this project's web daemon${c.reset}
625
- ${c.dim}$${c.reset} npx kandown daemon refresh-all ${c.dim}# refresh/restart outdated open daemons${c.reset}
626
- ${c.dim}$${c.reset} npx kandown init
627
- ${c.dim}$${c.reset} npx kandown init --path docs/kanban
628
- ${c.dim}$${c.reset} npx kandown init --no-agents
629
- ${c.dim}$${c.reset} npx kandown work ${c.dim}# what an AI agent should run first${c.reset}
630
- ${c.dim}$${c.reset} npx kandown list --json
631
- ${c.dim}$${c.reset} npx kandown create "Refactor auth" -p P1
632
- ${c.dim}$${c.reset} npx kandown commit -m "tasks: refactor auth"
633
- `);
520
+ function listTaskIds(kandownDir) {
521
+ const tasksDir = getTasksDir(kandownDir);
522
+ if (!existsSync3(tasksDir)) return [];
523
+ return readdirSync(tasksDir).filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -3)).sort((a, b) => a.localeCompare(b, void 0, { numeric: true }));
634
524
  }
635
-
636
- /**
637
- * 📖 Resilient recursive copy used by `kandown init` (t113).
638
- * Returns the list of per-entry error messages; an empty array means a fully
639
- * clean copy. A partially-failed init no longer leaves the user with no clue
640
- * about what went wrong — the caller reports each failure.
641
- */
642
- function copyRecursive(src, dest, errors = []) {
643
- if (!existsSync(src)) return errors;
644
- try {
645
- mkdirSync(dest, { recursive: true });
646
- } catch (e) {
647
- errors.push(`Failed to create directory ${dest}: ${e.message}`);
648
- return errors;
649
- }
650
- let entries;
651
- try {
652
- entries = readdirSync(src);
653
- } catch (e) {
654
- errors.push(`Failed to read ${src}: ${e.message}`);
655
- return errors;
656
- }
657
- for (const entry of entries) {
658
- const srcPath = join(src, entry);
659
- const destPath = join(dest, entry);
525
+ function readBoard(kandownDir) {
526
+ const config = loadConfig(kandownDir);
527
+ const ids = listTaskIds(kandownDir);
528
+ const tasks = [];
529
+ for (const id of ids) {
660
530
  try {
661
- if (statSync(srcPath).isDirectory()) {
662
- copyRecursive(srcPath, destPath, errors);
663
- } else {
664
- copyFileSync(srcPath, destPath);
665
- }
531
+ const task = readTask(kandownDir, id);
532
+ tasks.push({
533
+ ...task,
534
+ frontmatter: {
535
+ ...task.frontmatter,
536
+ id: task.frontmatter.id || id,
537
+ status: task.frontmatter.status || "Backlog"
538
+ }
539
+ });
666
540
  } catch (e) {
667
- // 📖 One unreadable / unwritable file shouldn't abort the whole init —
668
- // record the error and keep copying the rest (t113).
669
- errors.push(`Failed to copy ${srcPath}: ${e.message}`);
541
+ console.error(`[kandown] Failed to read task ${id}:`, e.message);
670
542
  }
671
543
  }
672
- return errors;
544
+ return {
545
+ frontmatter: null,
546
+ title: "Project Kanban",
547
+ columns: buildColumnsFromTasks(tasks, config.board.columns)
548
+ };
673
549
  }
674
-
675
- function findAgentsFile(cwd) {
676
- const candidates = ['AGENTS.md', 'CLAUDE.md', '.cursorrules', '.github/copilot-instructions.md'];
677
- for (const cand of candidates) {
678
- const p = join(cwd, cand);
679
- if (existsSync(p)) return cand;
550
+ function readTask(kandownDir, taskId) {
551
+ const taskPath = join3(getTasksDir(kandownDir), `${taskId}.md`);
552
+ if (!existsSync3(taskPath)) {
553
+ return {
554
+ frontmatter: { id: taskId, title: `Task ${taskId}`, status: "Backlog" },
555
+ body: ""
556
+ };
680
557
  }
681
- return null;
558
+ const content = readFileSync3(taskPath, "utf8");
559
+ const parsed = parseTaskFile(content);
560
+ return {
561
+ ...parsed,
562
+ frontmatter: {
563
+ ...parsed.frontmatter,
564
+ id: parsed.frontmatter.id || taskId,
565
+ status: parsed.frontmatter.status || "Backlog"
566
+ }
567
+ };
682
568
  }
683
-
684
- function appendAgentReference(cwd, agentsFile, kandownPath) {
685
- const filePath = join(cwd, agentsFile);
686
- const marker = '<!-- kandown:agent-ref -->';
687
- let existing;
569
+ var PKG_ROOT2 = dirname(dirname(fileURLToPath(import.meta.url)));
570
+ function readAgentDoc(kandownDir) {
571
+ const sections = [];
688
572
  try {
689
- existing = readFileSync(filePath, 'utf8');
573
+ sections.push(readFileSync3(join3(PKG_ROOT2, "templates", "AGENT_KANDOWN.md"), "utf8").trim());
690
574
  } catch (e) {
691
- // 📖 TOCTOU: file existed at the check but vanished or became unreadable.
692
- // Warn and skip rather than crashing init (t113).
693
- warn(`Could not read ${agentsFile} (${e.message}); skipping agent reference.`);
694
- return false;
575
+ console.warn("[kandown] Could not read base agent rules:", e.message);
695
576
  }
577
+ const globalPath = join3(homedir2(), ".kandown", "instructions.md");
578
+ if (existsSync3(globalPath)) {
579
+ try {
580
+ sections.push(`## Global instructions
696
581
 
697
- if (existing.includes(marker)) {
698
- info(`${agentsFile} already references the kandown (skipped)`);
699
- return false;
582
+ ${readFileSync3(globalPath, "utf8").trim()}`);
583
+ } catch (e) {
584
+ console.warn(`[kandown] Could not read ${globalPath}:`, e.message);
585
+ }
700
586
  }
587
+ const projectPath = join3(kandownDir, "instructions.md");
588
+ if (existsSync3(projectPath)) {
589
+ try {
590
+ sections.push(`## Project-specific instructions
701
591
 
702
- const ref = `
703
-
704
- ${marker}
705
- ## Task management
706
-
707
- This project uses **kandown** for task management. **Always run \`kandown work\` when starting a new task** — it prints the current rules and board state, kept in sync with the installed CLI version. (Tasks live in \`./tasks/*.md\`.)
708
- `;
592
+ ${readFileSync3(projectPath, "utf8").trim()}`);
593
+ } catch (e) {
594
+ console.warn(`[kandown] Could not read ${projectPath}:`, e.message);
595
+ }
596
+ }
597
+ try {
598
+ const root = getProjectRoot(kandownDir);
599
+ const gitLog = execFileSync("git", ["log", "-n", "5", "--oneline", "--", "tasks/"], { cwd: root, encoding: "utf8" }).trim();
600
+ if (gitLog) {
601
+ sections.push(`## Recent Task Activity (Git History)
709
602
 
603
+ \`\`\`
604
+ ${gitLog}
605
+ \`\`\``);
606
+ }
607
+ } catch {
608
+ }
609
+ return sections.filter(Boolean).join("\n\n---\n\n");
610
+ }
611
+ function moveTaskToColumn(kandownDir, taskId, targetColumn) {
612
+ const taskPath = join3(getTasksDir(kandownDir), `${taskId}.md`);
613
+ if (!existsSync3(taskPath)) return false;
710
614
  try {
711
- writeFileSync(filePath, existing + ref, 'utf8');
615
+ const prevContent = readFileSync3(taskPath, "utf8");
616
+ const parsed = readTask(kandownDir, taskId);
617
+ const newContent = serializeTaskFile({
618
+ ...parsed.frontmatter,
619
+ id: taskId,
620
+ status: targetColumn
621
+ }, parsed.body);
622
+ atomicWriteFileSync(taskPath, newContent);
623
+ pushUndo(kandownDir, {
624
+ type: "move",
625
+ taskId,
626
+ path: taskPath,
627
+ previousContent: prevContent,
628
+ newContent,
629
+ timestamp: Date.now()
630
+ });
712
631
  return true;
713
632
  } catch (e) {
714
- warn(`Could not append agent reference to ${agentsFile} (${e.message}).`);
633
+ console.error(`[kandown] Failed to move task ${taskId} to ${targetColumn}:`, e.message);
715
634
  return false;
716
635
  }
717
636
  }
718
-
719
- function createAgentsFileIfMissing(cwd, kandownPath) {
720
- const agentsPath = join(cwd, 'AGENTS.md');
721
- if (existsSync(agentsPath)) return false;
722
-
723
- const content = `# Agent instructions
724
-
725
- <!-- kandown:agent-ref -->
726
- ## Task management
727
-
728
- This project uses **kandown** for task management. **Always run \`kandown work\` when starting a new task** — it prints the current rules and board state, kept in sync with the installed CLI version. (Tasks live in \`./tasks/*.md\`.)
729
- `;
730
- writeFileSync(agentsPath, content, 'utf8');
731
- return true;
732
- }
733
-
734
- function parseArgs(argv) {
735
- const args = { path: '.kandown', noAgents: false, force: false, port: null };
736
- for (let i = 0; i < argv.length; i++) {
737
- const a = argv[i];
738
- if (a === '--path') args.path = argv[++i];
739
- else if (a === '--port') args.port = argv[++i];
740
- else if (a === '--no-agents') args.noAgents = true;
741
- else if (a === '--force' || a === '-f') args.force = true;
637
+ function pushUndo(kandownDir, record) {
638
+ try {
639
+ const undoDir = join3(kandownDir, ".undo");
640
+ if (!existsSync3(undoDir)) mkdirSync(undoDir, { recursive: true });
641
+ const logPath = join3(undoDir, "log.json");
642
+ let list = [];
643
+ if (existsSync3(logPath)) {
644
+ try {
645
+ list = JSON.parse(readFileSync3(logPath, "utf8"));
646
+ } catch {
647
+ list = [];
648
+ }
649
+ }
650
+ list.unshift(record);
651
+ if (list.length > 50) list = list.slice(0, 50);
652
+ atomicWriteFileSync(logPath, JSON.stringify(list, null, 2));
653
+ } catch {
742
654
  }
743
- return args;
744
655
  }
745
656
 
746
- /**
747
- * 📖 Returns the absolute path of the project root (parent of the kandown
748
- * config dir). Tasks live at the project root in `./tasks/`, not inside
749
- * `.kandown/tasks/`. Used by every code path that touches task files.
750
- * @param {string} kandownDir absolute path to the kandown config dir
751
- * @returns {string} absolute path to the project root
752
- */
753
- function getProjectRoot(kandownDir) {
754
- return dirname(kandownDir);
657
+ // src/cli/lib/daemon.ts
658
+ import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync as unlinkSync4 } from "fs";
659
+ import { dirname as dirname2, join as join4 } from "path";
660
+ import { execFileSync as execFileSync2, spawn as spawn2 } from "child_process";
661
+ import { createConnection } from "net";
662
+ function metadataPath(kandownDir) {
663
+ return join4(kandownDir, "daemon.json");
755
664
  }
756
-
757
- /**
758
- * 📖 Returns the absolute path of the tasks directory at the project root.
759
- * Mirrors the File System Access layout: `./tasks/` is a sibling of `.kandown/`.
760
- * @param {string} kandownDir absolute path to the kandown config dir
761
- * @returns {string} absolute path to `./tasks/`
762
- */
763
- function getTasksDir(kandownDir) {
764
- return join(getProjectRoot(kandownDir), 'tasks');
665
+ function isRecord(value) {
666
+ return typeof value === "object" && value !== null;
765
667
  }
766
-
767
- /**
768
- * 📖 Silently migrates task files from the legacy `.kandown/tasks/` location
769
- * to the new top-level `./tasks/` location. Idempotent: returns
770
- * `{ moved, cleanedUp }` describing what was done (or nothing).
771
- *
772
- * Rules:
773
- * - Never throw failures are logged and skipped so a single bad file
774
- * doesn't block the rest of the migration.
775
- *
776
- * @param {string} kandownDir absolute path to the kandown config dir
777
- * @returns {{ moved: number, cleanedUp: boolean, skipped: boolean }}
778
- */
779
- function migrateTasksToTopLevel(kandownDir) {
780
- const oldDir = join(kandownDir, 'tasks');
781
- const newDir = getTasksDir(kandownDir);
782
- const result = { moved: 0, cleanedUp: false, skipped: false };
783
-
784
- if (!existsSync(oldDir)) return result;
785
-
786
- // 📖 Check FIRST whether the legacy dir actually has anything to migrate
787
- // (top-level .md files or an archive/ subfolder with .md files). A stray
788
- // empty `.kandown/tasks/` — common on old/incomplete installs — has
789
- // nothing worth protecting, so it should just get cleaned up regardless
790
- // of what's in `./tasks/`, instead of being misreported as a "conflict".
791
- const oldMdFiles = readdirSync(oldDir, { withFileTypes: true })
792
- .filter(e => e.isFile() && e.name.endsWith('.md'));
793
- const oldArchiveDir = join(oldDir, 'archive');
794
- const oldArchiveHasMd = existsSync(oldArchiveDir)
795
- && readdirSync(oldArchiveDir, { withFileTypes: true }).some(e => e.isFile() && e.name.endsWith('.md'));
796
-
797
- if (oldMdFiles.length === 0 && !oldArchiveHasMd) {
798
- return cleanupLegacyTasksDir(kandownDir, result);
799
- }
800
-
801
- if (!existsSync(newDir)) mkdirSync(newDir, { recursive: true });
802
-
803
- // 📖 If the new location already has any .md file, don't touch anything.
804
- // The user is in a hybrid state and we should not clobber their work.
805
- const existingNew = existsSync(newDir)
806
- ? readdirSync(newDir).filter(n => n.endsWith('.md'))
807
- : [];
808
- if (existingNew.length > 0) {
809
- result.skipped = true;
810
- return result;
811
- }
812
-
813
- for (const entry of oldMdFiles) {
814
- try {
815
- renameSync(join(oldDir, entry.name), join(newDir, entry.name));
816
- result.moved += 1;
817
- } catch (e) {
818
- warn(`migrate: could not move ${entry.name} (${e.code ?? e.message})`);
819
- }
668
+ function parseMetadata(value) {
669
+ if (!isRecord(value)) return null;
670
+ const { pid, port, url, kandownDir, startedAt, version, token } = value;
671
+ if (typeof pid !== "number" || !Number.isInteger(pid)) return null;
672
+ if (typeof port !== "number" || !Number.isInteger(port)) return null;
673
+ if (typeof url !== "string" || typeof kandownDir !== "string") return null;
674
+ if (typeof startedAt !== "string") return null;
675
+ if (version !== null && typeof version !== "string" && version !== void 0) return null;
676
+ if (token !== null && typeof token !== "string" && token !== void 0) return null;
677
+ return { pid, port, url, kandownDir, startedAt, version: version ?? null, token: typeof token === "string" ? token : null };
678
+ }
679
+ function readDaemonMetadata(kandownDir) {
680
+ const path = metadataPath(kandownDir);
681
+ if (!existsSync4(path)) return null;
682
+ try {
683
+ return parseMetadata(JSON.parse(readFileSync4(path, "utf8")));
684
+ } catch {
685
+ return null;
820
686
  }
821
-
822
- return migrateArchive(kandownDir, result);
823
687
  }
824
-
825
- /**
826
- * 📖 Helper that moves the legacy `archive/` subfolder to the new location.
827
- * Called from migrateTasksToTopLevel after the active files have been moved.
828
- */
829
- function migrateArchive(kandownDir, result) {
830
- const oldArchive = join(kandownDir, 'tasks', 'archive');
831
- const newArchive = join(getTasksDir(kandownDir), 'archive');
832
- if (!existsSync(oldArchive)) return cleanupLegacyTasksDir(kandownDir, result);
833
-
834
- if (!existsSync(newArchive)) mkdirSync(newArchive, { recursive: true });
835
- for (const entry of readdirSync(oldArchive, { withFileTypes: true })) {
836
- if (entry.isFile() && entry.name.endsWith('.md')) {
837
- try {
838
- renameSync(join(oldArchive, entry.name), join(newArchive, entry.name));
839
- result.moved += 1;
840
- } catch (e) {
841
- warn(`migrate: could not move archive/${entry.name} (${e.code ?? e.message})`);
842
- }
843
- }
688
+ function removeDaemonMetadata(kandownDir) {
689
+ try {
690
+ const path = metadataPath(kandownDir);
691
+ if (existsSync4(path)) unlinkSync4(path);
692
+ } catch {
844
693
  }
845
-
846
- return cleanupLegacyTasksDir(kandownDir, result);
847
694
  }
848
-
849
- /**
850
- * 📖 Removes the legacy `.kandown/tasks/` directory only if it is empty.
851
- * Preserves any leftover non-md files (e.g. `.scratch/` notes) so we never
852
- * delete user data the migration didn't move.
853
- */
854
- function cleanupLegacyTasksDir(kandownDir, result) {
855
- const oldDir = join(kandownDir, 'tasks');
856
- if (!existsSync(oldDir)) return result;
857
- const remaining = readdirSync(oldDir);
858
- if (remaining.length === 0) {
859
- try {
860
- // 📖 fs.rmSync requires { recursive: true } to remove a directory at
861
- // all — even an already-confirmed-empty one — or it throws EISDIR.
862
- // `remaining.length === 0` just verified above makes this safe.
863
- rmSync(oldDir, { recursive: true });
864
- result.cleanedUp = true;
865
- } catch { /* directory not empty or platform limitation — leave it */ }
695
+ function isProcessAlive(pid) {
696
+ try {
697
+ process.kill(pid, 0);
698
+ return true;
699
+ } catch {
700
+ return false;
866
701
  }
867
- return result;
868
702
  }
869
-
870
- /**
871
- * @returns {{ kandownDir: string, alreadyExisted: boolean }} resolves the
872
- * kandown directory and auto-inits it if it doesn't exist (no prompt, silent init).
873
- * Also performs a one-time silent migration of tasks from `.kandown/tasks/` to
874
- * the project root `./tasks/` on first access of a legacy project.
875
- */
876
- function ensureKandownDir(rawArgs) {
877
- const args = parseArgs(rawArgs);
878
- const cwd = process.cwd();
879
- const kandownDir = resolve(cwd, args.path);
880
-
881
- if (existsSync(kandownDir)) {
882
- // 📖 Silent one-time migration: move any legacy `.kandown/tasks/*.md` to
883
- // the project-root `./tasks/`. Idempotent — safe to call on every startup.
884
- const migration = migrateTasksToTopLevel(kandownDir);
885
- if (migration.moved > 0) {
886
- success(`Migrated ${migration.moved} task${migration.moved === 1 ? '' : 's'} from .kandown/tasks/ to ./tasks/`);
887
- if (migration.cleanedUp) info('Removed empty .kandown/tasks/ folder');
888
- } else if (migration.skipped) {
889
- info('Both .kandown/tasks/ and ./tasks/ have files — leaving both in place');
890
- }
891
- syncKandownAgentDoc(kandownDir);
892
- return { kandownDir, alreadyExisted: true };
893
- }
894
-
895
- log('');
896
- info(`No .kandown/ found — auto-installing...`);
897
- doInit(args, cwd, args.path, kandownDir);
898
- return { kandownDir, alreadyExisted: false };
703
+ function parseRemoteDaemonInfo(value) {
704
+ if (!isRecord(value)) return null;
705
+ const { ok, pid, kandownDir } = value;
706
+ if (ok !== true || typeof pid !== "number" || !Number.isInteger(pid) || typeof kandownDir !== "string") return null;
707
+ return { ok, pid, kandownDir };
899
708
  }
900
-
901
- /**
902
- * Performs the actual init work. Returns on error (does not exit).
903
- * @returns {boolean} true if init succeeded, false otherwise.
904
- */
905
- function doInit(args, cwd, kandownPath, kandownDir) {
906
- mkdirSync(kandownDir, { recursive: true });
907
-
908
- const htmlSrc = join(PKG_ROOT, 'dist', 'index.html');
909
- const htmlDest = join(kandownDir, 'kandown.html');
910
- if (!existsSync(htmlSrc)) {
911
- err(`Missing build output at ${htmlSrc}. Did you run 'npm run build'?`);
912
- return false;
709
+ async function fetchDaemonInfo(port) {
710
+ try {
711
+ const response = await fetch(`http://127.0.0.1:${port}/api/daemon`, {
712
+ signal: AbortSignal.timeout(700)
713
+ });
714
+ if (!response.ok) return null;
715
+ return parseRemoteDaemonInfo(await response.json());
716
+ } catch {
717
+ return null;
913
718
  }
914
- copyFileSync(htmlSrc, htmlDest);
915
- success('kandown.html');
916
-
917
- // 📖 Keep the generated agent reference inside `.kandown/` only. It is safe
918
- // to recreate/overwrite because user-specific behavior belongs in
919
- // `.kandown/instructions.md` or the `agent.workOutput` config, not in this
920
- // generated package reference.
921
- syncKandownAgentDoc(kandownDir);
922
- success('AGENT_KANDOWN.md');
923
-
924
- const templatesDir = join(PKG_ROOT, 'templates');
925
- if (!existsSync(join(kandownDir, 'README.md'))) {
926
- copyFileSync(join(templatesDir, 'README.md'), join(kandownDir, 'README.md'));
927
- success('README.md');
719
+ }
720
+ function isPortListening(port, timeoutMs = 400) {
721
+ return new Promise((resolve3) => {
722
+ const socket = createConnection({ port, host: "127.0.0.1" }, () => {
723
+ socket.destroy();
724
+ resolve3(true);
725
+ });
726
+ socket.on("error", () => resolve3(false));
727
+ socket.setTimeout(timeoutMs);
728
+ socket.on("timeout", () => {
729
+ socket.destroy();
730
+ resolve3(false);
731
+ });
732
+ });
733
+ }
734
+ async function getDaemonStatus(kandownDir) {
735
+ const metadata = readDaemonMetadata(kandownDir);
736
+ if (!metadata) return { running: false, metadata: null };
737
+ if (!isProcessAlive(metadata.pid)) {
738
+ removeDaemonMetadata(kandownDir);
739
+ return { running: false, metadata: null };
928
740
  }
929
-
930
- // 📖 Tasks live at the project root in `./tasks/`, not inside `.kandown/`.
931
- // This keeps config separate from data and follows the user-facing convention.
932
- const tasksSrc = join(templatesDir, 'tasks');
933
- const tasksDest = getTasksDir(kandownDir);
934
- if (!existsSync(tasksDest)) {
935
- mkdirSync(tasksDest, { recursive: true });
936
- // 📖 copyRecursive now returns an errors[] array — report any partial
937
- // failures instead of letting them crash the whole init (t113).
938
- const copyErrors = copyRecursive(tasksSrc, tasksDest);
939
- if (copyErrors.length > 0) {
940
- warn(`Some task template files could not be copied:`);
941
- for (const msg of copyErrors) warn(` - ${msg}`);
942
- }
943
- success('./tasks/ (with welcome example)');
944
- } else {
945
- info('./tasks/ already exists (kept)');
741
+ const remote = await fetchDaemonInfo(metadata.port);
742
+ if (!remote) {
743
+ return { running: false, metadata: null };
946
744
  }
947
-
948
- if (!existsSync(join(kandownDir, 'kandown.json'))) {
949
- copyFileSync(join(templatesDir, 'kandown.json'), join(kandownDir, 'kandown.json'));
950
- success('kandown.json');
951
- } else {
952
- info('kandown.json already exists (kept)');
745
+ if (remote.pid !== metadata.pid || remote.kandownDir !== kandownDir) {
746
+ removeDaemonMetadata(kandownDir);
747
+ return { running: false, metadata: null };
953
748
  }
954
-
955
- const kandownGitignore = join(kandownDir, '.gitignore');
956
- if (!existsSync(kandownGitignore)) {
957
- writeFileSync(kandownGitignore, `${DAEMON_FILE}\n`, 'utf8');
958
- success('.gitignore (daemon runtime metadata ignored)');
749
+ return { running: true, metadata };
750
+ }
751
+ async function waitForDaemon(kandownDir, timeoutMs = 8e3) {
752
+ const started = Date.now();
753
+ while (Date.now() - started < timeoutMs) {
754
+ const metadata = readDaemonMetadata(kandownDir);
755
+ if (metadata && isProcessAlive(metadata.pid) && await isPortListening(metadata.port)) {
756
+ return { running: true, metadata };
757
+ }
758
+ await new Promise((resolve3) => setTimeout(resolve3, 120));
959
759
  }
960
-
961
- if (!args.noAgents) {
962
- log('');
963
- const existingAgents = findAgentsFile(cwd);
964
- if (existingAgents) {
965
- const added = appendAgentReference(cwd, existingAgents, kandownPath);
966
- if (added) success(`Appended kandown reference to ${c.bold}${existingAgents}${c.reset}`);
760
+ return { running: false, metadata: null };
761
+ }
762
+ async function startProjectDaemon(kandownDir, preferredPort) {
763
+ const current = await getDaemonStatus(kandownDir);
764
+ if (current.running) return current;
765
+ const cliPath = process.argv[1];
766
+ if (!cliPath) throw new Error("Cannot locate kandown CLI entrypoint");
767
+ const args = [
768
+ cliPath,
769
+ "--no-update-check",
770
+ "daemon",
771
+ "run",
772
+ "--path",
773
+ kandownDir
774
+ ];
775
+ if (typeof preferredPort === "number" && Number.isInteger(preferredPort)) {
776
+ args.push("--port", String(preferredPort));
777
+ }
778
+ const child = spawn2(process.execPath, args, {
779
+ cwd: dirname2(kandownDir),
780
+ detached: true,
781
+ stdio: "ignore",
782
+ env: { ...process.env, KANDOWN_DAEMON: "1" }
783
+ });
784
+ child.unref();
785
+ return waitForDaemon(kandownDir);
786
+ }
787
+
788
+ // src/cli/cli.ts
789
+ var c = {
790
+ reset: "\x1B[0m",
791
+ bold: "\x1B[1m",
792
+ dim: "\x1B[2m",
793
+ red: "\x1B[31m",
794
+ green: "\x1B[32m",
795
+ yellow: "\x1B[33m",
796
+ blue: "\x1B[34m",
797
+ cyan: "\x1B[36m"
798
+ };
799
+ function log(msg) {
800
+ console.log(msg);
801
+ }
802
+ function info(msg) {
803
+ console.log(`${c.blue}\u2139${c.reset} ${msg}`);
804
+ }
805
+ function success(msg) {
806
+ console.log(`${c.green}\u2713${c.reset} ${msg}`);
807
+ }
808
+ function err(msg) {
809
+ console.error(`${c.red}\u2717${c.reset} ${msg}`);
810
+ }
811
+ function parseArgs(args) {
812
+ const flags = {};
813
+ const positional = [];
814
+ for (let i = 0; i < args.length; i++) {
815
+ const a = args[i];
816
+ if (a.startsWith("--")) {
817
+ const key = a.slice(2);
818
+ const next = args[i + 1];
819
+ if (next && !next.startsWith("-")) {
820
+ flags[key] = next;
821
+ i++;
822
+ } else {
823
+ flags[key] = true;
824
+ }
825
+ } else if (a.startsWith("-")) {
826
+ flags[a.slice(1)] = true;
967
827
  } else {
968
- const created = createAgentsFileIfMissing(cwd, kandownPath);
969
- if (created) success(`Created ${c.bold}AGENTS.md${c.reset} with kandown reference`);
828
+ positional.push(a);
970
829
  }
971
830
  }
972
-
973
- const hasGit = existsSync(join(cwd, '.git'));
974
- if (!hasGit) {
975
- log('');
976
- warn(`No git repository detected in ${c.bold}${cwd}${c.reset}.`);
977
- info(` Tip: Run ${c.cyan}git init${c.reset} and track ${c.bold}./tasks/${c.reset} for git history & portability!`);
831
+ return { flags, positional, path: typeof flags.path === "string" ? flags.path : ".kandown" };
832
+ }
833
+ function resolveKandownDir(pathArg = ".kandown", cwd = process.cwd()) {
834
+ if (basename(cwd) === ".kandown" || existsSync5(join5(cwd, "kandown.json"))) {
835
+ return cwd;
978
836
  }
979
-
980
- return true;
837
+ return resolve2(cwd, pathArg);
981
838
  }
982
-
983
- function cmdInit(rawArgs) {
839
+ function ensureKandownDir(rawArgs) {
984
840
  const args = parseArgs(rawArgs);
985
841
  const cwd = process.cwd();
986
- const kandownPath = args.path;
987
- const kandownDir = resolve(cwd, kandownPath);
988
-
989
- if (existsSync(join(cwd, '.kandown', 'board.md'))) {
990
- err(`Kandown already initialized in this directory. To reinitialize, remove the .kandown folder first.`);
991
- process.exit(1);
992
- }
993
-
994
- log('');
995
- info(`Installing kandown in ${c.bold}${kandownPath}/${c.reset}`);
996
- log('');
997
-
998
- if (existsSync(kandownDir) && !args.force) {
999
- err(`Directory ${c.bold}${kandownPath}/${c.reset} already exists.`);
1000
- log(` Use ${c.cyan}--force${c.reset} to overwrite or ${c.cyan}--path <dir>${c.reset} for another location.`);
842
+ const kandownDir = resolveKandownDir(args.path, cwd);
843
+ if (!existsSync5(kandownDir)) {
844
+ err(`No Kandown installation found at ${c.bold}${kandownDir}${c.reset}`);
845
+ log(` Run ${c.cyan}npx kandown init${c.reset} to create one.`);
1001
846
  process.exit(1);
1002
847
  }
1003
-
1004
- if (!doInit(args, cwd, kandownPath, kandownDir)) process.exit(1);
1005
-
1006
- log('');
1007
- log(`${c.green}${c.bold}Done.${c.reset}`);
1008
- log('');
1009
- log(` ${c.dim}Layout:${c.reset}`);
1010
- log(` ${c.dim}└─${c.reset} ${c.bold}.kandown/${c.reset} — config, web UI, agent docs`);
1011
- log(` ${c.dim}└─${c.reset} ${c.bold}tasks/${c.reset} — task files (source of truth)`);
1012
- log('');
1013
- log(` ${c.dim}Next steps:${c.reset}`);
1014
- log(` ${c.cyan}1.${c.reset} Open ${c.bold}${kandownPath}/kandown.html${c.reset} in Chrome/Edge/Brave`);
1015
- log(` ${c.cyan}2.${c.reset} Select the ${c.bold}project root${c.reset} (the parent of ${c.bold}${kandownPath}/${c.reset})`);
1016
- log(` ${c.cyan}3.${c.reset} Start creating tasks. Press ${c.cyan}⌘K${c.reset} for the command palette`);
1017
- log('');
1018
- log(` ${c.dim}macOS:${c.reset} open ${kandownPath}/kandown.html`);
1019
- log(` ${c.dim}Linux:${c.reset} xdg-open ${kandownPath}/kandown.html`);
1020
- log(` ${c.dim}Windows:${c.reset} start ${kandownPath}/kandown.html`);
1021
- log('');
848
+ return { kandownDir, cwd };
849
+ }
850
+ function help() {
851
+ const current = getCurrentVersion();
852
+ log(`
853
+ ${c.bold}Kandown CLI${c.reset} v${current} \u2014 file-based Kanban backed by Markdown
854
+
855
+ ${c.bold}USAGE:${c.reset}
856
+ kandown [command] [options]
857
+
858
+ ${c.bold}COMMANDS:${c.reset}
859
+ (none) Start web server & launch TUI
860
+ work Output agent rules + live board digest
861
+ list List tasks (alias: ls)
862
+ show <id> Display task details
863
+ create "<title>" Create new task (alias: new)
864
+ move <id> <status> Move task column
865
+ assign <id> <user> Assign task
866
+ commit Commit task changes to git
867
+ update Update kandown CLI to latest version (alias: upgrade)
868
+ doctor Run environment & board diagnostics
869
+ daemon Manage background daemon (status, start, stop, restart)
870
+ projects List open kandown projects
871
+ export Export board tasks to JSON
872
+ import <file> Import tasks from JSON/Markdown
873
+ mcp Start Model Context Protocol (MCP) server
874
+ help Show help screen
875
+
876
+ ${c.bold}OPTIONS:${c.reset}
877
+ --path <dir> Path to .kandown folder (default: .kandown)
878
+ --port <number> Server port (default: 2050)
879
+ --no-open Don't open browser automatically
880
+ --help, -h Show help screen
881
+ `);
1022
882
  }
1023
-
1024
883
  async function cmdUpdate(rawArgs) {
1025
884
  const current = getCurrentVersion();
1026
- log(`${c.bold}kandown update${c.reset} ${c.dim} v${current}${c.reset}`);
1027
-
1028
- // 📖 Step 1: Check latest version on npm registry
1029
- const latest = await new Promise((resolve) => {
1030
- const child = spawn('npm', ['view', 'kandown', 'version'], {
1031
- timeout: 6000,
1032
- stdio: ['pipe', 'pipe', 'pipe'],
885
+ log(`${c.bold}kandown update${c.reset} ${c.dim}\u2014 v${current}${c.reset}`);
886
+ const latest = await new Promise((resolve3) => {
887
+ const child = spawn3("npm", ["view", "kandown", "version"], {
888
+ timeout: 6e3,
889
+ stdio: ["pipe", "pipe", "pipe"],
1033
890
  env: { ...process.env },
1034
- detached: false,
891
+ detached: false
892
+ });
893
+ let stdout = "";
894
+ child.stdout.on("data", (d) => {
895
+ stdout += d;
1035
896
  });
1036
- let stdout = '';
1037
- child.stdout.on('data', (d) => { stdout += d; });
1038
- child.stderr.on('data', () => {});
1039
- child.on('error', () => resolve(null));
1040
- child.on('close', (code) => {
1041
- if (code !== 0) return resolve(null);
1042
- const v = stdout.trim().replace(/^"|"$/g, '');
1043
- resolve(v || null);
897
+ child.stderr.on("data", () => {
898
+ });
899
+ child.on("error", () => resolve3(null));
900
+ child.on("close", (code) => {
901
+ if (code !== 0) return resolve3(null);
902
+ const v = stdout.trim().replace(/^"|"$/g, "");
903
+ resolve3(v || null);
1044
904
  });
1045
905
  });
1046
-
1047
906
  if (latest && semverGt(latest, current) > 0) {
1048
- tuiProgress(`Updating kandown package v${current} v${latest}…`, 30);
907
+ info(`Updating kandown package v${current} \u2192 v${latest}\u2026`);
1049
908
  const updateOk = await performGlobalPackageUpdate(`kandown@${latest}`);
1050
909
  if (updateOk) {
1051
- tuiDone('✓', `${c.green}Successfully upgraded kandown to v${latest}${c.reset}`);
1052
- printVersionChangelog(latest);
910
+ success(`Successfully upgraded kandown to v${latest}`);
1053
911
  } else {
1054
- tuiDone('✗', `${c.yellow}Global CLI update failed${c.reset} try running: ${c.cyan}pnpm add -g kandown@latest${c.reset} or ${c.cyan}npm install -g kandown@latest${c.reset}`);
912
+ err(`Global CLI update failed \u2014 try: pnpm add -g kandown@latest or npm install -g kandown@latest`);
1055
913
  }
1056
914
  } else {
1057
- printVersionChangelog(current);
1058
915
  info(`kandown CLI is already up to date (v${current}).`);
1059
916
  }
1060
-
1061
917
  const args = parseArgs(rawArgs);
1062
918
  const cwd = process.cwd();
1063
- const kandownDir = resolve(cwd, args.path);
1064
- const htmlDest = join(kandownDir, 'kandown.html');
1065
-
1066
- if (existsSync(htmlDest)) {
1067
- const htmlSrc = join(PKG_ROOT, 'dist', 'index.html');
1068
- if (existsSync(htmlSrc)) {
919
+ const kandownDir = resolve2(cwd, args.path);
920
+ const htmlDest = join5(kandownDir, "kandown.html");
921
+ if (existsSync5(htmlDest)) {
922
+ const htmlSrc = resolve2(import.meta.url ? new URL("../..", import.meta.url).pathname : process.cwd(), "dist", "index.html");
923
+ if (existsSync5(htmlSrc)) {
1069
924
  copyFileSync(htmlSrc, htmlDest);
1070
925
  success(`Refreshed ${args.path}/kandown.html`);
1071
926
  }
1072
927
  }
1073
928
  }
1074
-
1075
- /* ═════════════ One-shot task commands ═════════════ */
1076
- /**
1077
- * 📖 Self-contained minimal YAML frontmatter parser/writer for the one-shot task
1078
- * commands. The web app uses a richer schema (parseSimpleYaml) in the
1079
- * browser; the CLI keeps its own because it can't import the browser bundle
1080
- * and these commands only need to round-trip a known small set of scalar
1081
- * fields (status, priority, assignee, tags, ownerType, depends_on, etc.).
1082
- *
1083
- * Quirk: tags and depends_on are emitted as inline YAML arrays
1084
- * `[a, b, c]` because list-as-block-scalar is harder to round-trip cleanly
1085
- * and shell tools downstream (jq, awk, grep) parse the inline form
1086
- * trivially.
1087
- */
1088
-
1089
- function parseFrontmatter(content) {
1090
- const out = { frontmatter: {}, body: content || '' };
1091
- if (!content || !content.startsWith('---\n')) return out;
1092
- const end = content.indexOf('\n---\n', 4);
1093
- if (end === -1) {
1094
- out.body = content;
1095
- return out;
1096
- }
1097
- const yaml = content.slice(4, end);
1098
- out.body = content.slice(end + 5).replace(/^\n+/, '');
1099
-
1100
- const lines = yaml.split('\n');
1101
- let i = 0;
1102
- while (i < lines.length) {
1103
- const line = lines[i];
1104
- if (!line.trim() || line.trim().startsWith('#')) {
1105
- i++;
1106
- continue;
1107
- }
1108
-
1109
- const blockMatch = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*\|\s*$/);
1110
- if (blockMatch) {
1111
- const key = blockMatch[1];
1112
- const valLines = [];
1113
- i++;
1114
- while (i < lines.length && (lines[i].startsWith(' ') || lines[i] === '')) {
1115
- if (lines[i].startsWith(' ')) {
1116
- valLines.push(lines[i].slice(2));
1117
- } else if (lines[i].startsWith(' ')) {
1118
- valLines.push(lines[i].slice(1));
1119
- } else {
1120
- valLines.push(lines[i]);
1121
- }
1122
- i++;
1123
- }
1124
- out.frontmatter[key] = valLines.join('\n');
1125
- continue;
1126
- }
1127
-
1128
- const m = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
1129
- if (!m) {
1130
- i++;
1131
- continue;
1132
- }
1133
- const key = m[1];
1134
- let val = (m[2] || '').trim();
1135
- if (val.startsWith('[') && val.endsWith(']')) {
1136
- val = val.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
1137
- } else if (val === 'true' || val === 'false') {
1138
- val = val === 'true';
1139
- } else if (val === 'null' || val === '') {
1140
- val = null;
1141
- } else if (/^-?\d+(\.\d+)?$/.test(val)) {
1142
- val = Number(val);
1143
- } else {
1144
- val = val.replace(/^["']|["']$/g, '');
1145
- }
1146
- out.frontmatter[key] = val;
1147
- i++;
1148
- }
1149
- return out;
1150
- }
1151
-
1152
- function serializeFrontmatter(fm, body) {
1153
- const lines = ['---'];
1154
- for (const [k, v] of Object.entries(fm || {})) {
1155
- if (v === null || v === undefined || v === '') continue;
1156
- if (Array.isArray(v)) {
1157
- if (v.length === 0) continue;
1158
- lines.push(`${k}: [${v.join(', ')}]`);
1159
- } else if (typeof v === 'string' && v.includes('\n')) {
1160
- lines.push(`${k}: |`);
1161
- lines.push(...v.split('\n').map(l => (l === '' ? '' : ` ${l}`)));
1162
- } else {
1163
- lines.push(`${k}: ${v}`);
1164
- }
1165
- }
1166
- lines.push('---', '', (body || '').replace(/^\n+/, '').replace(/\n+$/, '') + '\n');
1167
- return lines.join('\n');
1168
- }
1169
-
1170
- function readKandownConfig(kandownDir) {
1171
- const configPath = join(kandownDir, 'kandown.json');
1172
- if (!existsSync(configPath)) return null;
1173
- try {
1174
- return JSON.parse(readFileSync(configPath, 'utf8'));
1175
- } catch {
1176
- return null;
1177
- }
1178
- }
1179
-
1180
- function findTaskFile(kandownDir, id) {
1181
- if (!/^[a-zA-Z0-9_-]+$/.test(id)) return null;
1182
- const tasksDir = getTasksDir(kandownDir);
1183
- const inTasks = join(tasksDir, `${id}.md`);
1184
- if (existsSync(inTasks)) return inTasks;
1185
- const inArchive = join(tasksDir, 'archive', `${id}.md`);
1186
- if (existsSync(inArchive)) return inArchive;
1187
- return null;
1188
- }
1189
-
1190
- function listAllTaskIds(kandownDir) {
1191
- const tasksDir = getTasksDir(kandownDir);
1192
- const ids = new Set();
1193
- if (existsSync(tasksDir)) {
1194
- for (const f of readdirSync(tasksDir).filter(f => f.endsWith('.md'))) {
1195
- ids.add(f.replace(/\.md$/, ''));
1196
- }
1197
- const archive = join(tasksDir, 'archive');
1198
- if (existsSync(archive)) {
1199
- for (const f of readdirSync(archive).filter(f => f.endsWith('.md'))) {
1200
- ids.add(f.replace(/\.md$/, ''));
1201
- }
1202
- }
1203
- }
1204
- return [...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
1205
- }
1206
-
1207
- function nextTaskId(kandownDir) {
1208
- const ids = listAllTaskIds(kandownDir);
1209
- let maxN = 0;
1210
- for (const id of ids) {
1211
- const m = id.match(/^t(\d+)$/i);
1212
- if (m) {
1213
- const n = parseInt(m[1], 10);
1214
- if (Number.isFinite(n) && n > maxN) maxN = n;
929
+ async function cmdDoctor(rawArgs) {
930
+ const { kandownDir } = ensureKandownDir(rawArgs);
931
+ const currentVersion = getCurrentVersion();
932
+ log(`${c.bold}kandown doctor${c.reset} ${c.dim}\u2014 environment & board diagnostic${c.reset}
933
+ `);
934
+ log(` CLI Version: ${currentVersion}`);
935
+ const configPath = join5(kandownDir, "kandown.json");
936
+ if (existsSync5(configPath)) {
937
+ try {
938
+ JSON.parse(readFileSync5(configPath, "utf8"));
939
+ success("kandown.json valid");
940
+ } catch (e) {
941
+ err(`kandown.json invalid: ${e.message}`);
1215
942
  }
943
+ } else {
944
+ err("Missing kandown.json");
1216
945
  }
1217
- return 't' + (maxN + 1);
1218
- }
1219
-
1220
- function padCell(str, len) {
1221
- const s = String(str);
1222
- // 📖 Only truncate when the string is strictly LONGER than the column —
1223
- // an exact fit must render as-is (`>=` chopped the last char of e.g. the
1224
- // longest task id, displaying "t99" as "t9").
1225
- if (s.length > len) return s.slice(0, Math.max(0, len - 1)) + '…';
1226
- return s + ' '.repeat(len - s.length);
1227
- }
1228
-
1229
- function taskParseArgs(argv) {
1230
- // 📖 Minimal flag parser for the task commands. Stops at the first
1231
- // positional arg so `kandown create "Some title with -dash"` works.
1232
- const out = { positional: [], flags: {} };
1233
- for (let i = 0; i < argv.length; i++) {
1234
- const a = argv[i];
1235
- if (a === '--json') out.flags.json = true;
1236
- else if (a === '--archived') out.flags.archived = true;
1237
- else if (a === '-s' || a === '--status') out.flags.status = argv[++i];
1238
- else if (a === '-a' || a === '--assignee') out.flags.assignee = argv[++i];
1239
- else if (a === '-p' || a === '--priority') out.flags.priority = argv[++i];
1240
- else if (a === '-t' || a === '--tag') out.flags.tags = (out.flags.tags || []).concat([argv[++i]]);
1241
- else if (a === '-d' || a === '--depends-on') out.flags.dependsOn = (out.flags.dependsOn || []).concat([argv[++i]]);
1242
- else if (a === '-m' || a === '--message') out.flags.message = argv[++i];
1243
- else if (a === '--to') out.flags.to = argv[++i];
1244
- else if (a === '--id') out.flags.id = argv[++i];
1245
- else out.positional.push(a);
946
+ const daemonStatus = await getDaemonStatus(kandownDir);
947
+ if (daemonStatus.running && daemonStatus.metadata) {
948
+ success(`Daemon running on port ${daemonStatus.metadata.port} (PID ${daemonStatus.metadata.pid})`);
949
+ } else {
950
+ info("Daemon not running");
1246
951
  }
1247
- return out;
952
+ const taskIds = listTaskIds(kandownDir);
953
+ success(`Tasks: ${taskIds.length} active task files`);
954
+ log(`
955
+ ${c.green}\u2713 Everything looks good!${c.reset}
956
+ `);
1248
957
  }
1249
-
1250
- function resolveStatusArg(config, status) {
1251
- // 📖 Status can be a configured column name OR the reserved `archived`
1252
- // sentinel. Match is case-insensitive to stay forgiving in scripts.
1253
- if (!status) return null;
1254
- const lower = status.toLowerCase();
1255
- if (lower === 'archived') return 'archived';
1256
- const columns = (config && config.board && config.board.columns) || [];
1257
- for (const c of columns) {
1258
- if (c.toLowerCase() === lower) return c;
958
+ async function cmdWork(rawArgs) {
959
+ const { kandownDir } = ensureKandownDir(rawArgs);
960
+ const doc = readAgentDoc(kandownDir);
961
+ const board = readBoard(kandownDir);
962
+ log(doc);
963
+ log("\n---\n");
964
+ log(`## Current Board Digest
965
+ `);
966
+ const allTasksCount = board.columns.reduce((sum, col) => sum + col.tasks.length, 0);
967
+ log(`Tasks total: ${allTasksCount}`);
968
+ for (const col of board.columns) {
969
+ log(`- **${col.name}** (${col.tasks.length}): ${col.tasks.map((t) => `${t.id} ${t.title}`).join(", ") || "empty"}`);
1259
970
  }
1260
- return null;
1261
971
  }
1262
-
1263
972
  function cmdList(rawArgs) {
1264
973
  const { kandownDir } = ensureKandownDir(rawArgs);
1265
- const args = taskParseArgs(rawArgs);
1266
- const config = readKandownConfig(kandownDir);
1267
- const statusFilter = args.flags.status ? resolveStatusArg(config, args.flags.status) : null;
1268
- if (args.flags.status && !statusFilter) {
1269
- err(`Unknown status: ${args.flags.status}`);
1270
- process.exit(1);
1271
- }
1272
- const ids = listAllTaskIds(kandownDir);
1273
- const rows = [];
1274
- for (const id of ids) {
1275
- const path = findTaskFile(kandownDir, id);
1276
- if (!path) continue;
1277
- const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
1278
- const fm = parsed.frontmatter;
1279
- const isArchived = fm.archived === true || fm.archived === 'true' || path.includes('/archive/');
1280
- if (args.flags.archived ? !isArchived : isArchived) continue;
1281
- if (statusFilter && statusFilter !== 'archived' && (fm.status || '').toLowerCase() !== statusFilter.toLowerCase()) continue;
1282
- if (args.flags.assignee && fm.assignee !== args.flags.assignee) continue;
1283
- if (args.flags.priority && (fm.priority || '').toLowerCase() !== args.flags.priority.toLowerCase()) continue;
1284
- if (args.flags.tags && args.flags.tags.length > 0) {
1285
- const taskTags = Array.isArray(fm.tags) ? fm.tags : [];
1286
- const wanted = new Set(args.flags.tags);
1287
- let ok = true;
1288
- for (const t of wanted) { if (!taskTags.includes(t)) { ok = false; break; } }
1289
- if (!ok) continue;
974
+ const board = readBoard(kandownDir);
975
+ const { flags } = parseArgs(rawArgs);
976
+ const statusFilter = typeof flags.status === "string" ? flags.status.toLowerCase() : null;
977
+ const priorityFilter = typeof flags.priority === "string" ? flags.priority.toUpperCase() : null;
978
+ for (const col of board.columns) {
979
+ if (statusFilter && col.name.toLowerCase() !== statusFilter) continue;
980
+ log(`
981
+ ${c.bold}${col.name}${c.reset} (${col.tasks.length})`);
982
+ for (const t of col.tasks) {
983
+ if (priorityFilter && t.priority !== priorityFilter) continue;
984
+ log(` ${c.cyan}${t.id}${c.reset} [${t.priority || "P2"}] ${t.title}`);
1290
985
  }
1291
- rows.push({ id, fm, body: parsed.body });
1292
- }
1293
-
1294
- if (args.flags.json) {
1295
- process.stdout.write(JSON.stringify(rows.map(r => ({
1296
- id: r.id, ...r.fm, archived: r.fm.archived === true || r.fm.archived === 'true',
1297
- })), null, 2) + '\n');
1298
- return;
1299
- }
1300
-
1301
- if (rows.length === 0) {
1302
- // 📖 Placeholder goes to stderr — an empty result on stdout stays empty
1303
- // so `[ -z "$(kandown list ...)" ]` style checks behave.
1304
- log(c.dim + '(no tasks)' + c.reset);
1305
- return;
1306
- }
1307
-
1308
- const idW = Math.max(2, ...rows.map(r => r.id.length));
1309
- out(`${c.dim}${padCell('ID', idW)} ${padCell('STATUS', 14)} ${padCell('PRI', 4)} ${padCell('ASSIGNEE', 12)} TITLE${c.reset}`);
1310
- for (const r of rows) {
1311
- const status = (r.fm.status || 'Backlog') + (r.fm.archived === true || r.fm.archived === 'true' ? ' (archived)' : '');
1312
- const pri = r.fm.priority || '';
1313
- const assignee = r.fm.assignee || '';
1314
- const title = (r.fm.title || '(untitled)').replace(/\n/g, ' ');
1315
- out(`${padCell(r.id, idW)} ${padCell(status, 14)} ${padCell(pri, 4)} ${padCell(assignee, 12)} ${title}`);
1316
986
  }
987
+ log("");
1317
988
  }
1318
-
1319
989
  function cmdShow(rawArgs) {
1320
990
  const { kandownDir } = ensureKandownDir(rawArgs);
1321
- const args = taskParseArgs(rawArgs);
1322
- const id = args.positional[0];
991
+ const id = rawArgs.find((a) => !a.startsWith("-"));
1323
992
  if (!id) {
1324
- err('Usage: kandown show <id>');
1325
- process.exit(1);
1326
- }
1327
- const path = findTaskFile(kandownDir, id);
1328
- if (!path) {
1329
- err(`Task not found: ${id}`);
1330
- process.exit(1);
1331
- }
1332
- process.stdout.write(readFileSync(path, 'utf8'));
1333
- }
1334
-
1335
- /**
1336
- * 📖 Resolves a task id to whether it is in a "blocking" state (i.e. still
1337
- * pending). Used by the move gate. Mirrors the web store's logic: a dep is
1338
- * resolved when it lives in the terminal column OR is archived. Unknown
1339
- * ids and self-references never block.
1340
- */
1341
- function depIsResolved(kandownDir, id, terminalLower) {
1342
- if (!/^[a-zA-Z0-9_-]+$/.test(id)) return true; // unknown / invalid → don't block
1343
- const path = findTaskFile(kandownDir, id);
1344
- if (!path) return true; // unknown id → don't block
1345
- const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
1346
- const isArch = parsed.frontmatter.archived === true || parsed.frontmatter.archived === 'true';
1347
- return isArch || (parsed.frontmatter.status || '').toLowerCase() === terminalLower;
1348
- }
1349
-
1350
- function parseInlineQuickAdd(rawTitle) {
1351
- let text = (rawTitle || '').trim();
1352
- let priority;
1353
- const tags = [];
1354
- let assignee;
1355
- let due;
1356
- const dependsOn = [];
1357
-
1358
- text = text.replace(/(?:^|\s)p([1-4])(?:\s|$)/i, (_, level) => {
1359
- priority = `P${level}`;
1360
- return ' ';
1361
- });
1362
-
1363
- text = text.replace(/(?:^|\s)#([a-zA-Z0-9_-]+)/g, (_, tag) => {
1364
- tags.push(tag.toLowerCase());
1365
- return ' ';
1366
- });
1367
-
1368
- text = text.replace(/(?:^|\s)@([a-zA-Z0-9_-]+)/g, (_, user) => {
1369
- assignee = user;
1370
- return ' ';
1371
- });
1372
-
1373
- text = text.replace(/(?:^|\s)due:([^\s]+)/i, (_, dateStr) => {
1374
- due = dateStr;
1375
- return ' ';
1376
- });
1377
-
1378
- text = text.replace(/(?:^|\s)\+([a-zA-Z0-9_-]+)/g, (_, depId) => {
1379
- dependsOn.push(depId);
1380
- return ' ';
1381
- });
1382
-
1383
- const title = text.replace(/\s+/g, ' ').trim();
1384
- return { title: title || rawTitle, priority, tags, assignee, due, dependsOn };
1385
- }
1386
-
1387
- function cmdCreate(rawArgs) {
1388
- const { kandownDir } = ensureKandownDir(rawArgs);
1389
- const args = taskParseArgs(rawArgs);
1390
- const rawTitle = args.positional[0];
1391
- if (!rawTitle) {
1392
- err('Usage: kandown create "<title>" [-p priority] [-a assignee] [-t tag ...] [--to status]');
1393
- process.exit(1);
1394
- }
1395
- const config = readKandownConfig(kandownDir);
1396
- const targetStatus = resolveStatusArg(config, args.flags.to) || (config && config.board && config.board.columns ? config.board.columns[0] : 'Backlog');
1397
- const id = args.flags.id || nextTaskId(kandownDir);
1398
- const tasksDir = getTasksDir(kandownDir);
1399
- if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
1400
- const targetPath = join(tasksDir, `${id}.md`);
1401
- if (existsSync(targetPath)) {
1402
- err(`Task already exists: ${id}`);
993
+ err("Usage: kandown show <task-id>");
1403
994
  process.exit(1);
1404
995
  }
1405
- const parsedInline = parseInlineQuickAdd(rawTitle);
1406
- const title = parsedInline.title;
1407
- const fm = {
1408
- id,
1409
- title,
1410
- status: targetStatus,
1411
- created: new Date().toISOString().slice(0, 10),
1412
- };
1413
- if (args.flags.priority) fm.priority = args.flags.priority;
1414
- if (args.flags.assignee) fm.assignee = args.flags.assignee;
1415
- if (args.flags.tags && args.flags.tags.length > 0) fm.tags = args.flags.tags;
1416
- if (args.flags.dependsOn && args.flags.dependsOn.length > 0) fm.depends_on = args.flags.dependsOn;
1417
- const content = serializeFrontmatter(fm, '');
1418
- atomicWriteFileSync(targetPath, content);
1419
- process.stderr.write(`${c.green}✓${c.reset} Created ${c.bold}${id}${c.reset} → ${targetStatus}\n`);
1420
- if (args.flags.json) {
1421
- process.stdout.write(JSON.stringify({ id, ...fm }, null, 2) + '\n');
1422
- } else {
1423
- // 📖 Print the id on stdout (last line) so scripts can do
1424
- // `ID=$(kandown create "...")` without parsing the colored status line.
1425
- process.stdout.write(id + '\n');
996
+ try {
997
+ const task = readTask(kandownDir, id);
998
+ log(`${c.bold}${task.frontmatter.id}: ${task.frontmatter.title}${c.reset}`);
999
+ log(`Status: ${task.frontmatter.status} | Priority: ${task.frontmatter.priority || "P2"} | Assignee: ${task.frontmatter.assignee || "none"}
1000
+ `);
1001
+ log(task.body);
1002
+ } catch (e) {
1003
+ err(`Could not read task ${id}: ${e.message}`);
1426
1004
  }
1427
1005
  }
1428
-
1429
1006
  function cmdMove(rawArgs) {
1430
1007
  const { kandownDir } = ensureKandownDir(rawArgs);
1431
- const args = taskParseArgs(rawArgs);
1432
- const [id, rawStatus] = args.positional;
1433
- const targetStatus = rawStatus || args.flags.to;
1434
- if (!id || !targetStatus) {
1435
- err('Usage: kandown move <id> <status>');
1436
- process.exit(1);
1437
- }
1438
- const config = readKandownConfig(kandownDir);
1439
- const resolved = resolveStatusArg(config, targetStatus);
1440
- if (!resolved) {
1441
- err(`Unknown status: ${targetStatus}`);
1008
+ const [id, newStatus] = rawArgs.filter((a) => !a.startsWith("-"));
1009
+ if (!id || !newStatus) {
1010
+ err("Usage: kandown move <task-id> <status>");
1442
1011
  process.exit(1);
1443
1012
  }
1444
- const path = findTaskFile(kandownDir, id);
1445
- if (!path) {
1446
- err(`Task not found: ${id}`);
1447
- process.exit(1);
1448
- }
1449
- // 📖 Terminal-status gate: if the target is the configured terminal column
1450
- // (default: last entry of `board.columns`, "Done"), refuse the move while
1451
- // any `depends_on` is not yet resolved. Mirrors the web store + TUI gate.
1452
- if (config && Array.isArray(config.board.columns) && config.board.columns.length > 0) {
1453
- const terminalLower = (config.board.columns[config.board.columns.length - 1]).toLowerCase();
1454
- if (resolved.toLowerCase() === terminalLower) {
1455
- const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
1456
- const deps = Array.isArray(parsed.frontmatter.depends_on) ? parsed.frontmatter.depends_on : [];
1457
- const blocked = [];
1458
- for (const dep of deps) {
1459
- if (typeof dep !== 'string' || !dep.trim() || dep === id) continue;
1460
- if (!depIsResolved(kandownDir, dep, terminalLower)) blocked.push(dep);
1013
+ try {
1014
+ moveTaskToColumn(kandownDir, id, newStatus);
1015
+ success(`Moved ${id} \u2192 "${newStatus}"`);
1016
+ } catch (e) {
1017
+ err(`Move failed: ${e.message}`);
1018
+ }
1019
+ }
1020
+ async function main() {
1021
+ const args = process.argv.slice(2);
1022
+ const cmd = args[0];
1023
+ const rest = args.slice(1);
1024
+ const skipUpdate = args.includes("--no-update-check") || process.env.KANDOWN_NO_UPDATE === "1";
1025
+ const SCRIPTED = /* @__PURE__ */ new Set(["list", "ls", "show", "create", "new", "move", "assign", "commit", "work", "doctor", "projects", "export", "import"]);
1026
+ if (!skipUpdate && !SCRIPTED.has(cmd)) {
1027
+ await checkForUpdate(process.argv);
1028
+ }
1029
+ switch (cmd) {
1030
+ case "update":
1031
+ case "upgrade":
1032
+ await cmdUpdate(rest);
1033
+ break;
1034
+ case "doctor":
1035
+ await cmdDoctor(rest);
1036
+ break;
1037
+ case "work":
1038
+ await cmdWork(rest);
1039
+ break;
1040
+ case "list":
1041
+ case "ls":
1042
+ cmdList(rest);
1043
+ break;
1044
+ case "show":
1045
+ cmdShow(rest);
1046
+ break;
1047
+ case "move":
1048
+ cmdMove(rest);
1049
+ break;
1050
+ case "help":
1051
+ case "--help":
1052
+ case "-h":
1053
+ help();
1054
+ break;
1055
+ case void 0: {
1056
+ const { kandownDir } = ensureKandownDir(rest);
1057
+ const status = await getDaemonStatus(kandownDir);
1058
+ if (!status.running) {
1059
+ await startProjectDaemon(kandownDir);
1461
1060
  }
1462
- if (blocked.length > 0) {
1463
- const list = blocked.length === 1 ? blocked[0] : `${blocked.slice(0, -1).join(', ')} and ${blocked[blocked.length - 1]}`;
1464
- err(`Cannot move ${id} to ${resolved}: blocked by ${list}`);
1465
- process.exit(1);
1061
+ const tuiPath = resolve2(import.meta.url ? new URL("../..", import.meta.url).pathname : process.cwd(), "bin", "tui.js");
1062
+ if (existsSync5(tuiPath)) {
1063
+ const child = spawn3("node", [tuiPath, ...rest], { stdio: "inherit" });
1064
+ child.on("close", (code) => process.exit(code || 0));
1065
+ } else {
1066
+ info("Kandown daemon running. Open kandown.html in browser.");
1466
1067
  }
1068
+ break;
1467
1069
  }
1468
- }
1469
- const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
1470
- parsed.frontmatter.status = resolved;
1471
- if (resolved === 'archived') parsed.frontmatter.archived = true;
1472
- else delete parsed.frontmatter.archived;
1473
- if (resolved === 'archived') {
1474
- const archiveDir = join(getTasksDir(kandownDir), 'archive');
1475
- if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
1476
- atomicWriteFileSync(join(archiveDir, `${id}.md`), serializeFrontmatter(parsed.frontmatter, parsed.body));
1477
- try { unlinkSync(path); } catch { /* already absent */ }
1478
- } else {
1479
- const normalTasksDir = getTasksDir(kandownDir);
1480
- const normalPath = join(normalTasksDir, `${id}.md`);
1481
- atomicWriteFileSync(normalPath, serializeFrontmatter(parsed.frontmatter, parsed.body));
1482
- if (path !== normalPath) {
1483
- try { unlinkSync(path); } catch { /* already absent */ }
1484
- }
1485
- }
1486
- log(`${c.green}✓${c.reset} ${c.bold}${id}${c.reset} → ${resolved}`);
1487
- }
1488
-
1489
- function cmdAssign(rawArgs) {
1490
- const { kandownDir } = ensureKandownDir(rawArgs);
1491
- const args = taskParseArgs(rawArgs);
1492
- const [id, name] = args.positional;
1493
- if (!id) {
1494
- err('Usage: kandown assign <id> [name] (omit name to unassign)');
1495
- process.exit(1);
1496
- }
1497
- const path = findTaskFile(kandownDir, id);
1498
- if (!path) {
1499
- err(`Task not found: ${id}`);
1500
- process.exit(1);
1501
- }
1502
- const parsed = parseFrontmatter(readFileSync(path, 'utf8'));
1503
- if (name) parsed.frontmatter.assignee = name;
1504
- else delete parsed.frontmatter.assignee;
1505
- atomicWriteFileSync(path, serializeFrontmatter(parsed.frontmatter, parsed.body));
1506
- log(`${c.green}✓${c.reset} ${c.bold}${id}${c.reset} → ${name ? c.cyan + name : c.dim + '(unassigned)'}${c.reset}`);
1507
- }
1508
-
1509
- function cmdCommit(rawArgs) {
1510
- const { kandownDir } = ensureKandownDir(rawArgs);
1511
- const args = taskParseArgs(rawArgs);
1512
- const projectRoot = getProjectRoot(kandownDir);
1513
- // 📖 Refuse to commit if not inside a git repo — silently failing here would
1514
- // let the user believe they versioned their tasks when they didn't.
1515
- try {
1516
- execSync('git rev-parse --is-inside-work-tree', { cwd: projectRoot, stdio: 'pipe' });
1517
- } catch {
1518
- err('Not a git repository — kandown commit only stages and commits in a git repo.');
1519
- err(' Run `git init` first or commit manually.');
1520
- process.exit(1);
1521
- }
1522
- const tasksRel = 'tasks';
1523
- const configRel = '.kandown/kandown.json';
1524
- const staged = [];
1525
- for (const rel of [tasksRel, configRel]) {
1526
- const abs = join(projectRoot, rel);
1527
- if (!existsSync(abs)) continue;
1528
- try {
1529
- execSync(`git add -A -- "${rel}"`, { cwd: projectRoot, stdio: 'pipe' });
1530
- staged.push(rel);
1531
- } catch (e) {
1532
- err(`git add failed for ${rel}: ${e.message}`);
1070
+ default:
1071
+ if (cmd.startsWith("-")) {
1072
+ help();
1073
+ break;
1074
+ }
1075
+ err(`Unknown command: ${cmd}`);
1076
+ help();
1533
1077
  process.exit(1);
1534
- }
1535
- }
1536
- if (staged.length === 0) {
1537
- log(c.dim + 'Nothing to commit.' + c.reset);
1538
- return;
1539
- }
1540
- const message = args.flags.message || `chore(kandown): update tasks`;
1541
- try {
1542
- execSync(`git commit -m ${JSON.stringify(message)}`, { cwd: projectRoot, stdio: 'inherit' });
1543
- success(`Committed ${staged.length} path${staged.length === 1 ? '' : 's'}: ${staged.join(', ')}`);
1544
- } catch (e) {
1545
- // 📖 git commit exits non-zero when there's nothing to commit (after the
1546
- // add). Treat that as a no-op so the user doesn't think they broke
1547
- // anything.
1548
- if (e && e.status === 1) {
1549
- log(c.dim + 'Nothing to commit (working tree clean).' + c.reset);
1550
- return;
1551
- }
1552
- err(`git commit failed: ${e.message}`);
1553
- process.exit(1);
1554
1078
  }
1555
1079
  }
1556
-
1557
- /**
1558
- * 📖 Prints the one-shot task-command cheatsheet. Reachable as `kandown tasks`
1559
- * (or `kandown tasks help`) — the commands themselves (list/show/create/move/
1560
- * assign/commit) are top-level, not nested under a "shell" prefix, since
1561
- * they're the most basic operations of the product and scripts/agents should
1562
- * never need to remember a wrapper word to reach them.
1563
- */
1564
- function printTaskCommandsHelp() {
1565
- out(`
1566
- ${c.bold}kandown${c.reset} ${c.dim}· one-shot task commands (scriptable, agent-friendly)${c.reset}
1567
-
1568
- ${c.bold}Commands:${c.reset}
1569
- ${c.cyan}list${c.reset} [-s status] [-a assignee] [-t tag] [-p priority] [--archived] [--json]
1570
- ${c.cyan}show${c.reset} <id>
1571
- ${c.cyan}create${c.reset} "title" [-p priority] [-a assignee] [-t tag ...] [--to status] [--id custom-id] [--json]
1572
- ${c.cyan}move${c.reset} <id> <status> (status is a column name or "archived")
1573
- ${c.cyan}assign${c.reset} <id> [name] (omit name to unassign)
1574
- ${c.cyan}commit${c.reset} [-m "message"] (git add tasks/ + .kandown/kandown.json + git commit)
1575
-
1576
- ${c.bold}Examples:${c.reset}
1577
- ${c.dim}$${c.reset} kandown list --json | jq '.[] | select(.priority=="P1")'
1578
- ${c.dim}$${c.reset} kandown create "Refactor auth middleware" -p P1 -t backend
1579
- ${c.dim}$${c.reset} kandown move t42 Done
1580
- ${c.dim}$${c.reset} kandown assign t42 alice
1581
- ${c.dim}$${c.reset} kandown commit -m "tasks: add auth refactor"
1582
- `);
1583
- }
1584
-
1585
- /* ═════════════ kandown work — agent entrypoint ═════════════ */
1586
- /**
1587
- * 📖 `kandown work` replaces the "paste the rules into AGENTS.md/CLAUDE.md"
1588
- * pattern: instead of a stale copy embedded in the project's agent file at
1589
- * init time, the CLI serves the rules fresh on every invocation — always in
1590
- * sync with the installed version — plus a live digest of the board so the
1591
- * agent gets its marching orders and its context in one call.
1592
- *
1593
- * Layering (base → global → project), each optional except the base:
1594
- * 1. Base rules — templates/AGENT_KANDOWN.md, shipped with the package.
1595
- * 2. Global — ~/.kandown/instructions.md, applies to every project
1596
- * on this machine (team conventions, personal style).
1597
- * 3. Project — .kandown/instructions.md, this project only.
1598
- * Later layers are appended, not merged — the agent reads all of them.
1599
- */
1600
-
1601
- const PRIORITY_RANK = { P1: 0, P2: 1, P3: 2, P4: 3 };
1602
- function priorityRank(p) {
1603
- return Object.prototype.hasOwnProperty.call(PRIORITY_RANK, p) ? PRIORITY_RANK[p] : 4;
1604
- }
1605
-
1606
- const WORK_OUTPUT_SECTION_IDS = ['baseRules', 'projectInstructions', 'boardDigest'];
1607
- const OPTIMIZED_AGENT_RULES = `# Kandown agent rules
1608
-
1609
- ## CLI Commands
1610
- - \`kandown list\` (list tasks) · \`kandown show <id>\` (view task)
1611
- - \`kandown create "<title>"\` (create task) · \`kandown move <id> <status>\` (move task column)
1612
- - \`kandown assign <id> <user>\` · \`kandown commit\` (commit board to git)
1613
-
1614
- ## Rules
1615
- - Task state lives in project-root \`tasks/*.md\`; do not maintain a separate board index.
1616
- - Before work, read the relevant task file and keep it updated while you progress.
1617
- - Move tasks by editing frontmatter \`status:\`; complete work by setting \`status: Done\` and adding a markdown \`report:\` summary.
1618
- - Update subtasks in-place: \`- [ ]\` → \`- [x]\`, with a short \`report:\` line for meaningful progress.
1619
- - Board columns live in \`.kandown/kandown.json\` under \`board.columns\`; project instructions live in \`.kandown/instructions.md\`.
1620
- - Never put Kandown task data inside \`.kandown/\`; tasks belong in \`./tasks/\`.`;
1621
-
1622
- const CAVEMAN_AGENT_RULES = `# Kandown agent rules
1623
-
1624
- CLI: kandown list | kandown show <id> | kandown create "<title>" | kandown move <id> <status> | kandown assign <id> <user> | kandown commit
1625
- RULES: TASKS IN ./tasks/*.md. READ TASK BEFORE WORK. UPDATE SUBTASKS - [ ] -> - [x] + REPORT. MOVE: EDIT status:. DONE: status: Done + report:.`;
1626
-
1627
- const CONCISE_AGENT_RULES = OPTIMIZED_AGENT_RULES;
1628
-
1629
- const DEFAULT_WORK_OUTPUT = {
1630
- mode: 'blocks',
1631
- includeBaseRules: true,
1632
- baseRulesMode: 'verbose',
1633
- includeProjectInstructions: true,
1634
- includeBoardDigest: true,
1635
- sectionOrder: WORK_OUTPUT_SECTION_IDS,
1636
- rawTemplate: '{{baseRules}}\n\n---\n\n{{projectInstructions}}\n\n---\n\n{{boardDigest}}',
1637
- boardDigest: {
1638
- showColumnCounts: true,
1639
- showTasks: true,
1640
- showPriority: true,
1641
- showAssignee: true,
1642
- showBlockedBy: true,
1643
- showNextActionable: true,
1644
- },
1645
- };
1646
-
1647
- function safePlainObject(value) {
1648
- return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
1649
- }
1650
-
1651
- function normalizeWorkOutputConfig(config) {
1652
- const raw = safePlainObject(safePlainObject(config?.agent).workOutput);
1653
- const digest = safePlainObject(raw.boardDigest);
1654
- const sectionOrder = Array.isArray(raw.sectionOrder)
1655
- ? raw.sectionOrder.filter(id => WORK_OUTPUT_SECTION_IDS.includes(id))
1656
- : DEFAULT_WORK_OUTPUT.sectionOrder;
1657
- const order = sectionOrder.length > 0 ? sectionOrder : DEFAULT_WORK_OUTPUT.sectionOrder;
1658
- const validBaseModes = ['verbose', 'optimized', 'caveman', 'full', 'concise'];
1659
- const baseMode = validBaseModes.includes(raw.baseRulesMode) ? raw.baseRulesMode : DEFAULT_WORK_OUTPUT.baseRulesMode;
1660
- return {
1661
- ...DEFAULT_WORK_OUTPUT,
1662
- ...raw,
1663
- mode: raw.mode === 'raw' ? 'raw' : 'blocks',
1664
- baseRulesMode: baseMode,
1665
- sectionOrder: order,
1666
- rawTemplate: typeof raw.rawTemplate === 'string' && raw.rawTemplate.trim()
1667
- ? raw.rawTemplate
1668
- : DEFAULT_WORK_OUTPUT.rawTemplate,
1669
- boardDigest: { ...DEFAULT_WORK_OUTPUT.boardDigest, ...digest },
1670
- };
1671
- }
1672
-
1673
- function readBaseAgentRules(mode = 'verbose') {
1674
- if (mode === 'caveman') return CAVEMAN_AGENT_RULES;
1675
- if (mode === 'optimized' || mode === 'concise') return OPTIMIZED_AGENT_RULES;
1676
- try {
1677
- return readFileSync(join(PKG_ROOT, 'templates', 'AGENT_KANDOWN.md'), 'utf8').trim();
1678
- } catch (e) {
1679
- warn(`Could not read base rules (${e.message})`);
1680
- return '';
1681
- }
1682
- }
1683
-
1684
- function syncKandownAgentDoc(kandownDir) {
1685
- const source = join(PKG_ROOT, 'templates', 'AGENT_KANDOWN.md');
1686
- const target = join(kandownDir, 'AGENT_KANDOWN.md');
1687
- if (!existsSync(source)) return false;
1688
- const expected = readFileSync(source, 'utf8');
1689
- const existing = existsSync(target) ? readFileSync(target, 'utf8') : null;
1690
- const isMissing = existing === null;
1691
- const isMalformed = existing !== null && !existing.includes('# Kandown') && !existing.includes('## The System');
1692
- const isStaleGeneratedDoc = existing !== null && existing.includes('# Kandown') && existing !== expected;
1693
-
1694
- if (isMissing || isMalformed || isStaleGeneratedDoc) {
1695
- atomicWriteFileSync(target, expected.endsWith('\n') ? expected : `${expected}\n`);
1696
- return true;
1697
- }
1698
- return false;
1699
- }
1700
-
1701
- function applyWorkRawTemplate(template, blocks) {
1702
- return template
1703
- .replaceAll('{{baseRules}}', blocks.baseRules)
1704
- .replaceAll('{{projectInstructions}}', blocks.projectInstructions)
1705
- .replaceAll('{{boardDigest}}', blocks.boardDigest)
1706
- .replace(/\n{3,}/g, '\n\n')
1707
- .trim();
1708
- }
1709
-
1710
- /**
1711
- * 📖 Builds the "Current board" section: column counts, tasks per column
1712
- * (with blocked-by annotations), and a "Next actionable task" pick — the
1713
- * task in the most-advanced non-terminal column (closest to done, so
1714
- * in-flight work finishes before new work starts) with no unresolved
1715
- * `depends_on`, tie-broken by priority. Mirrors the same gate logic used by
1716
- * `move`/the TUI/the web store.
1717
- */
1718
- function buildBoardDigest(kandownDir, options = DEFAULT_WORK_OUTPUT.boardDigest) {
1719
- const digestOptions = { ...DEFAULT_WORK_OUTPUT.boardDigest, ...safePlainObject(options) };
1720
- const config = readKandownConfig(kandownDir);
1721
- const columns = (config && config.board && Array.isArray(config.board.columns) && config.board.columns.length > 0)
1722
- ? config.board.columns
1723
- : ['Backlog', 'Todo', 'In Progress', 'Review', 'Done'];
1724
- const terminalCol = columns[columns.length - 1];
1725
- const terminalLower = terminalCol.toLowerCase();
1726
-
1727
- const rows = [];
1728
- for (const id of listAllTaskIds(kandownDir)) {
1729
- const path = findTaskFile(kandownDir, id);
1730
- if (!path) continue;
1731
- let content;
1732
- try { content = readFileSync(path, 'utf8'); } catch { continue; }
1733
- const parsed = parseFrontmatter(content);
1734
- const fm = parsed.frontmatter;
1735
- const isArchived = fm.archived === true || fm.archived === 'true' || path.includes('/archive/');
1736
- if (isArchived) continue;
1737
- rows.push({ id, fm });
1738
- }
1739
-
1740
- if (rows.length === 0) {
1741
- return `## Current board\n\n(no tasks yet)`;
1742
- }
1743
-
1744
- const resolved = new Map();
1745
- for (const r of rows) {
1746
- const isArch = r.fm.archived === true || r.fm.archived === 'true';
1747
- resolved.set(r.id, isArch || (r.fm.status || '').toLowerCase() === terminalLower);
1748
- }
1749
-
1750
- const byColumn = new Map(columns.map(col => [col, []]));
1751
- for (const r of rows) {
1752
- const status = r.fm.status || columns[0];
1753
- const col = columns.find(c => c.toLowerCase() === String(status).toLowerCase()) || columns[0];
1754
- const deps = Array.isArray(r.fm.depends_on) ? r.fm.depends_on : [];
1755
- const blocked = deps.filter(d => typeof d === 'string' && d.trim() && d !== r.id && !resolved.get(d));
1756
- // 📖 `col` always resolves to one of `columns` (falls back to columns[0]),
1757
- // which the Map was seeded with above — the entry always exists.
1758
- byColumn.get(col).push({ ...r, blocked });
1759
- }
1760
-
1761
- const lines = ['## Current board', ''];
1762
- if (digestOptions.showColumnCounts) {
1763
- lines.push(`**Columns:** ${columns.map(col => `${col} (${(byColumn.get(col) || []).length})`).join(' · ')}`);
1764
- }
1765
-
1766
- if (digestOptions.showTasks) {
1767
- for (const col of columns) {
1768
- const tasks = (byColumn.get(col) || []).sort((a, b) => priorityRank(a.fm.priority) - priorityRank(b.fm.priority));
1769
- if (tasks.length === 0) continue;
1770
- lines.push('', `### ${col}`);
1771
- for (const t of tasks) {
1772
- const pri = digestOptions.showPriority && t.fm.priority ? `[${t.fm.priority}] ` : '';
1773
- const assignee = digestOptions.showAssignee && t.fm.assignee ? ` (@${t.fm.assignee})` : '';
1774
- const blockedStr = digestOptions.showBlockedBy && t.blocked.length > 0 ? ` ⛔ blocked by ${t.blocked.join(', ')}` : '';
1775
- lines.push(`- ${t.id} ${pri}${t.fm.title || '(untitled)'}${assignee}${blockedStr}`);
1776
- }
1777
- }
1778
- }
1779
-
1780
- const actionable = rows.filter(r => {
1781
- const status = r.fm.status || columns[0];
1782
- if (status.toLowerCase() === terminalLower) return false;
1783
- const deps = Array.isArray(r.fm.depends_on) ? r.fm.depends_on : [];
1784
- return !deps.some(d => typeof d === 'string' && d.trim() && d !== r.id && !resolved.get(d));
1785
- });
1786
- const next = actionable.sort((a, b) => {
1787
- const idxA = columns.findIndex(col => col.toLowerCase() === (a.fm.status || columns[0]).toLowerCase());
1788
- const idxB = columns.findIndex(col => col.toLowerCase() === (b.fm.status || columns[0]).toLowerCase());
1789
- if (idxA !== idxB) return idxB - idxA; // most-advanced non-terminal column first
1790
- return priorityRank(a.fm.priority) - priorityRank(b.fm.priority);
1791
- })[0];
1792
-
1793
- if (digestOptions.showNextActionable) {
1794
- lines.push('', '### Next actionable task');
1795
- lines.push(next
1796
- ? `→ **${next.id}** — ${next.fm.title || '(untitled)'} (${next.fm.priority || 'no priority'}, ${next.fm.status || columns[0]})`
1797
- : 'None — every task is done, archived, or blocked.');
1798
- }
1799
-
1800
- return lines.join('\n');
1801
- }
1802
-
1803
- async function cmdWork(rawArgs) {
1804
- const { kandownDir } = ensureKandownDir(rawArgs);
1805
- const config = readKandownConfig(kandownDir);
1806
- const workOutput = normalizeWorkOutputConfig(config);
1807
- syncKandownAgentDoc(kandownDir);
1808
-
1809
- const baseRules = readBaseAgentRules(workOutput.baseRulesMode);
1810
-
1811
- let projectInstructions = '';
1812
- const projectPath = join(kandownDir, 'instructions.md');
1813
- if (existsSync(projectPath)) {
1814
- try { projectInstructions = readFileSync(projectPath, 'utf8').trim(); }
1815
- catch (e) { warn(`Could not read project instructions (${e.message})`); }
1816
- }
1817
-
1818
- const blocks = {
1819
- baseRules,
1820
- projectInstructions: projectInstructions ? `## Project-specific instructions\n\n${projectInstructions}` : '',
1821
- boardDigest: buildBoardDigest(kandownDir, workOutput.boardDigest),
1822
- };
1823
-
1824
- if (workOutput.mode === 'raw') {
1825
- out(applyWorkRawTemplate(workOutput.rawTemplate, blocks));
1826
- return;
1827
- }
1828
-
1829
- const sections = [];
1830
- for (const sectionId of workOutput.sectionOrder) {
1831
- if (sectionId === 'baseRules' && workOutput.includeBaseRules) sections.push(blocks.baseRules);
1832
- if (sectionId === 'projectInstructions' && workOutput.includeProjectInstructions) sections.push(blocks.projectInstructions);
1833
- if (sectionId === 'boardDigest' && workOutput.includeBoardDigest) sections.push(blocks.boardDigest);
1834
- }
1835
-
1836
- out(sections.filter(Boolean).join('\n\n---\n\n'));
1837
- }
1838
-
1839
- function parsePort(value) {
1840
- if (value === null) return null;
1841
- const port = Number(value);
1842
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
1843
- err(`Invalid port: ${c.bold}${value}${c.reset}`);
1844
- log(` Use ${c.cyan}--port <1-65535>${c.reset}.`);
1845
- process.exit(1);
1846
- }
1847
- return port;
1848
- }
1849
-
1850
- function daemonMetadataPath(kandownDir) {
1851
- return join(kandownDir, DAEMON_FILE);
1852
- }
1853
-
1854
- function readDaemonMetadata(kandownDir) {
1855
- const metadataPath = daemonMetadataPath(kandownDir);
1856
- if (!existsSync(metadataPath)) return null;
1857
- try {
1858
- const raw = JSON.parse(readFileSync(metadataPath, 'utf8'));
1859
- if (!raw || typeof raw !== 'object') return null;
1860
- if (!Number.isInteger(raw.pid) || !Number.isInteger(raw.port)) return null;
1861
- if (typeof raw.url !== 'string' || typeof raw.kandownDir !== 'string') return null;
1862
- return raw;
1863
- } catch {
1864
- return null;
1865
- }
1866
- }
1867
-
1868
- function writeDaemonMetadata(kandownDir, metadata) {
1869
- atomicWriteFileSync(daemonMetadataPath(kandownDir), JSON.stringify(metadata, null, 2) + '\n');
1870
- }
1871
-
1872
- function removeDaemonMetadata(kandownDir) {
1873
- try {
1874
- if (existsSync(daemonMetadataPath(kandownDir))) unlinkSync(daemonMetadataPath(kandownDir));
1875
- } catch { /* ignore cleanup failure */ }
1876
- }
1877
-
1878
- function ensureDaemonGitignore(kandownDir) {
1879
- const gitignorePath = join(kandownDir, '.gitignore');
1880
- // 📖 Runtime files that must never be committed: daemon metadata + spawn lock.
1881
- const runtimeEntries = [DAEMON_FILE, 'daemon.lock'];
1882
- try {
1883
- if (!existsSync(gitignorePath)) {
1884
- writeFileSync(gitignorePath, runtimeEntries.join('\n') + '\n', 'utf8');
1885
- return;
1886
- }
1887
- const existing = readFileSync(gitignorePath, 'utf8');
1888
- const lines = existing.split(/\r?\n/);
1889
- const missing = runtimeEntries.filter(entry => !lines.includes(entry));
1890
- if (missing.length > 0) {
1891
- writeFileSync(gitignorePath, `${existing.trimEnd()}\n${missing.join('\n')}\n`, 'utf8');
1892
- }
1893
- } catch { /* ignore gitignore best-effort failure */ }
1894
- }
1895
-
1896
- function isProcessAlive(pid) {
1897
- if (!Number.isInteger(pid) || pid <= 0) return false;
1898
- try {
1899
- process.kill(pid, 0);
1900
- return true;
1901
- } catch {
1902
- return false;
1903
- }
1904
- }
1905
-
1906
- async function fetchDaemonInfo(port) {
1907
- try {
1908
- const res = await fetch(`http://127.0.0.1:${port}/api/daemon`, {
1909
- signal: AbortSignal.timeout(700),
1910
- });
1911
- if (!res.ok) return null;
1912
- return await res.json();
1913
- } catch {
1914
- return null;
1915
- }
1916
- }
1917
-
1918
- /**
1919
- * 📖 Fast, dependency-free TCP probe. Returns true the instant a port accepts
1920
- * a connection — i.e. the moment the daemon has actually bound its socket.
1921
- * Used instead of an HTTP fetch to detect startup, because Node's fetch
1922
- * (undici) can fail to recover from the initial ECONNREFUSED window (fetches
1923
- * sent before the server binds) and report a healthy local server as down for
1924
- * seconds, which orphaned freshly-started multi-project daemons. A raw TCP
1925
- * connect has no connection-pool state to poison, so it reliably tracks the
1926
- * real liveness of the socket.
1927
- */
1928
- function isPortListening(port, timeoutMs = 400) {
1929
- return new Promise((resolve) => {
1930
- const socket = createConnection({ port, host: '127.0.0.1' }, () => {
1931
- socket.destroy();
1932
- resolve(true);
1933
- });
1934
- socket.on('error', () => resolve(false));
1935
- socket.setTimeout(timeoutMs);
1936
- socket.on('timeout', () => {
1937
- socket.destroy();
1938
- resolve(false);
1939
- });
1940
- });
1941
- }
1942
-
1943
- async function getDaemonStatus(kandownDir) {
1944
- const metadata = readDaemonMetadata(kandownDir);
1945
- if (!metadata) return { running: false, metadata: null };
1946
- if (!isProcessAlive(metadata.pid)) {
1947
- removeDaemonMetadata(kandownDir);
1948
- return { running: false, metadata: null };
1949
- }
1950
- const remote = await fetchDaemonInfo(metadata.port);
1951
- if (!remote) {
1952
- // 📖 Transient fetch failure (server still warming up, undici connection
1953
- // hiccup, high load). The owning process IS alive (checked above), so we
1954
- // must NOT destroy the metadata here — otherwise starting a 2nd kandown
1955
- // project races the just-written metadata and orphans a healthy daemon.
1956
- // Return non-running so callers retry; the metadata stays intact.
1957
- return { running: false, metadata: null };
1958
- }
1959
- if (remote.kandownDir !== kandownDir || remote.pid !== metadata.pid) {
1960
- // 📖 Real conflict: the port is owned by a DIFFERENT kandown (another
1961
- // project, or a reincarnated PID). That is a genuine stale entry — remove.
1962
- removeDaemonMetadata(kandownDir);
1963
- return { running: false, metadata: null };
1964
- }
1965
- return { running: true, metadata };
1966
- }
1967
-
1968
- function refreshKandownHtml(kandownDir) {
1969
- const htmlDest = join(kandownDir, 'kandown.html');
1970
- const htmlSrc = join(PKG_ROOT, 'dist', 'index.html');
1971
- if (existsSync(htmlDest) && existsSync(htmlSrc)) {
1972
- copyFileSync(htmlSrc, htmlDest);
1973
- return true;
1974
- }
1975
- return false;
1976
- }
1977
-
1978
- function daemonScanPorts() {
1979
- const ports = [];
1980
- for (let port = START_PORT_RANGE; port <= END_PORT_RANGE; port++) {
1981
- if (!isBrowserUnsafePort(port)) ports.push(port);
1982
- }
1983
- return ports;
1984
- }
1985
-
1986
- async function listRunningDaemons() {
1987
- const daemons = await Promise.all(daemonScanPorts().map(port => fetchDaemonInfo(port)));
1988
- const byDir = new Map();
1989
- for (const daemon of daemons) {
1990
- if (!daemon || typeof daemon.kandownDir !== 'string' || daemon.kandownDir.length === 0) continue;
1991
- if (!byDir.has(daemon.kandownDir)) byDir.set(daemon.kandownDir, daemon);
1992
- }
1993
- return [...byDir.values()];
1994
- }
1995
-
1996
- async function refreshRunningDaemons({ forceRestart = false } = {}) {
1997
- const daemons = await listRunningDaemons();
1998
- const currentVersion = getCurrentVersion();
1999
- const results = {
2000
- found: daemons.length,
2001
- refreshed: 0,
2002
- restarted: 0,
2003
- skipped: 0,
2004
- failed: [],
2005
- };
2006
-
2007
- for (const daemon of daemons) {
2008
- const kandownDir = daemon.kandownDir;
2009
- try {
2010
- if (refreshKandownHtml(kandownDir)) results.refreshed++;
2011
- const current = await getDaemonStatus(kandownDir);
2012
- const daemonVersion = daemon.version || current.metadata?.version || null;
2013
- const shouldRestart = forceRestart || !daemonVersion || (currentVersion && semverGt(currentVersion, daemonVersion) > 0);
2014
- if (!shouldRestart) {
2015
- results.skipped++;
2016
- continue;
2017
- }
2018
- const preferredPort = daemon.port || current.metadata?.port || null;
2019
- await stopDaemon(kandownDir);
2020
- const restarted = await startDaemon(kandownDir, preferredPort);
2021
- if (!restarted.running) throw new Error('daemon failed to restart');
2022
- results.restarted++;
2023
- } catch (e) {
2024
- results.failed.push({ kandownDir, error: e.message });
2025
- }
2026
- }
2027
-
2028
- return results;
2029
- }
2030
-
2031
- async function refreshRunningProjectHtml() {
2032
- const results = await refreshRunningDaemons({ forceRestart: false });
2033
- return results.refreshed;
2034
- }
2035
-
2036
- async function waitForDaemon(kandownDir, timeoutMs = 8000) {
2037
- // 📖 Detect daemon startup via TCP probe + process liveness rather than HTTP
2038
- // fetch. The freshly spawned child is known to be ours, so once its process
2039
- // is alive AND its port accepts a TCP connection, the daemon is up — no need
2040
- // to wait for an HTTP round-trip that undici may not serve reliably during
2041
- // the bind window. The dir/pid ownership check still happens later via
2042
- // getDaemonStatus() for the status/stop commands.
2043
- const started = Date.now();
2044
- while (Date.now() - started < timeoutMs) {
2045
- const metadata = readDaemonMetadata(kandownDir);
2046
- if (metadata && isProcessAlive(metadata.pid)) {
2047
- if (await isPortListening(metadata.port)) {
2048
- return { running: true, metadata };
2049
- }
2050
- }
2051
- await new Promise(r => setTimeout(r, 120));
2052
- }
2053
- return { running: false, metadata: null };
2054
- }
2055
-
2056
- /**
2057
- * 📖 Spawn lock (M7): two `kandown` processes started at the same moment in
2058
- * the same project must not BOTH spawn a daemon (the loser's daemon.json
2059
- * write would orphan the winner's daemon). O_EXCL file creation is the mutex;
2060
- * the loser just waits for the winner's daemon to come up. A lock older than
2061
- * 15s is stale (crashed spawner) and gets stolen.
2062
- */
2063
- function acquireDaemonSpawnLock(kandownDir) {
2064
- const lockPath = join(kandownDir, 'daemon.lock');
2065
- try {
2066
- const fd = openSync(lockPath, 'wx');
2067
- writeSync(fd, String(process.pid));
2068
- closeSync(fd);
2069
- return lockPath;
2070
- } catch (e) {
2071
- if (e.code !== 'EEXIST') return null;
2072
- try {
2073
- if (Date.now() - statSync(lockPath).mtimeMs > 15_000) {
2074
- unlinkSync(lockPath);
2075
- return acquireDaemonSpawnLock(kandownDir);
2076
- }
2077
- } catch { /* lock vanished — racing is fine, caller waits */ }
2078
- return null;
2079
- }
2080
- }
2081
-
2082
- function releaseDaemonSpawnLock(lockPath) {
2083
- try { unlinkSync(lockPath); } catch { /* already gone */ }
2084
- }
2085
-
2086
- async function startDaemon(kandownDir, preferredPort) {
2087
- const current = await getDaemonStatus(kandownDir);
2088
- if (current.running) {
2089
- const currentVersion = getCurrentVersion();
2090
- const daemonVersion = current.metadata?.version || null;
2091
- // 📖 Deep-link routes and other daemon-owned behavior live in the long-running
2092
- // daemon process, not in kandown.html. If the CLI updated while a daemon was
2093
- // already running, reconnecting to the old process keeps old routing alive
2094
- // (e.g. refreshing `/058?p=suzu` returned 404). Restart only when the local
2095
- // CLI is newer; newer/dev daemons are left untouched.
2096
- if (!daemonVersion || semverGt(currentVersion, daemonVersion) > 0) {
2097
- warn(`Restarting daemon v${daemonVersion || 'unknown'} → v${currentVersion}...`);
2098
- await stopDaemon(kandownDir);
2099
- } else {
2100
- return current;
2101
- }
2102
- }
2103
-
2104
- const lock = acquireDaemonSpawnLock(kandownDir);
2105
- if (!lock) {
2106
- // 📖 Another process is spawning the daemon right now — just wait for it.
2107
- return waitForDaemon(kandownDir);
2108
- }
2109
-
2110
- try {
2111
- // Double-checked: the daemon may have come up while we acquired the lock.
2112
- const recheck = await getDaemonStatus(kandownDir);
2113
- if (recheck.running) return recheck;
2114
-
2115
- removeDaemonMetadata(kandownDir);
2116
- ensureDaemonGitignore(kandownDir);
2117
- const daemonArgs = [
2118
- process.argv[1],
2119
- '--no-update-check',
2120
- 'daemon',
2121
- 'run',
2122
- '--path',
2123
- kandownDir,
2124
- ];
2125
- if (preferredPort !== null) daemonArgs.push('--port', String(preferredPort));
2126
-
2127
- const child = spawn(process.execPath, daemonArgs, {
2128
- cwd: dirname(kandownDir),
2129
- detached: true,
2130
- stdio: 'ignore',
2131
- env: { ...process.env, KANDOWN_DAEMON: '1' },
2132
- });
2133
- child.unref();
2134
-
2135
- return await waitForDaemon(kandownDir);
2136
- } finally {
2137
- releaseDaemonSpawnLock(lock);
2138
- }
2139
- }
2140
-
2141
- /**
2142
- * 📖 Guard against PID reuse before killing: the PID is only "ours" if the
2143
- * daemon API confirms ownership, or — when the API is unreachable (wedged /
2144
- * still starting) — the OS process table shows a kandown process launched for
2145
- * THIS project. Without this, stale metadata left by a crash could point at a
2146
- * recycled PID and stopDaemon would SIGKILL an unrelated process.
2147
- */
2148
- async function isOwnedKandownDaemon(pid, port, kandownDir) {
2149
- const remote = await fetchDaemonInfo(port);
2150
- if (remote) return remote.pid === pid && remote.kandownDir === kandownDir;
2151
- try {
2152
- const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', timeout: 2000 }).trim();
2153
- return /kandown/.test(cmd) && cmd.includes(kandownDir);
2154
- } catch {
2155
- return false;
2156
- }
2157
- }
2158
-
2159
- async function stopDaemon(kandownDir) {
2160
- const metadata = readDaemonMetadata(kandownDir);
2161
- if (!metadata) return false;
2162
-
2163
- const pid = metadata.pid;
2164
- if (isProcessAlive(pid)) {
2165
- if (!(await isOwnedKandownDaemon(pid, metadata.port, kandownDir))) {
2166
- // Alive PID that isn't our daemon (recycled PID / stale metadata) — never kill.
2167
- removeDaemonMetadata(kandownDir);
2168
- return false;
2169
- }
2170
- try {
2171
- process.kill(pid, 'SIGTERM');
2172
- } catch { /* already stopped */ }
2173
-
2174
- const started = Date.now();
2175
- let killed = false;
2176
- while (Date.now() - started < 2500) {
2177
- if (!isProcessAlive(pid)) {
2178
- killed = true;
2179
- break;
2180
- }
2181
- await new Promise(r => setTimeout(r, 100));
2182
- }
2183
-
2184
- if (!killed && isProcessAlive(pid)) {
2185
- try {
2186
- process.kill(pid, 'SIGKILL');
2187
- } catch {}
2188
- }
2189
- }
2190
-
2191
- removeDaemonMetadata(kandownDir);
2192
- return true;
2193
- }
2194
-
2195
- /**
2196
- * 📖 API auth token (M5). Generated fresh at every daemon start, stored in
2197
- * daemon.json (CLI/TUI read it there) and injected into the served HTML as
2198
- * `window.__KANDOWN_TOKEN__`. Every API route except `GET /api/daemon`
2199
- * requires it via the `X-Kandown-Token` header — a drive-by web page scanning
2200
- * localhost ports can no longer read or write the task files.
2201
- */
2202
- const DAEMON_TOKEN = randomBytes(24).toString('hex');
2203
-
2204
- function apiHeaders() {
2205
- // 📖 No Access-Control-Allow-Origin (M5): the web UI is served same-origin
2206
- // by this daemon, so cross-origin browser access is intentionally blocked.
2207
- // Non-browser clients (CLI, TUI, curl) are unaffected by CORS.
2208
- return {
2209
- 'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE, OPTIONS',
2210
- 'Access-Control-Allow-Headers': 'Content-Type, X-Kandown-Token',
2211
- };
2212
- }
2213
-
2214
- function handleCors(res) {
2215
- res.writeHead(204, apiHeaders());
2216
- res.end();
2217
- }
2218
-
2219
- /**
2220
- * 📖 Token gate for API routes. Returns true when the request carries the
2221
- * daemon token; otherwise answers 401 and returns false.
2222
- */
2223
- function requireToken(req, res) {
2224
- const tokenHeader = req.headers['x-kandown-token'];
2225
- const requestUrl = new URL(req.url || '/', 'http://localhost');
2226
- const tokenQuery = requestUrl.searchParams.get('token');
2227
- if (tokenHeader === DAEMON_TOKEN || tokenQuery === DAEMON_TOKEN) return true;
2228
- writeJson(res, 401, { error: 'missing or invalid X-Kandown-Token' });
2229
- return false;
2230
- }
2231
-
2232
- const sseClients = new Set();
2233
-
2234
- function broadcastSseEvent(eventData) {
2235
- const payload = `data: ${JSON.stringify(eventData)}\n\n`;
2236
- for (const client of sseClients) {
2237
- try {
2238
- client.write(payload);
2239
- } catch {
2240
- sseClients.delete(client);
2241
- }
2242
- }
2243
- }
2244
-
2245
- setInterval(() => {
2246
- for (const client of sseClients) {
2247
- try {
2248
- client.write(':\n\n');
2249
- } catch {
2250
- sseClients.delete(client);
2251
- }
2252
- }
2253
- }, 15000);
2254
-
2255
- function writeJson(res, status, body) {
2256
- const headers = { ...apiHeaders(), 'Content-Type': 'application/json' };
2257
- res.writeHead(status, headers);
2258
- res.end(JSON.stringify(body));
2259
- }
2260
-
2261
- function writeText(res, status, body, contentType = 'text/plain; charset=utf-8') {
2262
- res.writeHead(status, { ...apiHeaders(), 'Content-Type': contentType });
2263
- res.end(body);
2264
- }
2265
-
2266
- // 📖 Body size cap: task files and configs are small — anything above 10 MB
2267
- // is a bug or abuse, and buffering it would balloon the daemon's memory.
2268
- const MAX_BODY_BYTES = 10 * 1024 * 1024;
2269
-
2270
- function readBody(req) {
2271
- return new Promise((resolve, reject) => {
2272
- const chunks = [];
2273
- let total = 0;
2274
- req.on('data', chunk => {
2275
- total += chunk.length;
2276
- if (total > MAX_BODY_BYTES) {
2277
- const e = new Error('Request body too large (max 10 MB)');
2278
- e.statusCode = 413;
2279
- req.destroy();
2280
- reject(e);
2281
- return;
2282
- }
2283
- chunks.push(chunk);
2284
- });
2285
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
2286
- req.on('error', reject);
2287
- });
2288
- }
2289
-
2290
- /**
2291
- * 📖 Resolves the agent hook configuration from environment variables.
2292
- *
2293
- * Strictly opt-in: if KANDOWN_AGENT_HOOK_URL is not set, returns null and no
2294
- * agent-related UI surfaces in the web app. This is the only check the UI
2295
- * uses to decide whether to show the "Send to Agent" button.
2296
- *
2297
- * Env vars (all optional except URL):
2298
- * - KANDOWN_AGENT_HOOK_URL target URL (POST receives `{action, task, context}`)
2299
- * - KANDOWN_AGENT_HOOK_LABEL button label (default: "Agent")
2300
- * - KANDOWN_AGENT_HOOK_HEADERS JSON object of extra headers (default: {})
2301
- *
2302
- * Malformed HEADERS are silently ignored so a typo never disables the hook.
2303
- */
2304
- function loadAgentHook() {
2305
- const url = process.env.KANDOWN_AGENT_HOOK_URL;
2306
- if (!url) return null;
2307
- const label = process.env.KANDOWN_AGENT_HOOK_LABEL || 'Agent';
2308
- const headers = {};
2309
- const raw = process.env.KANDOWN_AGENT_HOOK_HEADERS;
2310
- if (raw) {
2311
- try {
2312
- const parsed = JSON.parse(raw);
2313
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
2314
- for (const [k, v] of Object.entries(parsed)) {
2315
- if (typeof v === 'string') headers[k] = v;
2316
- }
2317
- }
2318
- } catch {
2319
- // Malformed JSON — ignore and ship with the default empty headers.
2320
- }
2321
- }
2322
- return { url, label, headers };
2323
- }
2324
-
2325
- /**
2326
- * 📖 Forwards a task to the configured agent hook. Returns a status object
2327
- * the HTTP handler converts to a JSON response. Failures (network, non-2xx,
2328
- * timeout) are surfaced as 502 so the UI can show a useful error.
2329
- */
2330
- async function postAgentTask(hook, taskMarkdown, id, kandownDir) {
2331
- const controller = new AbortController();
2332
- const timeout = setTimeout(() => controller.abort(), 8000);
2333
- try {
2334
- const res = await fetch(hook.url, {
2335
- method: 'POST',
2336
- headers: {
2337
- 'Content-Type': 'application/json',
2338
- ...hook.headers,
2339
- },
2340
- body: JSON.stringify({
2341
- action: 'agent',
2342
- task: { id, content: taskMarkdown },
2343
- context: {
2344
- tasksDir: getTasksDir(kandownDir),
2345
- cwd: getProjectRoot(kandownDir),
2346
- schema: 'kandown',
2347
- },
2348
- }),
2349
- signal: controller.signal,
2350
- });
2351
- const body = await res.text().catch(() => '');
2352
- return { ok: res.ok, status: res.status, body };
2353
- } catch (e) {
2354
- const message = e && e.name === 'AbortError' ? 'agent hook timed out (8s)' : `agent hook failed: ${e.message}`;
2355
- return { ok: false, status: 502, body: message };
2356
- } finally {
2357
- clearTimeout(timeout);
2358
- }
2359
- }
2360
-
2361
- function postTaskToAgent(req, res, kandownDir, id) {
2362
- if (!isValidTaskId(id)) {
2363
- return writeText(res, 400, 'Invalid task id');
2364
- }
2365
- const hook = loadAgentHook();
2366
- if (!hook) {
2367
- return writeJson(res, 501, { error: 'agent hook not configured (set KANDOWN_AGENT_HOOK_URL)' });
2368
- }
2369
- const taskPath = findTaskPath(kandownDir, id);
2370
- if (!taskPath) {
2371
- return writeJson(res, 404, { error: 'task not found' });
2372
- }
2373
- let taskMarkdown;
2374
- try {
2375
- taskMarkdown = readFileSync(taskPath, 'utf8');
2376
- } catch (e) {
2377
- return writeJson(res, 500, { error: `failed to read task: ${e.message}` });
2378
- }
2379
- postAgentTask(hook, taskMarkdown, id, kandownDir).then(result => {
2380
- if (!result.ok) {
2381
- return writeJson(res, 502, { error: result.body || `agent hook returned ${result.status}` });
2382
- }
2383
- return writeJson(res, 200, { ok: true });
2384
- }).catch(e => {
2385
- writeJson(res, 500, { error: e.message });
2386
- });
2387
- }
2388
-
2389
- function isValidTaskId(id) {
2390
- return /^[a-zA-Z0-9_-]+$/.test(id);
2391
- }
2392
-
2393
- function getConfig(res, kandownDir) {
2394
- const configPath = join(kandownDir, 'kandown.json');
2395
- if (!existsSync(configPath)) {
2396
- writeJson(res, 404, { error: 'kandown.json not found' });
2397
- return;
2398
- }
2399
- try {
2400
- const content = readFileSync(configPath, 'utf8');
2401
- const config = JSON.parse(content);
2402
- writeJson(res, 200, config);
2403
- } catch (e) {
2404
- writeJson(res, 500, { error: `Failed to read config: ${e.message}` });
2405
- }
2406
- }
2407
-
2408
- /**
2409
- * 📖 Shape validation for kandown.json writes: "is valid JSON" is not enough —
2410
- * a stray `[]` or `null` body would overwrite the config and silently reset
2411
- * every setting on the next load. Requires a plain object, and when `board`
2412
- * or `board.columns` are present they must have the right shape.
2413
- */
2414
- function isValidConfigShape(parsed) {
2415
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
2416
- if ('board' in parsed) {
2417
- const board = parsed.board;
2418
- if (!board || typeof board !== 'object' || Array.isArray(board)) return false;
2419
- if ('columns' in board) {
2420
- if (!Array.isArray(board.columns)) return false;
2421
- if (!board.columns.every(col => typeof col === 'string' && col.trim().length > 0)) return false;
2422
- }
2423
- }
2424
- return true;
2425
- }
2426
-
2427
- function putConfig(req, res, kandownDir) {
2428
- readBody(req).then(body => {
2429
- let parsed;
2430
- try {
2431
- parsed = JSON.parse(body);
2432
- } catch {
2433
- return writeJson(res, 400, { error: 'Invalid JSON' });
2434
- }
2435
- if (!isValidConfigShape(parsed)) {
2436
- return writeJson(res, 400, { error: 'Invalid config shape: expected an object (board.columns must be a non-empty string array)' });
2437
- }
2438
- const configPath = join(kandownDir, 'kandown.json');
2439
- atomicWriteFileSync(configPath, body);
2440
- writeJson(res, 200, { ok: true });
2441
- }).catch(e => {
2442
- writeJson(res, e.statusCode || 500, { error: `Failed to read body: ${e.message}` });
2443
- });
2444
- }
2445
-
2446
- function getInstructions(res, kandownDir) {
2447
- const instructionsPath = join(kandownDir, 'instructions.md');
2448
- if (!existsSync(instructionsPath)) {
2449
- writeText(res, 200, '');
2450
- return;
2451
- }
2452
- try {
2453
- writeText(res, 200, readFileSync(instructionsPath, 'utf8'));
2454
- } catch (e) {
2455
- writeText(res, 500, `Failed to read instructions: ${e.message}`);
2456
- }
2457
- }
2458
-
2459
- function putInstructions(req, res, kandownDir) {
2460
- readBody(req).then(body => {
2461
- const instructionsPath = join(kandownDir, 'instructions.md');
2462
- const normalized = body.trim() ? body.replace(/\s+$/, '') + '\n' : '';
2463
- atomicWriteFileSync(instructionsPath, normalized);
2464
- writeJson(res, 200, { ok: true });
2465
- }).catch(e => {
2466
- writeJson(res, e.statusCode || 500, { error: `Failed to write instructions: ${e.message}` });
2467
- });
2468
- }
2469
-
2470
- function getBoard(res, kandownDir) {
2471
- const boardPath = join(kandownDir, 'board.md');
2472
- if (!existsSync(boardPath)) {
2473
- writeText(res, 404, 'board.md not found');
2474
- return;
2475
- }
2476
- try {
2477
- const content = readFileSync(boardPath, 'utf8');
2478
- writeText(res, 200, content);
2479
- } catch (e) {
2480
- writeText(res, 500, `Failed to read board: ${e.message}`);
2481
- }
2482
- }
2483
-
2484
- function putBoard(req, res, kandownDir) {
2485
- readBody(req).then(body => {
2486
- const boardPath = join(kandownDir, 'board.md');
2487
- atomicWriteFileSync(boardPath, body);
2488
- writeJson(res, 200, { ok: true });
2489
- }).catch(e => {
2490
- writeJson(res, 500, { error: `Failed to write board: ${e.message}` });
2491
- });
2492
- }
2493
-
2494
- /**
2495
- * 📖 Resolves the on-disk path of a task id, searching the active tasks dir
2496
- * first, then the archive subfolder. Returns null when the id exists in
2497
- * neither location. Used by every CRUD handler so archived tasks stay
2498
- * reachable at their real location.
2499
- *
2500
- * Tasks live at the project root in `./tasks/` (sibling of `.kandown/`),
2501
- * not inside `.kandown/tasks/`.
2502
- */
2503
- function findTaskPath(kandownDir, id) {
2504
- const tasksDir = getTasksDir(kandownDir);
2505
- const inTasks = join(tasksDir, `${id}.md`);
2506
- if (existsSync(inTasks)) return inTasks;
2507
- const inArchive = join(tasksDir, 'archive', `${id}.md`);
2508
- if (existsSync(inArchive)) return inArchive;
2509
- return null;
2510
- }
2511
-
2512
- function getTasks(res, kandownDir) {
2513
- const tasksDir = getTasksDir(kandownDir);
2514
- const archiveDir = join(tasksDir, 'archive');
2515
- const ids = new Set();
2516
- try {
2517
- if (existsSync(tasksDir)) {
2518
- for (const f of readdirSync(tasksDir).filter(f => f.endsWith('.md'))) {
2519
- ids.add(f.replace(/\.md$/, ''));
2520
- }
2521
- }
2522
- // 📖 Also surface archived tasks so the UI can list them in the archive
2523
- // view. The archived flag in frontmatter (not the folder) is the source of
2524
- // truth for hiding them from the active board.
2525
- if (existsSync(archiveDir)) {
2526
- for (const f of readdirSync(archiveDir).filter(f => f.endsWith('.md'))) {
2527
- ids.add(f.replace(/\.md$/, ''));
2528
- }
2529
- }
2530
- writeJson(res, 200, [...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })));
2531
- } catch (e) {
2532
- writeJson(res, 500, { error: `Failed to list tasks: ${e.message}` });
2533
- }
2534
- }
2535
-
2536
- function getTask(res, kandownDir, id) {
2537
- if (!isValidTaskId(id)) {
2538
- writeText(res, 400, 'Invalid task id');
2539
- return;
2540
- }
2541
- const taskPath = findTaskPath(kandownDir, id);
2542
- if (!taskPath) {
2543
- writeText(res, 404, 'Task not found');
2544
- return;
2545
- }
2546
- try {
2547
- const content = readFileSync(taskPath, 'utf8');
2548
- writeText(res, 200, content);
2549
- } catch (e) {
2550
- writeText(res, 500, `Failed to read task: ${e.message}`);
2551
- }
2552
- }
2553
-
2554
- function putTask(req, res, kandownDir, id) {
2555
- if (!isValidTaskId(id)) {
2556
- writeText(res, 400, 'Invalid task id');
2557
- return;
2558
- }
2559
- readBody(req).then(body => {
2560
- const tasksDir = getTasksDir(kandownDir);
2561
- if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
2562
- // 📖 Write in place: keep an archived task inside archive/ on save so the
2563
- // file location never drifts from its archived flag.
2564
- const existing = findTaskPath(kandownDir, id);
2565
- const archiveDir = join(tasksDir, 'archive');
2566
- const inArchive = existing && existing.startsWith(archiveDir);
2567
- const targetDir = inArchive ? archiveDir : tasksDir;
2568
- const taskPath = join(targetDir, `${id}.md`);
2569
- atomicWriteFileSync(taskPath, body);
2570
- writeJson(res, 200, { ok: true });
2571
- }).catch(e => {
2572
- writeJson(res, 500, { error: `Failed to write task: ${e.message}` });
2573
- });
2574
- }
2575
-
2576
- function deleteTask(res, kandownDir, id) {
2577
- if (!isValidTaskId(id)) {
2578
- writeText(res, 400, 'Invalid task id');
2579
- return;
2580
- }
2581
- const taskPath = findTaskPath(kandownDir, id);
2582
- if (!taskPath) {
2583
- writeJson(res, 404, { error: 'Task not found' });
2584
- return;
2585
- }
2586
- try {
2587
- unlinkSync(taskPath);
2588
- writeJson(res, 200, { ok: true });
2589
- } catch (e) {
2590
- writeJson(res, 500, { error: `Failed to delete task: ${e.message}` });
2591
- }
2592
- }
2593
-
2594
- /**
2595
- * 📖 Archives a task: writes the (already flag-updated) content into
2596
- * `tasks/archive/<id>.md` and removes the active `tasks/<id>.md` copy.
2597
- * The body comes pre-flagged from the web client (which knows frontmatter).
2598
- */
2599
- function archiveTask(req, res, kandownDir, id) {
2600
- if (!isValidTaskId(id)) {
2601
- writeText(res, 400, 'Invalid task id');
2602
- return;
2603
- }
2604
- readBody(req).then(body => {
2605
- const tasksDir = getTasksDir(kandownDir);
2606
- const archiveDir = join(tasksDir, 'archive');
2607
- if (!existsSync(archiveDir)) mkdirSync(archiveDir, { recursive: true });
2608
- atomicWriteFileSync(join(archiveDir, `${id}.md`), body);
2609
- try {
2610
- unlinkSync(join(tasksDir, `${id}.md`));
2611
- } catch { /* already absent */ }
2612
- writeJson(res, 200, { ok: true });
2613
- }).catch(e => {
2614
- writeJson(res, 500, { error: `Failed to archive task: ${e.message}` });
2615
- });
2616
- }
2617
-
2618
- /**
2619
- * 📖 Unarchives a task: writes the content back into `tasks/<id>.md` and
2620
- * removes the archived copy. Mirror of archiveTask.
2621
- */
2622
- function unarchiveTask(req, res, kandownDir, id) {
2623
- if (!isValidTaskId(id)) {
2624
- writeText(res, 400, 'Invalid task id');
2625
- return;
2626
- }
2627
- readBody(req).then(body => {
2628
- const tasksDir = getTasksDir(kandownDir);
2629
- atomicWriteFileSync(join(tasksDir, `${id}.md`), body);
2630
- try {
2631
- unlinkSync(join(tasksDir, 'archive', `${id}.md`));
2632
- } catch { /* already absent */ }
2633
- writeJson(res, 200, { ok: true });
2634
- }).catch(e => {
2635
- writeJson(res, 500, { error: `Failed to unarchive task: ${e.message}` });
2636
- });
2637
- }
2638
-
2639
- /**
2640
- * 📖 REST endpoint to trigger the silent one-time task migration. The web
2641
- * client (server mode) can call this on startup to perform the same
2642
- * `.kandown/tasks/` → `./tasks/` move the CLI does on first access.
2643
- * Safe to call multiple times — idempotent.
2644
- */
2645
- function postMigrateTasks(res, kandownDir) {
2646
- try {
2647
- const result = migrateTasksToTopLevel(kandownDir);
2648
- writeJson(res, 200, { ok: true, ...result });
2649
- } catch (e) {
2650
- writeJson(res, 500, { error: `Migration failed: ${e.message}` });
2651
- }
2652
- }
2653
-
2654
- /**
2655
- * 📖 The single-file Vite bundle can contain literal strings such as
2656
- * `</head>` from HTML parser libraries. Use the last closing head tag so the
2657
- * CLI does not inject server-mode globals into bundled JavaScript text.
2658
- */
2659
- function injectServerRoot(html, kandownDir) {
2660
- const marker = '</head>';
2661
- const markerIndex = html.toLowerCase().lastIndexOf(marker);
2662
- const safeRoot = JSON.stringify(kandownDir).replace(/</g, '\\u003c');
2663
- // 📖 The token rides along with the root: same-origin page → full API access;
2664
- // any other page (drive-by localhost scan) never sees it (M5).
2665
- const safeToken = JSON.stringify(DAEMON_TOKEN).replace(/</g, '\\u003c');
2666
- const script = `<script>window.__KANDOWN_ROOT__ = ${safeRoot}; window.__KANDOWN_TOKEN__ = ${safeToken};</script>\n`;
2667
-
2668
- if (markerIndex === -1) return script + html;
2669
-
2670
- return html.slice(0, markerIndex) + script + html.slice(markerIndex);
2671
- }
2672
-
2673
- function handleApi(req, res, url, kandownDir) {
2674
- const parts = url.pathname.replace('/api/', '').split('/');
2675
- const resource = parts[0];
2676
- const id = parts[1];
2677
-
2678
- // 📖 Token gate (M5): every API route requires X-Kandown-Token, except
2679
- // GET /api/daemon which stays open — it is the identity endpoint the CLI
2680
- // and sibling daemons use to verify ownership before they have the token,
2681
- // and it never exposes the token itself.
2682
- if (!(resource === 'daemon' && req.method === 'GET')) {
2683
- if (!requireToken(req, res)) return;
2684
- }
2685
-
2686
- if (resource === 'daemon') {
2687
- if (req.method === 'GET') {
2688
- const hook = loadAgentHook();
2689
- return writeJson(res, 200, {
2690
- ok: true,
2691
- pid: process.pid,
2692
- kandownDir,
2693
- version: getCurrentVersion(),
2694
- startedAt: daemonStartedAt,
2695
- agentHook: hook ? { enabled: true, label: hook.label } : null,
2696
- });
2697
- }
2698
- }
2699
-
2700
- if (resource === 'events') {
2701
- if (req.method === 'GET') {
2702
- res.writeHead(200, {
2703
- 'Content-Type': 'text/event-stream',
2704
- 'Cache-Control': 'no-cache',
2705
- 'Connection': 'keep-alive',
2706
- 'Access-Control-Allow-Origin': '*',
2707
- });
2708
- res.write('data: {"type":"connected"}\n\n');
2709
- sseClients.add(res);
2710
- req.on('close', () => {
2711
- sseClients.delete(res);
2712
- });
2713
- return;
2714
- }
2715
- }
2716
-
2717
- if (resource === 'git' && parts[1] === 'history') {
2718
- const taskId = url.searchParams.get('id');
2719
- if (!taskId) return writeJson(res, 400, { error: 'Missing task id' });
2720
- try {
2721
- const taskPath = findTaskPath(kandownDir, taskId);
2722
- if (!taskPath) return writeJson(res, 404, { error: 'Task not found' });
2723
- const relativePath = relative(getProjectRoot(kandownDir), taskPath);
2724
- const output = execFileSync('git', ['log', '-n', '10', '--follow', '--format=%h|%an|%ar|%s', '--', relativePath], {
2725
- cwd: getProjectRoot(kandownDir),
2726
- encoding: 'utf8',
2727
- }).trim();
2728
- const commits = output ? output.split('\n').map(line => {
2729
- const [hash, author, date, message] = line.split('|');
2730
- return { hash, author, date, message };
2731
- }) : [];
2732
- return writeJson(res, 200, { commits });
2733
- } catch {
2734
- return writeJson(res, 200, { commits: [] });
2735
- }
2736
- }
2737
-
2738
- if (resource === 'config') {
2739
- if (req.method === 'GET') return getConfig(res, kandownDir);
2740
- if (req.method === 'PUT') return putConfig(req, res, kandownDir);
2741
- }
2742
-
2743
- if (resource === 'instructions') {
2744
- if (req.method === 'GET') return getInstructions(res, kandownDir);
2745
- if (req.method === 'PUT') return putInstructions(req, res, kandownDir);
2746
- }
2747
-
2748
- if (resource === 'board') {
2749
- if (req.method === 'GET') return getBoard(res, kandownDir);
2750
- if (req.method === 'PUT') return putBoard(req, res, kandownDir);
2751
- }
2752
-
2753
- if (resource === 'tasks') {
2754
- if (req.method === 'GET' && !id) return getTasks(res, kandownDir);
2755
- if (req.method === 'GET' && id) return getTask(res, kandownDir, id);
2756
- if (req.method === 'PUT' && id) return putTask(req, res, kandownDir, id);
2757
- if (req.method === 'DELETE' && id) return deleteTask(res, kandownDir, id);
2758
- // parts[2] is the sub-resource: 'archive' or 'unarchive'. The body carries
2759
- // the full task file content with the archived flag already toggled.
2760
- if (req.method === 'POST' && id && parts[2] === 'archive') return archiveTask(req, res, kandownDir, id);
2761
- if (req.method === 'POST' && id && parts[2] === 'unarchive') return unarchiveTask(req, res, kandownDir, id);
2762
- if (req.method === 'POST' && id && parts[2] === 'agent') return postTaskToAgent(req, res, kandownDir, id);
2763
- }
2764
-
2765
- // 📖 Migration endpoint: `POST /api/migrate-tasks` with no id. Idempotent.
2766
- if (resource === 'migrate-tasks' && req.method === 'POST' && !id) {
2767
- return postMigrateTasks(res, kandownDir);
2768
- }
2769
-
2770
- writeJson(res, 404, { error: 'Not found' });
2771
- }
2772
-
2773
- const daemonStartedAt = new Date().toISOString();
2774
-
2775
- function serveApp(res, kandownDir) {
2776
- const htmlPath = join(kandownDir, 'kandown.html');
2777
- if (!existsSync(htmlPath)) {
2778
- writeText(res, 404, 'kandown.html not found');
2779
- return;
2780
- }
2781
-
2782
- try {
2783
- const html = readFileSync(htmlPath, 'utf8');
2784
- const injected = injectServerRoot(html, kandownDir);
2785
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2786
- res.end(injected);
2787
- } catch (e) {
2788
- // 📖 Don't leak internal paths / error details to HTTP clients — log the
2789
- // full error server-side, send a generic message to the browser (t113).
2790
- console.error(`[serve] Error serving ${htmlPath}:`, e);
2791
- writeText(res, 500, 'Internal server error — check the terminal where kandown is running.');
2792
- }
2793
- }
2794
-
2795
- /**
2796
- * 📖 Creates the local HTTP server used by `kandown` with no arguments.
2797
- * It serves the single-file web app and exposes placeholder API routes for the
2798
- * follow-up REST task, keeping this refactor limited to server bootstrapping.
2799
- */
2800
- const ASSET_MIME_TYPES = {
2801
- '.svg': 'image/svg+xml',
2802
- '.png': 'image/png',
2803
- '.ico': 'image/x-icon',
2804
- '.json': 'application/json',
2805
- '.webmanifest': 'application/manifest+json',
2806
- };
2807
-
2808
- function serveStaticAsset(req, res, pathname) {
2809
- const fileBasename = pathname.replace(/^\//, '');
2810
- if (!fileBasename || fileBasename.includes('..')) return false;
2811
- const assetPath = join(PKG_ROOT, 'public', fileBasename);
2812
- if (existsSync(assetPath) && statSync(assetPath).isFile()) {
2813
- const ext = extname(assetPath).toLowerCase();
2814
- const contentType = ASSET_MIME_TYPES[ext] || 'application/octet-stream';
2815
- try {
2816
- handleCors(res);
2817
- const content = readFileSync(assetPath);
2818
- if (req.method === 'HEAD') {
2819
- res.writeHead(200, {
2820
- 'Content-Type': contentType,
2821
- 'Content-Length': content.length,
2822
- 'Cache-Control': 'public, max-age=86400',
2823
- });
2824
- res.end();
2825
- return true;
2826
- }
2827
- res.writeHead(200, {
2828
- 'Content-Type': contentType,
2829
- 'Content-Length': content.length,
2830
- 'Cache-Control': 'public, max-age=86400',
2831
- });
2832
- res.end(content);
2833
- return true;
2834
- } catch {
2835
- return false;
2836
- }
2837
- }
2838
- return false;
2839
- }
2840
-
2841
- function isTaskDeepLinkPath(pathname) {
2842
- try {
2843
- const cleaned = decodeURIComponent(pathname).replace(/^\/+|\/+$/g, '');
2844
- return !!cleaned && !cleaned.includes('/') && !cleaned.includes('.') && /^[A-Za-z0-9_-]+$/.test(cleaned);
2845
- } catch {
2846
- return false;
2847
- }
2848
- }
2849
-
2850
- function createServeServer(kandownDir) {
2851
- try {
2852
- const tasksDir = getTasksDir(kandownDir);
2853
- const configPath = join(kandownDir, 'kandown.json');
2854
- const watcher = watchFs([join(tasksDir, '*.md'), configPath], {
2855
- ignoreInitial: true,
2856
- awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
2857
- });
2858
- watcher.on('all', (_event, filePath) => {
2859
- const taskId = filePath.replace(/\\/g, '/').split('/').pop()?.replace(/\.md$/, '') || '';
2860
- broadcastSseEvent({ type: 'change', taskId });
2861
- });
2862
- } catch (e) {
2863
- console.error('[daemon] File watcher init warning:', e.message);
2864
- }
2865
-
2866
- return createServer((req, res) => {
2867
- const requestUrl = new URL(req.url || '/', 'http://localhost');
2868
- if (req.method === 'OPTIONS') return handleCors(res);
2869
- if (requestUrl.pathname === '/') return serveApp(res, kandownDir);
2870
- if (requestUrl.pathname.startsWith('/api/')) {
2871
- return handleApi(req, res, requestUrl, kandownDir);
2872
- }
2873
- if (serveStaticAsset(req, res, requestUrl.pathname)) return;
2874
- if (req.method === 'GET' && isTaskDeepLinkPath(requestUrl.pathname)) return serveApp(res, kandownDir);
2875
- return writeText(res, 404, 'Not found');
2876
- });
2877
- }
2878
-
2879
- function listen(server, port) {
2880
- return new Promise((resolveListen, rejectListen) => {
2881
- const onError = (e) => {
2882
- server.off('listening', onListening);
2883
- rejectListen(e);
2884
- };
2885
- const onListening = () => {
2886
- server.off('error', onError);
2887
- resolveListen();
2888
- };
2889
- server.once('error', onError);
2890
- server.once('listening', onListening);
2891
- server.listen(port, '127.0.0.1');
2892
- });
2893
- }
2894
-
2895
- async function listenOnAvailablePort(kandownDir, preferredPort) {
2896
- const port = preferredPort ?? START_PORT_RANGE;
2897
- const isSinglePort = preferredPort !== null;
2898
-
2899
- // 📖 Check if the target port is already occupied by a stale kandown process
2900
- // from the SAME project. A stale process is one whose TUI exited but the HTTP
2901
- // server is still alive. We ONLY reclaim same-project daemons here — a daemon
2902
- // belonging to a DIFFERENT project (reason === null) must never be touched;
2903
- // it is handled by the scan loop below (which skips it).
2904
- const staleInfo = detectStaleKandown(port, kandownDir);
2905
- if (staleInfo && staleInfo.reason) {
2906
- warn(staleInfo.reason);
2907
- try {
2908
- process.kill(staleInfo.pid, 'SIGTERM');
2909
- // Give it a moment to clean up
2910
- await new Promise(r => setTimeout(r, 300));
2911
- info(`Reclaimed port ${c.cyan}${port}${c.reset} (killed stale kandown PID ${staleInfo.pid})`);
2912
- } catch {
2913
- // Process already dead
2914
- }
2915
- }
2916
-
2917
- // If user specified a specific port, only try that one
2918
- if (isSinglePort) {
2919
- if (isBrowserUnsafePort(port)) {
2920
- err(`Port ${c.bold}${port}${c.reset} is reserved for a well-known service and ${c.bold}blocked by all browsers${c.reset} (net::ERR_UNSAFE_PORT).`);
2921
- log(` Pick another port with ${c.cyan}--port${c.reset}.`);
2922
- process.exit(1);
2923
- }
2924
- const server = createServeServer(kandownDir);
2925
- try {
2926
- await listen(server, port);
2927
- return { server, port };
2928
- } catch (e) {
2929
- if (e.code === 'EADDRINUSE') {
2930
- err(`Port ${c.bold}${port}${c.reset} is in use by another application.`);
2931
- process.exit(1);
2932
- }
2933
- throw e;
2934
- }
2935
- }
2936
-
2937
- // 📖 Scan range — try ports until one works
2938
- for (let p = START_PORT_RANGE; p <= END_PORT_RANGE; p++) {
2939
- // 📖 Skip browser-blocked ports (e.g. 2049 = NFS). A daemon here would
2940
- // start fine and answer curl, but the browser refuses with
2941
- // net::ERR_UNSAFE_PORT, so the web UI looks dead. Move to the next port.
2942
- if (isBrowserUnsafePort(p)) continue;
2943
- // Skip port if occupied by a stale kandown from a DIFFERENT project
2944
- const stale = detectStaleKandown(p, kandownDir);
2945
- if (stale && !stale.reason) {
2946
- // reason is null → different project, skip this port
2947
- continue;
2948
- }
2949
- if (stale) {
2950
- // Same project — already killed above for the first port, but handle edge case
2951
- try { process.kill(stale.pid, 'SIGTERM'); } catch {}
2952
- await new Promise(r => setTimeout(r, 200));
2953
- }
2954
-
2955
- const server = createServeServer(kandownDir);
2956
- try {
2957
- await listen(server, p);
2958
- return { server, port: p };
2959
- } catch (e) {
2960
- // 📖 EADDRINUSE → try the next port. EACCES (privileged port / blocked
2961
- // by OS) → also try the next port instead of crashing (t113). Anything
2962
- // else is unexpected and bubbles up.
2963
- if (e.code !== 'EADDRINUSE' && e.code !== 'EACCES') throw e;
2964
- }
2965
- }
2966
-
2967
- err(`No free port available in ${c.bold}${START_PORT_RANGE}-${END_PORT_RANGE}${c.reset}.`);
2968
- process.exit(1);
2969
- }
2970
-
2971
- /**
2972
- * 📖 Detects if a port is occupied by a stale/zombie kandown process.
2973
- * A "stale" kandown is one whose TUI has exited but the HTTP server is still alive.
2974
- * Returns { pid, cwd, reason } or null if the port is free or used by something else.
2975
- */
2976
- function detectStaleKandown(port, currentKandownDir) {
2977
- let pid;
2978
- try {
2979
- pid = execSync(`lsof -ti :${port} -sTCP:LISTEN`, { encoding: 'utf8', timeout: 2000 }).trim();
2980
- } catch {
2981
- return null; // Port is free
2982
- }
2983
- if (!pid) return null;
2984
-
2985
- const pids = pid.split('\n').filter(Boolean);
2986
- if (pids.length === 0) return null;
2987
- pid = parseInt(pids[0], 10);
2988
- if (isNaN(pid) || pid === process.pid) return null;
2989
-
2990
- // Check if it's a kandown process
2991
- let cmdline;
2992
- try {
2993
- cmdline = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', timeout: 2000 }).trim();
2994
- } catch {
2995
- return null; // Process gone
2996
- }
2997
-
2998
- if (!/[/\\]kandown\b|^kandown\b|\skandown\b/.test(cmdline)) return null; // Not a kandown process
2999
-
3000
- // Get the working directory of the existing process
3001
- let cwd;
3002
- try {
3003
- if (process.platform === 'linux') {
3004
- cwd = execSync(`readlink -f /proc/${pid}/cwd`, { encoding: 'utf8', timeout: 2000 }).trim();
3005
- } else {
3006
- // macOS
3007
- cwd = execSync(`lsof -p ${pid} -Fn -a -d cwd 2>/dev/null | grep '^n' | cut -c2-`, { encoding: 'utf8', timeout: 2000, shell: true }).trim();
3008
- }
3009
- } catch {
3010
- cwd = null;
3011
- }
3012
-
3013
- // 📖 Compare with our project root (parent of .kandown/), not process.cwd().
3014
- // process.cwd() could be a subdirectory; the project root is stable.
3015
- const ourProjectRoot = currentKandownDir ? dirname(currentKandownDir) : process.cwd();
3016
- const isSameProject = cwd && (cwd === ourProjectRoot || cwd === process.cwd());
3017
-
3018
- // 📖 If it's our own process, skip (we're checking our own port)
3019
- if (pid === process.pid) return null;
3020
-
3021
- // 📖 Different project — don't touch, just skip the port
3022
- if (!isSameProject) {
3023
- return { pid, cwd: cwd || 'unknown', reason: null };
3024
- }
3025
-
3026
- // 📖 Same project — stale/zombie or legitimate, kill it either way.
3027
- // The user is explicitly launching kandown, so they want a fresh instance.
3028
- return {
3029
- pid,
3030
- cwd: cwd || 'unknown',
3031
- reason: `${c.yellow}Existing kandown found on port ${c.cyan}${port}${c.yellow} (PID ${pid}, same project). Reconnecting...${c.reset}`,
3032
- };
3033
- }
3034
-
3035
- async function cmdDaemon(rawArgs) {
3036
- const [subcommand = 'status', ...rest] = rawArgs;
3037
-
3038
- if (subcommand === 'refresh-all' || subcommand === 'restart-all') {
3039
- const forceRestart = rest.includes('--force') || subcommand === 'restart-all';
3040
- const results = await refreshRunningDaemons({ forceRestart });
3041
- success(`Scanned ${results.found} running daemon${results.found === 1 ? '' : 's'}`);
3042
- info(`Refreshed ${results.refreshed} kandown.html bundle${results.refreshed === 1 ? '' : 's'}`);
3043
- info(`${forceRestart ? 'Restarted' : 'Restarted outdated'} ${results.restarted} daemon${results.restarted === 1 ? '' : 's'}`);
3044
- if (!forceRestart && results.skipped > 0) info(`Skipped ${results.skipped} already-current daemon${results.skipped === 1 ? '' : 's'}`);
3045
- if (results.failed.length > 0) {
3046
- for (const failure of results.failed) warn(`${failure.kandownDir}: ${failure.error}`);
3047
- process.exit(1);
3048
- }
3049
- return;
3050
- }
3051
-
3052
- const { kandownDir } = ensureKandownDir(rest);
3053
- const preferredPort = parsePort(parseArgs(rest).port);
3054
-
3055
- if (subcommand === 'run') {
3056
- const { server, port } = await listenOnAvailablePort(kandownDir, preferredPort);
3057
- const url = `http://localhost:${port}`;
3058
- ensureDaemonGitignore(kandownDir);
3059
- writeDaemonMetadata(kandownDir, {
3060
- pid: process.pid,
3061
- port,
3062
- url,
3063
- kandownDir,
3064
- startedAt: daemonStartedAt,
3065
- version: getCurrentVersion(),
3066
- // 📖 Local-only secret (M5): daemon.json is chmod-protected by the
3067
- // user's umask and gitignored; the CLI/TUI read the token here to call
3068
- // the API. Never exposed by GET /api/daemon.
3069
- token: DAEMON_TOKEN,
3070
- });
3071
-
3072
- const shutdown = () => {
3073
- server.close(() => {
3074
- removeDaemonMetadata(kandownDir);
3075
- process.exit(0);
3076
- });
3077
- };
3078
- process.once('SIGINT', shutdown);
3079
- process.once('SIGTERM', shutdown);
3080
- await new Promise(() => {});
3081
- return;
3082
- }
3083
-
3084
- if (subcommand === 'start') {
3085
- refreshKandownHtml(kandownDir);
3086
- const status = await startDaemon(kandownDir, preferredPort);
3087
- if (!status.running || !status.metadata) {
3088
- err('Daemon failed to start');
3089
- process.exit(1);
3090
- }
3091
- success(`Daemon running: ${status.metadata.url}`);
3092
- return;
3093
- }
3094
-
3095
- if (subcommand === 'stop') {
3096
- const stopped = await stopDaemon(kandownDir);
3097
- if (stopped) success('Daemon stopped');
3098
- else info('Daemon already stopped');
3099
- return;
3100
- }
3101
-
3102
- if (subcommand === 'status') {
3103
- const status = await getDaemonStatus(kandownDir);
3104
- if (status.running && status.metadata) {
3105
- success(`Daemon ON ${status.metadata.url} PID ${status.metadata.pid}`);
3106
- } else {
3107
- info('Daemon OFF');
3108
- }
3109
- return;
3110
- }
3111
-
3112
- err(`Unknown daemon command: ${subcommand}`);
3113
- log(` Use ${c.cyan}kandown daemon start|stop|status|refresh-all${c.reset}`);
3114
- process.exit(1);
3115
- }
3116
-
3117
- /**
3118
- * 📖 Starts/reconnects the per-project web daemon, opens it in the browser,
3119
- * then hands the terminal to the board TUI. The daemon intentionally survives
3120
- * TUI exit so the web UI keeps working until the user stops it.
3121
- */
3122
- async function cmdServe(rawArgs) {
3123
- const { kandownDir } = ensureKandownDir(rawArgs);
3124
-
3125
- try {
3126
- if (refreshKandownHtml(kandownDir)) {
3127
- info(`Refreshed kandown.html (CLI v${getCurrentVersion()})`);
3128
- }
3129
- } catch (e) {
3130
- warn(`Could not refresh kandown.html: ${e.message}`);
3131
- }
3132
-
3133
- const preferredPort = parsePort(parseArgs(rawArgs).port);
3134
- const status = await startDaemon(kandownDir, preferredPort);
3135
- if (!status.running || !status.metadata) {
3136
- err('Failed to start web daemon');
3137
- process.exit(1);
3138
- }
3139
-
3140
- success(`Web daemon: ${status.metadata.url}`);
3141
- info(`Project: ${kandownDir}`);
3142
- openInBrowser(status.metadata.url);
3143
- await cmdTui('board', rawArgs);
3144
- }
3145
-
3146
- /**
3147
- * 📖 Opens a URL in the system default browser after confirming the server is ready.
3148
- * Non-blocking — spawns the opener and returns immediately.
3149
- * macOS: open, Linux: xdg-open, Windows: start (via cmd.exe).
3150
- */
3151
- async function openInBrowser(url) {
3152
- // 📖 Wait for server to be truly ready (up to 2s) before opening browser.
3153
- // This prevents ERR_UNSAFE_PORT and similar race conditions.
3154
- for (let i = 0; i < 10; i++) {
3155
- try {
3156
- const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(500) });
3157
- if (res.ok || res.status === 404) break; // server is up (404 means serving HTML)
3158
- } catch { /* server not ready yet */ }
3159
- await new Promise(r => setTimeout(r, 200));
3160
- }
3161
-
3162
- const opener = process.platform === 'darwin'
3163
- ? 'open'
3164
- : process.platform === 'win32'
3165
- ? 'cmd'
3166
- : 'xdg-open';
3167
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
3168
- const child = spawn(opener, args, { detached: true, stdio: 'ignore' });
3169
- child.on('error', (e) => warn(`Could not open browser automatically: ${e.message}`));
3170
- child.unref();
3171
- }
3172
-
3173
- // 📖 Launches the fullscreen TUI for a given screen (settings, board, etc.)
3174
- async function cmdTui(screen, rawArgs) {
3175
- const { kandownDir } = ensureKandownDir(rawArgs);
3176
- const version = getCurrentVersion();
3177
-
3178
- try {
3179
- const { run } = await import(new URL('./tui.js', import.meta.url).href);
3180
- await run(screen, kandownDir, version);
3181
- } catch (e) {
3182
- err(`Failed to launch TUI: ${e.message}`);
3183
- process.exit(1);
3184
- }
3185
- }
3186
-
3187
- const rawArgs = process.argv.slice(2).filter((a) => a !== '--no-update-check');
3188
- const [cmd, ...rest] = rawArgs;
3189
-
3190
- // 📖 Handle --version / -v before any command logic
3191
- if (cmd === '--version' || cmd === '-v') {
3192
- const v = getCurrentVersion() ?? 'unknown';
3193
- out(`kandown v${v}`);
3194
- process.exit(0);
3195
- }
3196
-
3197
- // 📖 Skip auto-update if this is a respawned child after an update.
3198
- // The parent passes --no-update-check to prevent an infinite update loop.
3199
- const skipUpdate = process.argv.slice(2).includes('--no-update-check');
3200
-
3201
- // 📖 Update policy (M1): the check only runs for INTERACTIVE commands.
3202
- // The one-shot task commands (scripts/agents) and `daemon` (spawned
3203
- // children, status probes) must never pay a network round-trip nor risk a
3204
- // mid-pipeline respawn. Inside checkForUpdate there are further guards: 24h
3205
- // throttle, TTY-only, and the KANDOWN_NO_UPDATE=1 opt-out.
3206
- async function cmdDoctor(rawArgs) {
3207
- const { kandownDir } = ensureKandownDir(rawArgs);
3208
- const fix = rawArgs.includes('--fix');
3209
- log(`${c.bold}kandown doctor${c.reset} ${c.dim}— environment & board diagnostic${c.reset}\n`);
3210
-
3211
- let errors = 0;
3212
- let warnings = 0;
3213
-
3214
- const cliVer = getCurrentVersion();
3215
- log(` ${c.cyan}CLI Version:${c.reset} ${cliVer}`);
3216
-
3217
- const configPath = join(kandownDir, 'kandown.json');
3218
- if (existsSync(configPath)) {
3219
- try {
3220
- JSON.parse(readFileSync(configPath, 'utf8'));
3221
- log(` ${c.green}✓${c.reset} kandown.json valid`);
3222
- } catch (e) {
3223
- log(` ${c.red}✗${c.reset} kandown.json invalid JSON: ${e.message}`);
3224
- errors++;
3225
- }
3226
- } else {
3227
- log(` ${c.red}✗${c.reset} kandown.json missing`);
3228
- errors++;
3229
- }
3230
-
3231
- const daemon = readDaemonMetadata(kandownDir);
3232
- if (daemon) {
3233
- const alive = isProcessAlive(daemon.pid);
3234
- if (alive) {
3235
- log(` ${c.green}✓${c.reset} Daemon running on port ${daemon.port} (PID ${daemon.pid})`);
3236
- } else {
3237
- log(` ${c.yellow}⚠${c.reset} Daemon metadata stale (PID ${daemon.pid} dead)`);
3238
- warnings++;
3239
- if (fix) {
3240
- removeDaemonMetadata(kandownDir);
3241
- log(` ${c.green}└─ Fixed: removed stale daemon.json${c.reset}`);
3242
- }
3243
- }
3244
- } else {
3245
- log(` ${c.dim}ℹ Daemon not running${c.reset}`);
3246
- }
3247
-
3248
- const tasksDir = getTasksDir(kandownDir);
3249
- if (existsSync(tasksDir)) {
3250
- const activeFiles = readdirSync(tasksDir).filter(f => f.endsWith('.md'));
3251
- const archiveDir = join(tasksDir, 'archive');
3252
- const archiveFiles = existsSync(archiveDir) ? readdirSync(archiveDir).filter(f => f.endsWith('.md')) : [];
3253
-
3254
- log(` ${c.cyan}Tasks:${c.reset} ${activeFiles.length} active, ${archiveFiles.length} archived`);
3255
-
3256
- const activeSet = new Set(activeFiles);
3257
- const duplicates = archiveFiles.filter(f => activeSet.has(f));
3258
- if (duplicates.length > 0) {
3259
- log(` ${c.red}✗${c.reset} Found ${duplicates.length} duplicate file(s) in tasks/ and archive/: ${duplicates.join(', ')}`);
3260
- errors++;
3261
- if (fix) {
3262
- for (const dup of duplicates) {
3263
- unlinkSync(join(archiveDir, dup));
3264
- }
3265
- log(` ${c.green}└─ Fixed: removed duplicate archived files${c.reset}`);
3266
- }
3267
- } else {
3268
- log(` ${c.green}✓${c.reset} No duplicate task files`);
3269
- }
3270
-
3271
- let invalidFm = 0;
3272
- for (const f of activeFiles) {
3273
- try {
3274
- const content = readFileSync(join(tasksDir, f), 'utf8');
3275
- parseFrontmatter(content);
3276
- } catch {
3277
- invalidFm++;
3278
- }
3279
- }
3280
- if (invalidFm > 0) {
3281
- log(` ${c.yellow}⚠${c.reset} ${invalidFm} task file(s) have invalid frontmatter formatting`);
3282
- warnings++;
3283
- } else {
3284
- log(` ${c.green}✓${c.reset} Task frontmatters valid`);
3285
- }
3286
- }
3287
-
3288
- log('');
3289
- if (errors === 0 && warnings === 0) {
3290
- success('Everything looks good!');
3291
- } else {
3292
- info(`Doctor summary: ${errors} error(s), ${warnings} warning(s). ${fix ? '' : 'Run with --fix to resolve automatically.'}`);
3293
- }
3294
- }
3295
-
3296
- function cmdUndo(rawArgs) {
3297
- const { kandownDir } = ensureKandownDir(rawArgs);
3298
- const logPath = join(kandownDir, '.undo', 'log.json');
3299
- if (!existsSync(logPath)) {
3300
- info('No actions to undo');
3301
- return;
3302
- }
3303
- try {
3304
- const list = JSON.parse(readFileSync(logPath, 'utf8'));
3305
- if (!list || list.length === 0) {
3306
- info('No actions to undo');
3307
- return;
3308
- }
3309
- const record = list.shift();
3310
- writeFileSync(logPath, JSON.stringify(list, null, 2), 'utf8');
3311
- if (record.previousContent === null) {
3312
- if (existsSync(record.path)) unlinkSync(record.path);
3313
- } else {
3314
- const dir = dirname(record.path);
3315
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
3316
- writeFileSync(record.path, record.previousContent, 'utf8');
3317
- if (record.newContent !== null && record.path.includes('/archive/')) {
3318
- const activePath = record.path.replace('/archive/', '/');
3319
- if (existsSync(activePath)) unlinkSync(activePath);
3320
- }
3321
- }
3322
- success(`Undid last action (${record.type} ${record.taskId})`);
3323
- } catch (e) {
3324
- err(`Undo failed: ${e.message}`);
3325
- }
3326
- }
3327
-
3328
- async function cmdProjects(rawArgs) {
3329
- const isJson = rawArgs.includes('--json');
3330
- const running = [];
3331
-
3332
- for (let port = START_PORT_RANGE; port <= END_PORT_RANGE; port++) {
3333
- if (isBrowserUnsafePort(port)) continue;
3334
- try {
3335
- const controller = new AbortController();
3336
- const timeout = setTimeout(() => controller.abort(), 200);
3337
- const res = await fetch(`http://127.0.0.1:${port}/api/daemon`, { signal: controller.signal });
3338
- clearTimeout(timeout);
3339
- if (res.ok) {
3340
- const data = await res.json();
3341
- if (data && data.ok) {
3342
- running.push({ port, pid: data.pid, kandownDir: data.kandownDir, startedAt: data.startedAt, version: data.version });
3343
- }
3344
- }
3345
- } catch {}
3346
- }
3347
-
3348
- if (isJson) {
3349
- out(JSON.stringify(running, null, 2));
3350
- return;
3351
- }
3352
-
3353
- if (running.length === 0) {
3354
- info('No active kandown web daemons running on this machine');
3355
- return;
3356
- }
3357
-
3358
- log(`${c.bold}Active Kandown Daemons (${running.length})${c.reset}\n`);
3359
- for (const d of running) {
3360
- log(` ${c.green}●${c.reset} Port ${c.cyan}${d.port}${c.reset} PID ${d.pid} ${c.dim}${d.kandownDir}${c.reset}`);
3361
- }
3362
- }
3363
-
3364
- function cmdExport(rawArgs) {
3365
- const { kandownDir } = ensureKandownDir(rawArgs);
3366
- const isCsv = rawArgs.includes('--csv');
3367
- const board = readBoard(kandownDir);
3368
-
3369
- if (isCsv) {
3370
- let csv = 'id,title,status,priority,assignee,tags,created\n';
3371
- for (const col of board.columns) {
3372
- for (const t of col.tasks) {
3373
- const task = readTask(kandownDir, t.id);
3374
- const tags = (t.tags || []).join(';');
3375
- csv += `"${t.id}","${t.title.replace(/"/g, '""')}","${col.name}","${t.priority || ''}","${t.assignee || ''}","${tags}","${task.frontmatter.created || ''}"\n`;
3376
- }
3377
- }
3378
- out(csv);
3379
- } else {
3380
- const data = [];
3381
- for (const col of board.columns) {
3382
- for (const t of col.tasks) {
3383
- const task = readTask(kandownDir, t.id);
3384
- data.push({ ...task, status: col.name });
3385
- }
3386
- }
3387
- out(JSON.stringify(data, null, 2));
3388
- }
3389
- }
3390
-
3391
- function cmdImport(rawArgs) {
3392
- const { kandownDir } = ensureKandownDir(rawArgs);
3393
- const fileIdx = rawArgs.findIndex(a => a.endsWith('.json') || a.endsWith('.md'));
3394
- if (fileIdx === -1 || !existsSync(rawArgs[fileIdx])) {
3395
- err('Usage: kandown import <file.json | file.md>');
3396
- process.exit(1);
3397
- }
3398
- const filePath = rawArgs[fileIdx];
3399
- const content = readFileSync(filePath, 'utf8');
3400
- let count = 0;
3401
-
3402
- if (filePath.endsWith('.json')) {
3403
- try {
3404
- const parsed = JSON.parse(content);
3405
- const cards = Array.isArray(parsed) ? parsed : (parsed.cards || []);
3406
- for (const c of cards) {
3407
- const title = c.name || c.title || 'Imported Task';
3408
- createTaskInBoard(kandownDir, title, c.status || 'Backlog');
3409
- count++;
3410
- }
3411
- } catch (e) {
3412
- err(`Import failed: ${e.message}`);
3413
- process.exit(1);
3414
- }
3415
- } else {
3416
- const lines = content.split('\n');
3417
- for (const line of lines) {
3418
- const m = line.match(/^#{1,3}\s+(.+)$/);
3419
- if (m) {
3420
- createTaskInBoard(kandownDir, m[1].trim(), 'Backlog');
3421
- count++;
3422
- }
3423
- }
3424
- }
3425
-
3426
- success(`Imported ${count} tasks into board`);
3427
- }
3428
-
3429
- const SCRIPTED_COMMANDS = new Set([
3430
- 'list', 'ls', 'show', 'create', 'new', 'move', 'assign', 'commit', 'tasks', 'work', 'daemon',
3431
- 'doctor', 'undo', 'projects', 'export', 'import',
3432
- ]);
3433
- if (!skipUpdate && !SCRIPTED_COMMANDS.has(cmd)) await checkForUpdate(process.argv);
3434
-
3435
- if (!SCRIPTED_COMMANDS.has(cmd)) checkVersionSeenNotices();
3436
-
3437
- switch (cmd) {
3438
- case 'export':
3439
- cmdExport(rest);
3440
- break;
3441
-
3442
- case 'import':
3443
- cmdImport(rest);
3444
- break;
3445
- case 'init':
3446
- cmdInit(rest);
3447
- break;
3448
-
3449
- case 'doctor':
3450
- await cmdDoctor(rest);
3451
- break;
3452
-
3453
- case 'undo':
3454
- cmdUndo(rest);
3455
- break;
3456
-
3457
- case 'mcp': {
3458
- const { kandownDir } = ensureKandownDir(rest);
3459
- const { startMcpServer } = await import('../src/cli/lib/mcp.js');
3460
- startMcpServer(kandownDir);
3461
- break;
3462
- }
3463
-
3464
- case 'projects':
3465
- await cmdProjects(rest);
3466
- break;
3467
-
3468
- case 'board':
3469
- // 📖 kandown board — open the interactive kanban board TUI only
3470
- await cmdTui('board', rest);
3471
- break;
3472
-
3473
- case 'settings':
3474
- await cmdTui('settings', rest);
3475
- break;
3476
-
3477
- case 'daemon':
3478
- await cmdDaemon(rest);
3479
- break;
3480
-
3481
- case 'update':
3482
- await cmdUpdate(rest);
3483
- break;
3484
-
3485
- // 📖 One-shot task commands — top-level, no "shell" wrapper. These are the
3486
- // most basic operations of the product (list/read/create/move/assign a
3487
- // task, commit the board to git); nesting them under a prefix only added
3488
- // friction for scripts and AI agents, which is exactly who these are for.
3489
- case 'list':
3490
- case 'ls':
3491
- cmdList(rest);
3492
- break;
3493
-
3494
- case 'show':
3495
- cmdShow(rest);
3496
- break;
3497
-
3498
- case 'create':
3499
- case 'new':
3500
- cmdCreate(rest);
3501
- break;
3502
-
3503
- case 'move':
3504
- cmdMove(rest);
3505
- break;
3506
-
3507
- case 'assign':
3508
- cmdAssign(rest);
3509
- break;
3510
-
3511
- case 'commit':
3512
- cmdCommit(rest);
3513
- break;
3514
-
3515
- case 'tasks':
3516
- // 📖 `kandown tasks` — cheatsheet for the commands above (`list help`
3517
- // etc. would collide with real usage, so the index lives on its own verb).
3518
- printTaskCommandsHelp();
3519
- break;
3520
-
3521
- case 'work':
3522
- // 📖 `kandown work` — the agent entrypoint (rules + live board digest).
3523
- // See cmdWork's doc comment for the full rationale.
3524
- await cmdWork(rest);
3525
- break;
3526
-
3527
- case 'help':
3528
- case '--help':
3529
- case '-h':
3530
- help();
3531
- break;
3532
-
3533
- case undefined:
3534
- // 📖 kandown (no args) — serve the web UI over localhost and open the board TUI.
3535
- await cmdServe(rest);
3536
- break;
3537
-
3538
- default:
3539
- if (cmd.startsWith('-')) {
3540
- await cmdServe([cmd, ...rest]);
3541
- break;
3542
- }
3543
- err(`Unknown command: ${cmd}`);
3544
- help();
3545
- process.exit(1);
3546
- }
3547
-
3548
- // 📖 Some bundled/imported dependencies can leave passive handles alive even
3549
- // after a one-shot CLI command has finished its synchronous work. Scripted
3550
- // commands must be pipeline-safe, so terminate explicitly once their handler
3551
- // returns. The daemon runner, MCP server, and interactive TUI paths are the
3552
- // long-lived exceptions.
3553
- if (SCRIPTED_COMMANDS.has(cmd) && !(cmd === 'daemon' && rest[0] === 'run')) {
3554
- process.exit(0);
3555
- }
1080
+ void main();