@polderlabs/openkan 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/LICENSE +21 -0
  3. package/README.md +318 -0
  4. package/agents/openkan.md +254 -0
  5. package/bin/install-agent.mjs +63 -0
  6. package/bin/ok.mjs +17 -0
  7. package/bin/openkan.mjs +10 -0
  8. package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
  9. package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
  10. package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
  11. package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
  12. package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
  13. package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
  14. package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
  15. package/dist/agents/openkan.md +254 -0
  16. package/dist/bin/install-agent.mjs +76 -0
  17. package/dist/bin/ok-install.js +58 -0
  18. package/dist/bin/ok.js +138 -0
  19. package/dist/bin/openkan.js +804 -0
  20. package/dist/commands/organize.md +15 -0
  21. package/dist/kanban/agent-profile.js +8 -0
  22. package/dist/kanban/archive.js +49 -0
  23. package/dist/kanban/bizar.js +242 -0
  24. package/dist/kanban/board.js +367 -0
  25. package/dist/kanban/bulk.js +139 -0
  26. package/dist/kanban/changelog.js +186 -0
  27. package/dist/kanban/chat.js +1280 -0
  28. package/dist/kanban/claude-state.js +974 -0
  29. package/dist/kanban/comments.js +80 -0
  30. package/dist/kanban/docs.js +144 -0
  31. package/dist/kanban/fs.js +163 -0
  32. package/dist/kanban/git.js +196 -0
  33. package/dist/kanban/images.js +140 -0
  34. package/dist/kanban/import.js +295 -0
  35. package/dist/kanban/inputs.js +94 -0
  36. package/dist/kanban/insights.js +140 -0
  37. package/dist/kanban/io.js +75 -0
  38. package/dist/kanban/mdx-render.js +348 -0
  39. package/dist/kanban/mdx.js +231 -0
  40. package/dist/kanban/projects.js +545 -0
  41. package/dist/kanban/search.js +121 -0
  42. package/dist/kanban/server.js +3296 -0
  43. package/dist/kanban/tags.js +124 -0
  44. package/dist/kanban/template.js +145 -0
  45. package/dist/kanban/tsx-sandbox.js +187 -0
  46. package/dist/kanban/watcher.js +270 -0
  47. package/dist/ok/commands/goal.js +65 -0
  48. package/dist/ok/commands/index.js +87 -0
  49. package/dist/ok/commands/init.js +15 -0
  50. package/dist/ok/commands/plan.js +155 -0
  51. package/dist/ok/commands/prd.js +202 -0
  52. package/dist/ok/commands/progress.js +31 -0
  53. package/dist/ok/commands/task.js +377 -0
  54. package/dist/ok/ids.js +98 -0
  55. package/dist/ok/lock.js +156 -0
  56. package/dist/ok/migrate.js +197 -0
  57. package/dist/ok/schemas.js +402 -0
  58. package/dist/ok/storage.js +222 -0
  59. package/dist/skills/openkan/SKILL.md +111 -0
  60. package/dist/skills/openkan/agents/openai.yaml +4 -0
  61. package/dist/skills/openkan/examples/simple-task.mdx +34 -0
  62. package/dist/skills/openkan/examples/with-ask.mdx +32 -0
  63. package/dist/skills/openkan/examples/with-choice.mdx +51 -0
  64. package/dist/skills/openkan/examples/with-preview.mdx +54 -0
  65. package/dist/skills/openkan/references/api.md +169 -0
  66. package/dist/skills/openkan/templates/task.mdx +46 -0
  67. package/dist/web/api.js +257 -0
  68. package/dist/web/app.js +4251 -0
  69. package/dist/web/bizar.js +39 -0
  70. package/dist/web/brand/agent-activity-sprite.svg +1 -0
  71. package/dist/web/brand/banner-docs.svg +24 -0
  72. package/dist/web/brand/banner.svg +32 -0
  73. package/dist/web/brand/empty-sessions.svg +17 -0
  74. package/dist/web/brand/empty-tasks.svg +17 -0
  75. package/dist/web/brand/favicon.svg +9 -0
  76. package/dist/web/brand/infinity-loader-animated.svg +220 -0
  77. package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
  78. package/dist/web/brand/logo-wordmark.svg +10 -0
  79. package/dist/web/brand/logo.svg +9 -0
  80. package/dist/web/brand/pixel-infinity-track.svg +1 -0
  81. package/dist/web/brand/social-card.svg +26 -0
  82. package/dist/web/changelog-view.js +456 -0
  83. package/dist/web/charts.js +269 -0
  84. package/dist/web/chat-sidebar.js +2397 -0
  85. package/dist/web/chat-status-motion.js +154 -0
  86. package/dist/web/claude-pane.js +820 -0
  87. package/dist/web/command-palette.js +381 -0
  88. package/dist/web/contributors-view.js +317 -0
  89. package/dist/web/cross-tab.js +102 -0
  90. package/dist/web/docs-view.js +168 -0
  91. package/dist/web/experience.css +165 -0
  92. package/dist/web/goals-view.js +45 -0
  93. package/dist/web/home-view.js +113 -0
  94. package/dist/web/images.js +311 -0
  95. package/dist/web/index.html +485 -0
  96. package/dist/web/insights.js +217 -0
  97. package/dist/web/keyboard.js +446 -0
  98. package/dist/web/mdx-viewer.js +600 -0
  99. package/dist/web/path-picker.js +787 -0
  100. package/dist/web/preview-frame.html +187 -0
  101. package/dist/web/settings.js +582 -0
  102. package/dist/web/style.css +8545 -0
  103. package/dist/web/task-view.js +1759 -0
  104. package/dist/web/vendor/gsap.min.js +11 -0
  105. package/dist/web/workspace.css +1513 -0
  106. package/package.json +71 -0
  107. package/skills/openkan/SKILL.md +111 -0
  108. package/skills/openkan/agents/openai.yaml +4 -0
  109. package/skills/openkan/examples/simple-task.mdx +34 -0
  110. package/skills/openkan/examples/with-ask.mdx +32 -0
  111. package/skills/openkan/examples/with-choice.mdx +51 -0
  112. package/skills/openkan/examples/with-preview.mdx +54 -0
  113. package/skills/openkan/references/api.md +169 -0
  114. package/skills/openkan/templates/task.mdx +46 -0
