@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.
- package/CHANGELOG.md +226 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/agents/openkan.md +254 -0
- package/bin/install-agent.mjs +63 -0
- package/bin/ok.mjs +17 -0
- package/bin/openkan.mjs +10 -0
- package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
- package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
- package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
- package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
- package/dist/agents/openkan.md +254 -0
- package/dist/bin/install-agent.mjs +76 -0
- package/dist/bin/ok-install.js +58 -0
- package/dist/bin/ok.js +138 -0
- package/dist/bin/openkan.js +804 -0
- package/dist/commands/organize.md +15 -0
- package/dist/kanban/agent-profile.js +8 -0
- package/dist/kanban/archive.js +49 -0
- package/dist/kanban/bizar.js +242 -0
- package/dist/kanban/board.js +367 -0
- package/dist/kanban/bulk.js +139 -0
- package/dist/kanban/changelog.js +186 -0
- package/dist/kanban/chat.js +1280 -0
- package/dist/kanban/claude-state.js +974 -0
- package/dist/kanban/comments.js +80 -0
- package/dist/kanban/docs.js +144 -0
- package/dist/kanban/fs.js +163 -0
- package/dist/kanban/git.js +196 -0
- package/dist/kanban/images.js +140 -0
- package/dist/kanban/import.js +295 -0
- package/dist/kanban/inputs.js +94 -0
- package/dist/kanban/insights.js +140 -0
- package/dist/kanban/io.js +75 -0
- package/dist/kanban/mdx-render.js +348 -0
- package/dist/kanban/mdx.js +231 -0
- package/dist/kanban/projects.js +545 -0
- package/dist/kanban/search.js +121 -0
- package/dist/kanban/server.js +3296 -0
- package/dist/kanban/tags.js +124 -0
- package/dist/kanban/template.js +145 -0
- package/dist/kanban/tsx-sandbox.js +187 -0
- package/dist/kanban/watcher.js +270 -0
- package/dist/ok/commands/goal.js +65 -0
- package/dist/ok/commands/index.js +87 -0
- package/dist/ok/commands/init.js +15 -0
- package/dist/ok/commands/plan.js +155 -0
- package/dist/ok/commands/prd.js +202 -0
- package/dist/ok/commands/progress.js +31 -0
- package/dist/ok/commands/task.js +377 -0
- package/dist/ok/ids.js +98 -0
- package/dist/ok/lock.js +156 -0
- package/dist/ok/migrate.js +197 -0
- package/dist/ok/schemas.js +402 -0
- package/dist/ok/storage.js +222 -0
- package/dist/skills/openkan/SKILL.md +111 -0
- package/dist/skills/openkan/agents/openai.yaml +4 -0
- package/dist/skills/openkan/examples/simple-task.mdx +34 -0
- package/dist/skills/openkan/examples/with-ask.mdx +32 -0
- package/dist/skills/openkan/examples/with-choice.mdx +51 -0
- package/dist/skills/openkan/examples/with-preview.mdx +54 -0
- package/dist/skills/openkan/references/api.md +169 -0
- package/dist/skills/openkan/templates/task.mdx +46 -0
- package/dist/web/api.js +257 -0
- package/dist/web/app.js +4251 -0
- package/dist/web/bizar.js +39 -0
- package/dist/web/brand/agent-activity-sprite.svg +1 -0
- package/dist/web/brand/banner-docs.svg +24 -0
- package/dist/web/brand/banner.svg +32 -0
- package/dist/web/brand/empty-sessions.svg +17 -0
- package/dist/web/brand/empty-tasks.svg +17 -0
- package/dist/web/brand/favicon.svg +9 -0
- package/dist/web/brand/infinity-loader-animated.svg +220 -0
- package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
- package/dist/web/brand/logo-wordmark.svg +10 -0
- package/dist/web/brand/logo.svg +9 -0
- package/dist/web/brand/pixel-infinity-track.svg +1 -0
- package/dist/web/brand/social-card.svg +26 -0
- package/dist/web/changelog-view.js +456 -0
- package/dist/web/charts.js +269 -0
- package/dist/web/chat-sidebar.js +2397 -0
- package/dist/web/chat-status-motion.js +154 -0
- package/dist/web/claude-pane.js +820 -0
- package/dist/web/command-palette.js +381 -0
- package/dist/web/contributors-view.js +317 -0
- package/dist/web/cross-tab.js +102 -0
- package/dist/web/docs-view.js +168 -0
- package/dist/web/experience.css +165 -0
- package/dist/web/goals-view.js +45 -0
- package/dist/web/home-view.js +113 -0
- package/dist/web/images.js +311 -0
- package/dist/web/index.html +485 -0
- package/dist/web/insights.js +217 -0
- package/dist/web/keyboard.js +446 -0
- package/dist/web/mdx-viewer.js +600 -0
- package/dist/web/path-picker.js +787 -0
- package/dist/web/preview-frame.html +187 -0
- package/dist/web/settings.js +582 -0
- package/dist/web/style.css +8545 -0
- package/dist/web/task-view.js +1759 -0
- package/dist/web/vendor/gsap.min.js +11 -0
- package/dist/web/workspace.css +1513 -0
- package/package.json +71 -0
- package/skills/openkan/SKILL.md +111 -0
- package/skills/openkan/agents/openai.yaml +4 -0
- package/skills/openkan/examples/simple-task.mdx +34 -0
- package/skills/openkan/examples/with-ask.mdx +32 -0
- package/skills/openkan/examples/with-choice.mdx +51 -0
- package/skills/openkan/examples/with-preview.mdx +54 -0
- package/skills/openkan/references/api.md +169 -0
- 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
|
+
}
|