@@ -0,0 +1,804 @@
1
+ #!/usr/bin/env node
2
+ // OpenKan — standalone CLI entrypoint.
3
+ import { existsSync, readFileSync, writeFileSync, rmSync, appendFileSync, statSync } from "node:fs";
4
+ import { join, dirname, resolve, basename } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawn } from "node:child_process";
7
+ import { startOrAttach } from "../kanban/server.js";
8
+ import { addProject, setActiveProject } from "../kanban/projects.js";
9
+ import { initBoard, getBoard, setProjectRoot } from "../kanban/board.js";
10
+ // Resolve the openkan repo's web/ folder so the static UI is served no matter
11
+ // where the user invokes the CLI from. `import.meta.url` → bin/openkan.ts →
12
+ // `../web` is the bundled UI.
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+ const OPENKAN_ROOT = resolve(__dirname, "..");
16
+ const OPENKAN_WEB = join(OPENKAN_ROOT, "web");
17
+ import { removeDir, ensureDir } from "../kanban/io.js";
18
+ import { runImport } from "../kanban/import.js";
19
+ import { main as runPlanning } from "./ok.js";
20
+ import { cpSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { installAgent } from "./install-agent.mjs";
23
+ const DEFAULT_CONFIG = {
24
+ port: 7777,
25
+ host: "127.0.0.1",
26
+ defaultAgent: "",
27
+ defaultModel: null,
28
+ import: { include: [], exclude: [] },
29
+ sandbox: { tsxMaxBytes: 32768 },
30
+ };
31
+ // Agent-facing REST capability map. `openkan api` exposes this entire surface
32
+ // without requiring a different shell script for each dashboard feature.
33
+ const AGENT_CAPABILITIES = Object.freeze({
34
+ board: ["GET /api/board", "GET /api/tasks-index", "GET /api/tasks/:id", "POST /api/tasks", "PATCH /api/tasks/:id", "DELETE /api/tasks/:id", "POST /api/tasks/bulk", "POST /api/organize", "POST /api/import"],
35
+ taskContext: ["GET|POST /api/tasks/:id/comments", "POST /api/tasks/:id/ask", "POST /api/tasks/:id/respond", "GET /api/tasks/:id/subtasks", "GET|POST /api/tasks/:id/images", "POST /api/tasks/:id/start", "POST /api/tasks/:id/abort"],
36
+ planning: ["openkan task|plan|prd|goal …", "openkan progress --json", "openkan doctor", "ok task|plan|prd|goal …", "GET /api/goals", "PATCH /api/goals/:prdId/:goalId"],
37
+ docs: ["GET /api/docs", "GET|PUT|DELETE /api/docs/:path", "POST /api/docs/render", "POST /api/docs/generate"],
38
+ chat: ["POST /api/chat/send", "GET /api/chat/sessions", "GET /api/chat/sessions/:id", "POST /api/chat/sessions/:id/abort"],
39
+ agents: ["GET /api/claude/snapshot", "GET /api/claude/agents|skills|commands|hooks|teams|workflows", "GET /api/claude/activity", "GET /api/claude/model-router"],
40
+ projects: ["GET|POST /api/projects", "PATCH /api/projects/:id/active", "POST /api/projects/auto-detect", "DELETE /api/projects/:id"],
41
+ insight: ["GET /api/search", "GET /api/tags", "GET /api/changelog", "GET /api/changelog/summary", "GET /api/insights/velocity", "GET /api/contributors"],
42
+ config: ["GET|PATCH /api/settings", "GET /api/config-sections", "PATCH /api/config-sections/:sectionId", "openkan config list|get|set"],
43
+ });
44
+ function configPath() {
45
+ return join(process.cwd(), ".ok", "openkan.json");
46
+ }
47
+ function loadConfig() {
48
+ const p = configPath();
49
+ if (!existsSync(p))
50
+ return { ...DEFAULT_CONFIG };
51
+ try {
52
+ return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(p, "utf-8")) };
53
+ }
54
+ catch {
55
+ return { ...DEFAULT_CONFIG };
56
+ }
57
+ }
58
+ function saveConfig(cfg) {
59
+ ensureDir(join(process.cwd(), ".ok"));
60
+ writeFileSync(configPath(), JSON.stringify(cfg, null, 2), "utf-8");
61
+ }
62
+ function parseArgs(argv) {
63
+ const cmd = argv[0] ?? "";
64
+ const positionals = [];
65
+ const flags = {};
66
+ let i = 1;
67
+ while (i < argv.length) {
68
+ const arg = argv[i];
69
+ if (!arg.startsWith("-")) {
70
+ positionals.push(arg);
71
+ i++;
72
+ continue;
73
+ }
74
+ // Flag: --flag or --flag=value or --flag value
75
+ const flagMatch = arg.match(/^--([^=]+)(=(.*))?$/);
76
+ if (!flagMatch) {
77
+ i++;
78
+ continue;
79
+ }
80
+ const key = flagMatch[1];
81
+ if (flagMatch[2] !== undefined) {
82
+ flags[key] = flagMatch[3];
83
+ }
84
+ else {
85
+ const next = argv[i + 1];
86
+ if (next !== undefined && !next.startsWith("-")) {
87
+ flags[key] = next;
88
+ i++;
89
+ }
90
+ else {
91
+ flags[key] = true;
92
+ }
93
+ }
94
+ i++;
95
+ }
96
+ return { cmd, positionals, flags };
97
+ }
98
+ // ─── Subcommand: init ─────────────────────────────────────────────────────────
99
+ async function cmdInit() {
100
+ const dir = join(process.cwd(), ".ok");
101
+ ensureDir(dir);
102
+ const boardFile = join(dir, "board.json");
103
+ if (!existsSync(boardFile)) {
104
+ writeFileSync(boardFile, JSON.stringify({ version: 1, columns: [{ id: "backlog", title: "Backlog" }, { id: "todo", title: "To Do" }, { id: "doing", title: "In Progress" }, { id: "review", title: "Review" }, { id: "done", title: "Done" }], tasks: [], sessions: {} }, null, 2), "utf-8");
105
+ }
106
+ const tasksIndexFile = join(dir, "tasks.json");
107
+ if (!existsSync(tasksIndexFile)) {
108
+ writeFileSync(tasksIndexFile, JSON.stringify({ tasks: [] }, null, 2), "utf-8");
109
+ }
110
+ const cfg = configPath();
111
+ if (!existsSync(cfg)) {
112
+ saveConfig(DEFAULT_CONFIG);
113
+ }
114
+ console.log("Initialized .ok/ directory.");
115
+ }
116
+ // ─── Subcommand: import ───────────────────────────────────────────────────────
117
+ async function cmdImport(ctx, argv) {
118
+ // parseArgs treats argv[0] as the command name, so prefix before parsing
119
+ // or our flags end up stored as `cmd` instead of `flags`.
120
+ const args = parseArgs(["import", ...argv]);
121
+ const pathFlag = args.flags["path"];
122
+ const includeFlag = args.flags["include"];
123
+ const excludeFlag = args.flags["exclude"];
124
+ // Surface typos in flag names — npm/wget behaviour. The import surface is
125
+ // small and stable: only --path, --include, --exclude.
126
+ const KNOWN_IMPORT_FLAGS = new Set(["path", "include", "exclude"]);
127
+ for (const flag of Object.keys(args.flags)) {
128
+ if (!KNOWN_IMPORT_FLAGS.has(flag)) {
129
+ console.warn(`openkan import: warning: unknown flag --${flag} (known: ${[...KNOWN_IMPORT_FLAGS].map(f => `--${f}`).join(", ")})`);
130
+ }
131
+ }
132
+ // ctx.directory must be set
133
+ if (!ctx.directory) {
134
+ console.error("openkan import: no project directory set — run 'openkan start' first or set --path");
135
+ process.exit(1);
136
+ }
137
+ const targetDir = pathFlag ?? ctx.directory;
138
+ const importCtx = { ...ctx, directory: targetDir };
139
+ const include = includeFlag ? includeFlag.split(",").map((s) => s.trim()) : undefined;
140
+ const exclude = excludeFlag ? excludeFlag.split(",").map((s) => s.trim()) : undefined;
141
+ const result = await runImport(importCtx, { include, exclude });
142
+ if (result.imported.length === 0) {
143
+ console.log("No unchecked checkboxes found.");
144
+ return;
145
+ }
146
+ console.log(`imported ${result.imported.length} tasks`);
147
+ const board = await getBoard();
148
+ for (const id of result.imported) {
149
+ const task = board.tasks.find((t) => t.id === id);
150
+ if (task && task.source) {
151
+ console.log(` created ${id} at ${task.source.path}:${task.source.line}`);
152
+ }
153
+ else {
154
+ console.log(` created ${id}`);
155
+ }
156
+ }
157
+ }
158
+ // ─── Subcommand: start ───────────────────────────────────────────────────────
159
+ async function cmdStart(ctx, argv) {
160
+ const args = parseArgs(argv);
161
+ const host = args.flags["host"] ?? loadConfig().host;
162
+ const port = parseInt(args.flags["port"] ?? String(loadConfig().port), 10);
163
+ const noOpen = args.flags["no-open"] === true || args.flags["no-open"] === "true";
164
+ const foreground = args.flags["foreground"] === true || args.flags["foreground"] === "true";
165
+ const noAutoDetect = args.flags["no-auto-detect"] === true || args.flags["no-auto-detect"] === "true";
166
+ // --project flag: switch the active project before starting
167
+ const projectFlag = args.flags["project"];
168
+ if (projectFlag) {
169
+ const projectRoot = projectFlag;
170
+ const id = basename(projectRoot).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
171
+ const entry = addProject({ id, name: basename(projectRoot), root: projectRoot });
172
+ setActiveProject(entry.id);
173
+ ctx.directory = projectRoot;
174
+ setProjectRoot(projectRoot);
175
+ }
176
+ // Init board if not already
177
+ await initBoard(ctx);
178
+ const result = await startOrAttach(ctx, { host, port, webRoot: OPENKAN_WEB, _autoDetect: !noAutoDetect });
179
+ if (foreground) {
180
+ console.log(`OpenKan server running at ${result.url} (pid=${result.pid})`);
181
+ // Keep process alive
182
+ await new Promise(() => { });
183
+ }
184
+ else {
185
+ // Write PID and log files. Format: "pid:port" so status can read both
186
+ // without re-probing. The port may differ from the config if the
187
+ // configured port was busy.
188
+ const pidFile = join(ctx.directory, ".ok", "server.pid");
189
+ writeFileSync(pidFile, `${result.pid}:${result.port}`, "utf-8");
190
+ const logFile = join(ctx.directory, ".ok", "server.log");
191
+ const logStream = appendFileSync ? appendFileSync : (() => { });
192
+ console.log(`OpenKan server at ${result.url} (pid=${result.pid})`);
193
+ if (!noOpen) {
194
+ openUrl(result.url);
195
+ }
196
+ }
197
+ }
198
+ // ─── Subcommand: stop ─────────────────────────────────────────────────────────
199
+ async function cmdStop(ctx) {
200
+ const pidFile = join(ctx.directory, ".ok", "server.pid");
201
+ if (!existsSync(pidFile)) {
202
+ console.error("No server.pid found — is the server running?");
203
+ process.exit(1);
204
+ }
205
+ const pid = parseInt(readFileSync(pidFile, "utf-8").trim(), 10);
206
+ if (isNaN(pid)) {
207
+ console.error("Invalid PID in server.pid");
208
+ process.exit(1);
209
+ }
210
+ try {
211
+ process.kill(pid, "SIGTERM");
212
+ }
213
+ catch {
214
+ // PID may already be dead
215
+ }
216
+ // Wait up to 5s for graceful shutdown
217
+ let waited = 0;
218
+ while (waited < 5000) {
219
+ try {
220
+ process.kill(pid, 0);
221
+ await new Promise(r => setTimeout(r, 200));
222
+ waited += 200;
223
+ }
224
+ catch {
225
+ break;
226
+ }
227
+ }
228
+ if (waited >= 5000) {
229
+ try {
230
+ process.kill(pid, "SIGKILL");
231
+ }
232
+ catch { /* ignore */ }
233
+ }
234
+ try {
235
+ rmSync(pidFile);
236
+ }
237
+ catch { /* ignore */ }
238
+ console.log("Server stopped.");
239
+ }
240
+ // ─── Subcommand: status ───────────────────────────────────────────────────────
241
+ async function cmdStatus(ctx) {
242
+ const pidFile = join(ctx.directory, ".ok", "server.pid");
243
+ if (!existsSync(pidFile)) {
244
+ console.log("status: stopped");
245
+ return;
246
+ }
247
+ const raw = readFileSync(pidFile, "utf-8").trim();
248
+ // Format: "pid:port" (new) or "pid" (legacy)
249
+ const [pidStr, portStr] = raw.split(":");
250
+ const pid = parseInt(pidStr, 10);
251
+ if (isNaN(pid)) {
252
+ console.log("status: stopped (invalid PID)");
253
+ return;
254
+ }
255
+ let alive = false;
256
+ try {
257
+ process.kill(pid, 0);
258
+ alive = true;
259
+ }
260
+ catch {
261
+ alive = false;
262
+ }
263
+ if (!alive) {
264
+ console.log("status: stopped");
265
+ return;
266
+ }
267
+ // Port from the pid file if present, else fall back to config
268
+ const cfg = loadConfig();
269
+ const port = portStr ? parseInt(portStr, 10) : cfg.port;
270
+ const host = cfg.host;
271
+ const uptimeMs = Date.now() - (() => {
272
+ try {
273
+ const st = require("node:fs").statSync(pidFile);
274
+ return st.mtimeMs;
275
+ }
276
+ catch {
277
+ return Date.now();
278
+ }
279
+ })();
280
+ const uptimeSec = Math.floor(uptimeMs / 1000);
281
+ console.log(`status: running`);
282
+ console.log(`pid: ${pid}`);
283
+ console.log(`port: ${port}`);
284
+ console.log(`host: ${host}`);
285
+ console.log(`uptime: ${uptimeSec}s`);
286
+ }
287
+ // ─── Subcommand: open ─────────────────────────────────────────────────────────
288
+ async function cmdOpen(ctx) {
289
+ // Mirror cmdStatus: refuse to open the browser when no server is up so the
290
+ // user gets a clear error instead of staring at a blank tab.
291
+ const pidFile = join(ctx.directory, ".ok", "server.pid");
292
+ if (!existsSync(pidFile)) {
293
+ console.error("No server.pid found — is the server running? Start it with `openkan start`.");
294
+ process.exit(1);
295
+ }
296
+ const raw = readFileSync(pidFile, "utf-8").trim();
297
+ const [pidStr, portStr] = raw.split(":");
298
+ const pid = parseInt(pidStr, 10);
299
+ if (isNaN(pid)) {
300
+ console.error("Invalid PID in server.pid");
301
+ process.exit(1);
302
+ }
303
+ let alive = false;
304
+ try {
305
+ process.kill(pid, 0);
306
+ alive = true;
307
+ }
308
+ catch {
309
+ alive = false;
310
+ }
311
+ if (!alive) {
312
+ console.error("Server is not running (stale PID). Start it with `openkan start`.");
313
+ process.exit(1);
314
+ }
315
+ const cfg = loadConfig();
316
+ const port = portStr ? parseInt(portStr, 10) : cfg.port;
317
+ const url = `http://${cfg.host}:${port}/`;
318
+ openUrl(url);
319
+ }
320
+ // ─── Subcommand: config ───────────────────────────────────────────────────────
321
+ async function cmdConfig(argv) {
322
+ const sub = argv[0] ?? "";
323
+ const cfg = loadConfig();
324
+ if (sub === "list") {
325
+ console.log(JSON.stringify(cfg, null, 2));
326
+ return;
327
+ }
328
+ if (sub === "get") {
329
+ const key = argv[1];
330
+ if (!key) {
331
+ console.error("Usage: config get <key>");
332
+ process.exit(1);
333
+ }
334
+ const val = key.split(".").reduce((obj, k) => obj?.[k], cfg);
335
+ console.log(typeof val === "object" ? JSON.stringify(val) : String(val ?? ""));
336
+ return;
337
+ }
338
+ if (sub === "set") {
339
+ const key = argv[1];
340
+ const value = argv[2];
341
+ if (!key || value === undefined) {
342
+ console.error("Usage: config set <key> <value>");
343
+ process.exit(1);
344
+ }
345
+ // Parse value as JSON if possible, else string
346
+ let parsed;
347
+ try {
348
+ parsed = JSON.parse(value);
349
+ }
350
+ catch {
351
+ parsed = value;
352
+ }
353
+ const keys = key.split(".");
354
+ const last = keys.pop();
355
+ const target = keys.reduce((obj, k) => { if (!(k in obj))
356
+ obj[k] = {}; return obj[k]; }, cfg);
357
+ target[last] = parsed;
358
+ saveConfig(cfg);
359
+ console.log(`Set ${key} = ${JSON.stringify(parsed)}`);
360
+ return;
361
+ }
362
+ console.error("Usage: config list | config get <key> | config set <key> <value>");
363
+ process.exit(1);
364
+ }
365
+ // ─── Subcommand: logs ─────────────────────────────────────────────────────────
366
+ async function cmdLogs(argv) {
367
+ const args = parseArgs(argv);
368
+ const tail = parseInt(args.flags["tail"] ?? "50", 10);
369
+ const follow = args.flags["follow"] === true || args.flags["follow"] === "true";
370
+ const logFile = join(process.cwd(), ".ok", "server.log");
371
+ if (!existsSync(logFile)) {
372
+ console.error("No server.log found.");
373
+ process.exit(1);
374
+ }
375
+ const lines = readFileSync(logFile, "utf-8").split("\n");
376
+ const lastLines = lines.slice(-tail);
377
+ console.log(lastLines.join("\n"));
378
+ if (follow) {
379
+ // Simple tail -f using fs watch
380
+ const { watch } = await import("node:fs");
381
+ let offset = lines.length;
382
+ watch(logFile, () => {
383
+ const newLines = readFileSync(logFile, "utf-8").split("\n");
384
+ const newPart = newLines.slice(offset);
385
+ if (newPart.length) {
386
+ process.stdout.write(newPart.join("\n") + "\n");
387
+ offset = newLines.length;
388
+ }
389
+ });
390
+ // Keep alive
391
+ await new Promise(() => { });
392
+ }
393
+ }
394
+ // ─── Agent API bridge ─────────────────────────────────────────────────────────
395
+ function apiBaseUrl(args) {
396
+ const cfg = loadConfig();
397
+ const host = String(args.flags.host ?? cfg.host);
398
+ const port = Number.parseInt(String(args.flags.port ?? cfg.port), 10);
399
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
400
+ throw new Error("--port must be a valid TCP port");
401
+ if (!/^(127\.0\.0\.1|localhost|::1)$/.test(host))
402
+ throw new Error("openkan api only permits a loopback --host");
403
+ return `http://${host.includes(":") ? `[${host}]` : host}:${port}`;
404
+ }
405
+ function parseJsonInput(args) {
406
+ const raw = args.flags.data;
407
+ const file = args.flags["data-file"];
408
+ if (raw !== undefined && file !== undefined)
409
+ throw new Error("Use either --data or --data-file, not both");
410
+ const value = file !== undefined ? readFileSync(resolve(String(file)), "utf-8") : raw;
411
+ if (value === undefined || value === true)
412
+ return undefined;
413
+ try {
414
+ return JSON.parse(String(value));
415
+ }
416
+ catch {
417
+ throw new Error("--data must be valid JSON");
418
+ }
419
+ }
420
+ function printApiResult(status, statusText, body, jsonOnly) {
421
+ let rendered = body;
422
+ try {
423
+ rendered = JSON.stringify(JSON.parse(body), null, 2);
424
+ }
425
+ catch { /* keep text response */ }
426
+ if (!jsonOnly)
427
+ process.stderr.write(`openkan api: ${status} ${statusText}\n`);
428
+ process.stdout.write(`${rendered}${rendered.endsWith("\n") ? "" : "\n"}`);
429
+ }
430
+ async function cmdApi(argv) {
431
+ const args = parseArgs(["api", ...argv]);
432
+ const path = args.positionals[0];
433
+ if (!path)
434
+ throw new Error("Usage: openkan api <path> [--method GET] [--data JSON|--data-file file] [--json]");
435
+ if (!path.startsWith("/api/"))
436
+ throw new Error("API path must begin with /api/");
437
+ if (path.includes("..") || /\s/.test(path))
438
+ throw new Error("API path must be a clean relative API path");
439
+ const method = String(args.flags.method ?? (args.flags.data !== undefined || args.flags["data-file"] !== undefined ? "POST" : "GET")).toUpperCase();
440
+ if (!/^(GET|POST|PATCH|PUT|DELETE)$/.test(method))
441
+ throw new Error("--method must be GET, POST, PATCH, PUT, or DELETE");
442
+ const payload = parseJsonInput(args);
443
+ const headers = { Accept: "application/json" };
444
+ const init = { method, headers };
445
+ if (payload !== undefined) {
446
+ headers["content-type"] = "application/json";
447
+ init.body = JSON.stringify(payload);
448
+ }
449
+ const response = await fetch(`${apiBaseUrl(args)}${path}`, init);
450
+ const body = await response.text();
451
+ printApiResult(response.status, response.statusText, body, args.flags.json === true || args.flags.json === "true");
452
+ if (!response.ok)
453
+ process.exitCode = 1;
454
+ }
455
+ async function cmdBoard(argv) {
456
+ const [sub, ...rest] = argv;
457
+ const args = parseArgs(['board', ...rest]);
458
+ const [id, ...words] = args.positionals;
459
+ const transport = ['--json'];
460
+ for (const key of ['host', 'port'])
461
+ if (args.flags[key] !== undefined)
462
+ transport.push(`--${key}`, String(args.flags[key]));
463
+ let path = '/api/board';
464
+ let method = 'GET';
465
+ let data;
466
+ if (sub === 'show' && id)
467
+ path = `/api/tasks/${encodeURIComponent(id)}`;
468
+ else if (sub === 'add' && id) {
469
+ if (args.flags.column && !['backlog', 'todo', 'doing', 'review', 'done'].includes(String(args.flags.column)))
470
+ throw new Error('column must be backlog|todo|doing|review|done');
471
+ path = '/api/tasks';
472
+ method = 'POST';
473
+ data = { title: [id, ...words].join(' '), column: String(args.flags.column || 'todo') };
474
+ if (typeof args.flags.description === 'string')
475
+ data.description = args.flags.description;
476
+ }
477
+ else if (sub === 'move' && id && words.length === 1) {
478
+ if (!['backlog', 'todo', 'doing', 'review', 'done'].includes(words[0]))
479
+ throw new Error('column must be backlog|todo|doing|review|done');
480
+ path = `/api/tasks/${encodeURIComponent(id)}`;
481
+ method = 'PATCH';
482
+ data = { column: words[0] };
483
+ }
484
+ else if (sub === 'comment' && id && words.length) {
485
+ path = `/api/tasks/${encodeURIComponent(id)}/comments`;
486
+ method = 'POST';
487
+ data = { text: words.join(' '), blockId: 'progress', line: 1, author: String(args.flags.author || 'agent:openkan') };
488
+ }
489
+ else if (sub !== 'list') {
490
+ throw new Error('Usage: openkan board list | show <id> | add <title> [--column todo] | move <id> <column> | comment <id> <text> [--author agent:NAME]');
491
+ }
492
+ // The dashboard can select another repository; never silently write to it.
493
+ const response = await fetch(`${apiBaseUrl(args)}/api/project`, { signal: AbortSignal.timeout(10000) });
494
+ if (!response.ok)
495
+ throw new Error(`Cannot verify active project: HTTP ${response.status}`);
496
+ const project = await response.json();
497
+ if (project.active?.root && resolve(project.active.root) !== resolve(process.cwd())) {
498
+ throw new Error(`Dashboard is on ${project.active.root}; select this repository with openkan project use <id> before using board commands`);
499
+ }
500
+ await cmdApi([path, '--method', method, ...transport, ...(data ? ['--data', JSON.stringify(data)] : [])]);
501
+ }
502
+ async function cmdProject(argv) {
503
+ if (argv[0] === 'list')
504
+ return cmdApi(['/api/projects', '--json', ...argv.slice(1)]);
505
+ if (argv[0] === 'use' && argv[1])
506
+ return cmdApi([`/api/projects/${encodeURIComponent(argv[1])}/active`, '--method', 'PATCH', '--json', ...argv.slice(2)]);
507
+ throw new Error('Usage: openkan project list | use <id>');
508
+ }
509
+ async function cmdAgentContext(argv) {
510
+ const args = parseArgs(["context", ...argv]);
511
+ const endpoints = {
512
+ project: "/api/project", board: "/api/board", tasks: "/api/tasks-index", goals: "/api/goals",
513
+ docs: "/api/docs", projects: "/api/projects", agents: "/api/claude/agents", workflows: "/api/claude/workflows",
514
+ chatSessions: "/api/chat/sessions", settings: "/api/config-sections", tags: "/api/tags",
515
+ };
516
+ const base = apiBaseUrl(args);
517
+ const entries = await Promise.all(Object.entries(endpoints).map(async ([name, path]) => {
518
+ try {
519
+ const response = await fetch(`${base}${path}`, { headers: { Accept: "application/json" } });
520
+ const raw = await response.text();
521
+ let value = raw;
522
+ try {
523
+ value = JSON.parse(raw);
524
+ }
525
+ catch { /* retain raw body */ }
526
+ return [name, response.ok ? value : { error: `HTTP ${response.status}`, body: value }];
527
+ }
528
+ catch (error) {
529
+ return [name, { error: error instanceof Error ? error.message : String(error) }];
530
+ }
531
+ }));
532
+ const context = Object.fromEntries(entries);
533
+ process.stdout.write(`${JSON.stringify({ generatedAt: new Date().toISOString(), capabilities: AGENT_CAPABILITIES, context }, null, 2)}\n`);
534
+ }
535
+ async function cmdAgent(argv) {
536
+ const sub = argv[0] ?? "capabilities";
537
+ if (sub === "install") {
538
+ // parseArgs treats argv[0] as the command name, so prefix before parsing.
539
+ const args = parseArgs(["install", ...argv.slice(1)]);
540
+ // Validate the provider through whichever channel it arrived: the
541
+ // explicit --provider flag or a positional argument. Without this,
542
+ // `agent install bogus` silently falls through to the default provider.
543
+ const SUPPORTED_PROVIDERS = new Set(["claude"]);
544
+ const providerFromFlag = typeof args.flags.provider === "string" ? args.flags.provider : undefined;
545
+ const providerFromPositional = args.positionals.find((p) => !p.startsWith("-"));
546
+ const provider = providerFromFlag ?? providerFromPositional;
547
+ if (provider !== undefined && !SUPPORTED_PROVIDERS.has(provider)) {
548
+ throw new Error(`Only the Claude provider is currently supported (got: ${provider})`);
549
+ }
550
+ const result = installAgent({ force: args.flags.force === true, ...(typeof args.flags.target === "string" ? { configDir: resolve(args.flags.target) } : {}) });
551
+ console.log(JSON.stringify(result, null, 2));
552
+ return;
553
+ }
554
+ if (sub === "-h" || sub === "--help" || sub === "help") {
555
+ process.stdout.write("Usage: openkan agent install|capabilities|context|call|start|abort\n\n install [--target DIR] [--force] Install the Claude agent and skill\n capabilities Print the supported local API groups\n context [--json] Snapshot active workspace context\n call /api/path [flags] Call a loopback OpenKan API route\n start <task-id> [flags] Start the configured agent for a task\n abort <task-id> [flags] Abort a running task agent\n");
556
+ return;
557
+ }
558
+ if (sub === "capabilities") {
559
+ process.stdout.write(`${JSON.stringify(AGENT_CAPABILITIES, null, 2)}\n`);
560
+ return;
561
+ }
562
+ if (sub === "context")
563
+ return cmdAgentContext(argv.slice(1));
564
+ if (sub === "call")
565
+ return cmdApi(argv.slice(1));
566
+ if (sub === "start") {
567
+ const taskId = argv[1];
568
+ if (!taskId)
569
+ throw new Error("Usage: openkan agent start <task-id> [--agent id] [--model id]");
570
+ const args = parseArgs(["start", ...argv.slice(2)]);
571
+ const data = {};
572
+ if (typeof args.flags.agent === "string")
573
+ data.agent = args.flags.agent;
574
+ if (typeof args.flags.model === "string")
575
+ data.model = args.flags.model;
576
+ const requestArgs = [`/api/tasks/${encodeURIComponent(taskId)}/start`, "--method", "POST", "--data", JSON.stringify(data)];
577
+ if (args.flags.port !== undefined)
578
+ requestArgs.push("--port", String(args.flags.port));
579
+ if (args.flags.host !== undefined)
580
+ requestArgs.push("--host", String(args.flags.host));
581
+ return cmdApi(requestArgs);
582
+ }
583
+ if (sub === "abort") {
584
+ const taskId = argv[1];
585
+ if (!taskId)
586
+ throw new Error("Usage: openkan agent abort <task-id>");
587
+ return cmdApi([`/api/tasks/${encodeURIComponent(taskId)}/abort`, "--method", "POST", ...argv.slice(2)]);
588
+ }
589
+ throw new Error("Usage: openkan agent install|capabilities|context|call|start|abort …");
590
+ }
591
+ // ─── Subcommand: reset ───────────────────────────────────────────────────────
592
+ async function cmdReset(ctx, argv) {
593
+ // parseArgs treats argv[0] as the command name, so prefix before parsing
594
+ // or our flags end up stored as `cmd` instead of `flags`.
595
+ const args = parseArgs(["reset", ...argv]);
596
+ const hard = args.flags["hard"] === true || args.flags["hard"] === "true";
597
+ const yes = args.flags["yes"] === true || args.flags["yes"] === "true";
598
+ // In a non-interactive shell (CI, piped input), the stdin "data" listener
599
+ // never resolves — Node exits with the Promise pending and the user gets
600
+ // no feedback. Require an explicit flag in non-TTY mode.
601
+ if (!process.stdin.isTTY && !hard && !yes) {
602
+ console.error("openkan reset: non-interactive shell requires --yes (or --hard). Refusing to prompt.");
603
+ process.exit(1);
604
+ }
605
+ if (process.stdin.isTTY && !hard && !yes) {
606
+ process.stderr.write("Type 'yes' to confirm: ");
607
+ const answer = await new Promise(resolve => {
608
+ process.stdin.once("data", d => resolve(d.toString().trim()));
609
+ });
610
+ if (answer !== "yes") {
611
+ console.log("Aborted.");
612
+ return;
613
+ }
614
+ }
615
+ // Stop if running
616
+ try {
617
+ await cmdStop(ctx);
618
+ }
619
+ catch { /* ignore */ }
620
+ const dir = join(ctx.directory, ".ok");
621
+ if (hard) {
622
+ // Wipe tasks and sessions subdirs
623
+ const tasksDir = join(dir, "tasks");
624
+ const sessionsDir = join(dir, "sessions");
625
+ removeDir(tasksDir);
626
+ removeDir(sessionsDir);
627
+ }
628
+ removeDir(dir);
629
+ console.log("Reset complete.");
630
+ }
631
+ // ─── URL opener ────────────────────────────────────────────────────────────────
632
+ function openUrl(url) {
633
+ const openCmd = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
634
+ try {
635
+ spawn(openCmd, [url], { detached: true, stdio: "ignore" }).unref();
636
+ }
637
+ catch (e) {
638
+ console.warn(`Could not open browser: ${e}`);
639
+ }
640
+ }
641
+ // ─── Main dispatcher ─────────────────────────────────────────────────────────
642
+ function printHelp(cmd) {
643
+ const msgs = {
644
+ init: "init Create .ok/ directory (idempotent)",
645
+ start: "start [--port N] [--host H] [--no-open] [--no-auto-detect] [--foreground] [--project /abs/path] Start the server",
646
+ import: "import [--path DIR] [--include PATTERN] [--exclude PATTERN] Import checkboxes as tasks",
647
+ stop: "stop Stop the running server",
648
+ status: "status Show server status, port, pid, uptime",
649
+ open: "open Open the kanban UI in browser",
650
+ config: "config list|get <key>|set <key> <value> Manage config",
651
+ logs: "logs [--tail N] [--follow] Print server logs",
652
+ api: "api <path> [--method M] [--data JSON|--data-file FILE] Call any local OpenKan REST feature",
653
+ agent: "agent install|capabilities|context|call|start|abort Agent-first command/control bridge",
654
+ task: "task add|list|show|update|claim|heartbeat|complete|cancel|release Durable offline tasks (same as ok task)",
655
+ board: "board list|show|add|move|comment Dashboard tasks (requires local server and matching project)",
656
+ project: "project list|use <id> Inspect/select the dashboard project",
657
+ plan: "plan add|list|show|update Plans and phases (same as ok plan)",
658
+ prd: "prd add|list|show|update Long-horizon scope (same as ok prd)",
659
+ goal: "goal list|add|show|update Goals within PRDs; goal update <prd> <goal> --status met",
660
+ progress: "progress [--prd ID] [--json] Task, goal, plan and PRD rollups without a server",
661
+ skill: "skill install [--agent codex|claude|all] [--target DIR] [--force] Install command-first agent guidance",
662
+ doctor: "doctor Validate the .ok/ planning store",
663
+ reset: "reset [--hard] Reset .ok/ (--hard also wipes tasks/sessions)",
664
+ };
665
+ if (cmd && msgs[cmd]) {
666
+ console.log(`openkan ${msgs[cmd]}`);
667
+ }
668
+ else {
669
+ console.log("Usage: openkan <command> [args...]\n");
670
+ Object.values(msgs).forEach(m => console.log(` ${m}`));
671
+ console.log("\nFlags: --flag=value or --flag value, can appear before or after positionals.");
672
+ }
673
+ }
674
+ export async function main(argv = process.argv.slice(2)) {
675
+ if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
676
+ printHelp(argv[0] === "-h" || argv[0] === "--help" ? argv[1] : undefined);
677
+ return;
678
+ }
679
+ const { cmd, positionals, flags } = parseArgs(argv);
680
+ if (["task", "plan", "prd", "goal", "progress", "doctor", "index", "migrate-from-openkan"].includes(cmd)) {
681
+ // Help and bare invocations: print the command's help line instead of
682
+ // forwarding to runPlanning (which would throw a generic "Usage: …").
683
+ if (argv.length === 1 || argv[1] === "-h" || argv[1] === "--help") {
684
+ printHelp(cmd);
685
+ return;
686
+ }
687
+ process.exitCode = await runPlanning(argv);
688
+ return;
689
+ }
690
+ if (cmd === "skill") {
691
+ if (positionals[0] !== "install")
692
+ throw new Error("Usage: openkan skill install [--agent codex|claude|all] [--target DIR] [--force]");
693
+ const agent = String(flags.agent || "all");
694
+ if (!["all", "claude", "codex"].includes(agent))
695
+ throw new Error("--agent must be codex, claude, or all");
696
+ const targets = typeof flags.target === 'string' ? [resolve(flags.target)] : (agent === 'all' ? ['claude', 'codex'] : [agent]).map(name => join(homedir(), `.${name}`, 'skills', 'openkan'));
697
+ for (const target of targets) {
698
+ if (!existsSync(target)) {
699
+ // cpSync would create parents and silently succeed; reject early so
700
+ // the user does not end up with a target at a typo'd path.
701
+ throw new Error(`--target ${target} does not exist`);
702
+ }
703
+ const st = statSync(target);
704
+ if (!st.isDirectory())
705
+ throw new Error(`--target ${target} is not a directory`);
706
+ // Probe writability with a temp file rather than access() — file-mode
707
+ // checks are unreliable on WSL/macOS sandbox paths.
708
+ const probe = join(target, `.openkan-write-probe-${process.pid}`);
709
+ try {
710
+ writeFileSync(probe, "");
711
+ }
712
+ catch {
713
+ throw new Error(`--target ${target} is not writable`);
714
+ }
715
+ try {
716
+ rmSync(probe, { force: true });
717
+ }
718
+ catch { /* best effort */ }
719
+ if (existsSync(target) && !flags.force)
720
+ throw new Error(`${target} already exists; use --force to update`);
721
+ }
722
+ for (const target of targets) {
723
+ cpSync(join(OPENKAN_ROOT, 'skills', 'openkan'), target, { recursive: true });
724
+ console.log(`Installed openkan skill: ${target}`);
725
+ }
726
+ return;
727
+ }
728
+ // Resolve nested invocations without creating a second workspace.
729
+ if (cmd !== 'init') {
730
+ let directory = process.cwd();
731
+ while (!existsSync(join(directory, '.ok')) && dirname(directory) !== directory)
732
+ directory = dirname(directory);
733
+ if (existsSync(join(directory, '.ok')))
734
+ process.chdir(directory);
735
+ }
736
+ // Command/API helpers must not rewrite board state just to read it.
737
+ if (cmd === 'board')
738
+ return cmdBoard(argv.slice(1));
739
+ if (cmd === 'project')
740
+ return cmdProject(argv.slice(1));
741
+ if (cmd === 'api')
742
+ return cmdApi(argv.slice(1));
743
+ if (cmd === 'agent')
744
+ return cmdAgent(argv.slice(1));
745
+ const ctx = {
746
+ directory: process.cwd(),
747
+ client: null,
748
+ log: async (lvl, msg) => { console.log(`[${lvl}] ${msg}`); },
749
+ };
750
+ // Make sure ctx.directory is set before any command tries to use it
751
+ // (init doesn't need it, but others do)
752
+ if (cmd !== "init" && cmd !== "config") {
753
+ // Init board to set KANBAN_DIR for other commands
754
+ try {
755
+ await initBoard(ctx);
756
+ }
757
+ catch (e) {
758
+ if (e?.message?.includes("not initialised") || e?.message?.includes("Board not initialised")) {
759
+ // Board not yet initialized — init first
760
+ await cmdInit();
761
+ await initBoard(ctx);
762
+ }
763
+ }
764
+ }
765
+ switch (cmd) {
766
+ case "init":
767
+ await cmdInit();
768
+ process.exitCode = await runPlanning(['init']);
769
+ return;
770
+ case "start": return cmdStart(ctx, argv.slice(1));
771
+ case "import": return cmdImport(ctx, argv.slice(1));
772
+ case "stop": return cmdStop(ctx);
773
+ case "status": return cmdStatus(ctx);
774
+ case "open": return cmdOpen(ctx);
775
+ case "config": return cmdConfig(argv.slice(1));
776
+ case "logs": return cmdLogs(argv.slice(1));
777
+ case "api": return cmdApi(argv.slice(1));
778
+ case "agent": return cmdAgent(argv.slice(1));
779
+ case "reset": return cmdReset(ctx, argv.slice(1));
780
+ case "onboard": return cmdOnboard();
781
+ case "mcp": return cmdMcp();
782
+ default:
783
+ console.error(`Unknown command: ${cmd}`);
784
+ printHelp();
785
+ process.exit(1);
786
+ }
787
+ }
788
+ // ─── Onboard stub (M20 wires this) ──────────────────────────────────────────
789
+ function cmdOnboard() {
790
+ console.log("openkan onboard: wired in M20");
791
+ console.log(" Hint: run 'openkan start' and use the Settings sidebar to configure agents.");
792
+ }
793
+ // ─── MCP stub (M21 wires this) ───────────────────────────────────────────────
794
+ function cmdMcp() {
795
+ console.error("openkan mcp: not yet wired (M21)");
796
+ process.exit(1);
797
+ }
798
+ // ─── Entry point ─────────────────────────────────────────────────────────────
799
+ if (import.meta.url === `file://${process.argv[1]}`) {
800
+ main().catch((e) => {
801
+ console.error(`openkan: ${e?.message ?? e}`);
802
+ process.exit(1);
803
+ });
804
+ }