kelocan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # kelocan
2
+
3
+ CLI for the [Kelocan](https://kanban.sakze.com) kanban board — a thin client over the
4
+ REST API, designed for humans **and** AI agents (low-token output via `--json` /
5
+ `--fields`).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g kelocan # or: npx kelocan <command>
11
+ ```
12
+
13
+ Requires Node.js ≥ 20.
14
+
15
+ ## Onboarding — install the MCP server & skills into your agent
16
+
17
+ ```bash
18
+ npx kelocan init
19
+ ```
20
+
21
+ One command: logs you in (browser), detects installed agents, lets you pick
22
+ which to configure, writes the `kelocan` MCP entry **and** the agent skills,
23
+ then verifies the connection — in ≤ 3 prompts.
24
+
25
+ Supported agents:
26
+
27
+ | Agent | Config file | Entry | Auth | Skills dir |
28
+ | --- | --- | --- | --- | --- |
29
+ | Devin CLI | `~/.config/devin/mcp_config.json` | `mcpServers.kelocan.url` | OAuth | `~/.agents/skills` |
30
+ | Claude Code | `claude mcp add` (fallback `~/.claude.json`) | `mcpServers.kelocan` (http) | OAuth | `~/.claude/skills` |
31
+ | Cursor | `~/.cursor/mcp.json` | `mcpServers.kelocan.url` | OAuth | `~/.agents/skills` |
32
+ | Codex | `~/.codex/config.toml` | `[mcp_servers.kelocan]` + `bearer_token_env_var` | API key env | `~/.agents/skills` |
33
+ | Opencode | `~/.config/opencode/opencode.json` | `mcp.kelocan` (remote) | OAuth | `~/.agents/skills` |
34
+ | Antigravity | `~/.gemini/antigravity/mcp_config.json` or `~/.gemini/config/mcp_config.json` (whichever exists) | `mcpServers.kelocan.serverUrl` + headers | API key header | `skills/` next to the config |
35
+ | Hermes | `~/.hermes/config.yaml` | `mcp_servers.kelocan` + `auth: oauth` | OAuth | `~/.hermes/skills` |
36
+
37
+ Safety: only the `kelocan` entry is ever merged — other MCP entries are left
38
+ untouched, the original file is backed up to `<config>.bak`, and re-running is
39
+ idempotent. Secrets are never written literally; API-key installs reference
40
+ `$KELOCAN_API_KEY` from the environment.
41
+
42
+ Non-interactive:
43
+
44
+ ```bash
45
+ kelocan init --agent devin,cursor # specific agents
46
+ kelocan mcp install --agent all # only MCP entries
47
+ kelocan skills install --agent claude # only skill files
48
+ kelocan uninstall --agent codex # remove kelocan entry + skills only
49
+ kelocan doctor # verify everything (read-only)
50
+ ```
51
+
52
+ The skills can also be installed by any agent that supports the Agent Skills
53
+ convention: `npx skills add IFLyer/kelocan-cli`.
54
+
55
+ ## Auth
56
+
57
+ ```bash
58
+ kelocan login # browser flow: opens the /cli/authorize page
59
+ kelocan login --api-key klc_... # headless: store an existing API key
60
+ kelocan login --url http://localhost:8001 --web-url http://localhost:5173
61
+ kelocan whoami
62
+ kelocan logout
63
+ ```
64
+
65
+ Config is stored in `~/.config/kelocan/config.json` (mode `600`;
66
+ `%APPDATA%/kelocan` on Windows). Environment overrides:
67
+ `KELOCAN_URL`, `KELOCAN_API_KEY`, `KELOCAN_WEB_URL`.
68
+
69
+ ## Commands
70
+
71
+ | Command | Description |
72
+ | --- | --- |
73
+ | `board show <board>` | Board columns & task counts |
74
+ | `task list <board> [--column X] [--label Y] [--sprint Z]` | List tasks |
75
+ | `task show <id>` | Task detail |
76
+ | `task prompt <id>` | Rendered agent prompt |
77
+ | `task create <board> --title "..." [--column X] [--kind task\|idea\|feedback] [--priority low\|medium\|high\|urgent] [--label a,b] [--sprint active\|backlog\|uuid]` | Create task |
78
+ | `task move <id> --to <column\|role> [--position N]` | Move task |
79
+ | `task comment <id> -m "..."` / `--file path` | Add comment / attachment |
80
+ | `run start <task> --agent "Devin"` | Open agent run (prints `run_id`) |
81
+ | `run finish <run_id> --outcome succeeded\|failed\|cancelled -m "..."` | Close run |
82
+ | `subtask list <task>` / `subtask toggle <id>` | Sub-task checklist |
83
+
84
+ Boards, columns, and labels can be referenced by **name, workflow role**
85
+ (`backlog|active|review|done`) **or UUID**. Ambiguous names produce an error
86
+ listing the candidates.
87
+
88
+ ## Output & exit codes
89
+
90
+ Default output is concise text. `--json` prints the raw API payload;
91
+ `--fields a,b,c` selects fields (JSON objects, or tab-separated text).
92
+
93
+ Exit codes: `0` success · `1` validation/usage (message lists valid values) ·
94
+ `2` auth · `3` network/server.
95
+
96
+ Every command supports `--help` with examples:
97
+ `kelocan task create --help`.
98
+
99
+ ## Agent workflow example
100
+
101
+ ```bash
102
+ kelocan task prompt 8c8fb91c-... > prompt.md # fetch instructions
103
+ kelocan run start 8c8fb91c-... --agent "Devin" # -> run_id: ...
104
+ kelocan task comment 8c8fb91c-... -m "Progress: ..." # report progress
105
+ kelocan task move 8c8fb91c-... --to review # move card
106
+ kelocan run finish <run_id> --outcome succeeded -m "PR opened"
107
+ ```
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ npm install
113
+ npm run build # tsc -> dist/
114
+ npm test # unit tests (node:test via tsx)
115
+ npm run test:e2e # e2e vs live backend (needs KELOCAN_E2E=1, KELOCAN_URL, KELOCAN_API_KEY)
116
+ ```
117
+
118
+ ## Publishing
119
+
120
+ GitHub Actions publishes to npm when a `v*` tag is pushed
121
+ (`.github/workflows/publish.yml`). Requires an `NPM_TOKEN` secret.
package/dist/agents.js ADDED
@@ -0,0 +1,326 @@
1
+ import { existsSync } from "node:fs";
2
+ import { delimiter, dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { CliError } from "./errors.js";
5
+ function skillDirNextTo(configPath) {
6
+ // ~/.gemini/antigravity/mcp_config.json -> ~/.gemini/antigravity/skills
7
+ return join(dirname(configPath), "skills");
8
+ }
9
+ export const AGENTS = [
10
+ {
11
+ id: "devin",
12
+ name: "Devin CLI",
13
+ bins: ["devin"],
14
+ configCandidates: ["~/.config/devin/mcp_config.json"],
15
+ containerKey: "mcpServers",
16
+ format: "json",
17
+ defaultAuth: "oauth",
18
+ skillDirs: (home) => [join(home, ".agents", "skills")],
19
+ },
20
+ {
21
+ id: "claude",
22
+ name: "Claude Code",
23
+ bins: ["claude"],
24
+ configCandidates: ["~/.claude.json"],
25
+ containerKey: "mcpServers",
26
+ format: "json",
27
+ defaultAuth: "oauth",
28
+ cli: {
29
+ bin: "claude",
30
+ args: (url) => ["mcp", "add", "--transport", "http", "--scope", "user", "kelocan", url],
31
+ },
32
+ skillDirs: (home) => [join(home, ".claude", "skills")],
33
+ },
34
+ {
35
+ id: "cursor",
36
+ name: "Cursor",
37
+ bins: ["cursor"],
38
+ configCandidates: ["~/.cursor/mcp.json"],
39
+ containerKey: "mcpServers",
40
+ format: "json",
41
+ defaultAuth: "oauth",
42
+ skillDirs: (home) => [join(home, ".agents", "skills")],
43
+ },
44
+ {
45
+ id: "codex",
46
+ name: "Codex",
47
+ bins: ["codex"],
48
+ configCandidates: ["~/.codex/config.toml"],
49
+ format: "toml",
50
+ defaultAuth: "api-key",
51
+ skillDirs: (home) => [join(home, ".agents", "skills")],
52
+ },
53
+ {
54
+ id: "opencode",
55
+ name: "Opencode",
56
+ bins: ["opencode"],
57
+ configCandidates: [
58
+ "~/.config/opencode/opencode.json",
59
+ "~/.config/opencode/opencode.jsonc",
60
+ ],
61
+ containerKey: "mcp",
62
+ format: "json",
63
+ defaultAuth: "oauth",
64
+ skillDirs: (home) => [join(home, ".agents", "skills")],
65
+ },
66
+ {
67
+ id: "antigravity",
68
+ name: "Antigravity",
69
+ bins: ["antigravity", "gemini"],
70
+ configCandidates: [
71
+ "~/.gemini/antigravity/mcp_config.json",
72
+ "~/.gemini/config/mcp_config.json",
73
+ ],
74
+ containerKey: "mcpServers",
75
+ format: "json",
76
+ defaultAuth: "api-key",
77
+ skillDirs: (_home, configPath) => [skillDirNextTo(configPath)],
78
+ },
79
+ {
80
+ id: "hermes",
81
+ name: "Hermes Agent",
82
+ bins: ["hermes"],
83
+ configCandidates: ["~/.hermes/config.yaml", "~/.hermes/config.yml"],
84
+ format: "yaml",
85
+ defaultAuth: "oauth",
86
+ skillDirs: (home) => [join(home, ".hermes", "skills")],
87
+ },
88
+ ];
89
+ export function agentById(id) {
90
+ const def = AGENTS.find((a) => a.id === id || a.name.toLowerCase() === id.toLowerCase());
91
+ if (!def) {
92
+ throw new CliError(`Unknown agent "${id}". Valid agents: ${AGENTS.map((a) => a.id).join(", ")}`);
93
+ }
94
+ return def;
95
+ }
96
+ export function expandHome(p, home) {
97
+ return p.startsWith("~/") || p === "~" ? join(home, p.slice(2)) : p;
98
+ }
99
+ /** True when a binary is found on PATH (checks common Windows extensions too). */
100
+ export function binOnPath(bin) {
101
+ const pathEnv = process.env.PATH ?? "";
102
+ const exts = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
103
+ for (const dir of pathEnv.split(delimiter)) {
104
+ if (!dir)
105
+ continue;
106
+ for (const ext of exts) {
107
+ if (existsSync(join(dir, bin + ext)))
108
+ return true;
109
+ }
110
+ }
111
+ return false;
112
+ }
113
+ /**
114
+ * Pick the active config path for an agent:
115
+ * an existing file wins; otherwise a candidate whose parent dir already exists;
116
+ * otherwise the first candidate (file will be created there).
117
+ */
118
+ export function resolveConfigPath(def, home = homedir()) {
119
+ const cands = def.configCandidates.map((p) => expandHome(p, home));
120
+ for (const c of cands)
121
+ if (existsSync(c))
122
+ return c;
123
+ for (const c of cands)
124
+ if (existsSync(dirname(c)))
125
+ return c;
126
+ return cands[0];
127
+ }
128
+ export function detectAgent(def, home = homedir()) {
129
+ const via = [];
130
+ for (const bin of def.bins) {
131
+ if (binOnPath(bin))
132
+ via.push(`binary ${bin}`);
133
+ }
134
+ const configPath = resolveConfigPath(def, home);
135
+ const configExists = def.configCandidates
136
+ .map((p) => expandHome(p, home))
137
+ .some((p) => existsSync(p));
138
+ if (configExists)
139
+ via.push(configPath);
140
+ const skillDirs = def.skillDirs(home, configPath);
141
+ for (const d of skillDirs)
142
+ if (existsSync(d))
143
+ via.push(d);
144
+ return {
145
+ def,
146
+ detected: via.length > 0,
147
+ via,
148
+ configPath,
149
+ configExists,
150
+ skillDirs,
151
+ };
152
+ }
153
+ export function detectAll(home = homedir()) {
154
+ return AGENTS.map((a) => detectAgent(a, home));
155
+ }
156
+ // ---------- MCP entry builders ----------
157
+ const KEY_REF = "${KELOCAN_API_KEY}";
158
+ function apiKeyHeaders() {
159
+ return { Authorization: `Bearer ${KEY_REF}` };
160
+ }
161
+ /** JSON-format MCP server entry for an agent, honoring the auth mode. */
162
+ export function jsonEntry(def, url, auth) {
163
+ const base = {};
164
+ switch (def.id) {
165
+ case "claude":
166
+ Object.assign(base, { type: "http", url });
167
+ break;
168
+ case "opencode":
169
+ Object.assign(base, { type: "remote", url, enabled: true });
170
+ break;
171
+ case "antigravity":
172
+ Object.assign(base, { serverUrl: url });
173
+ break;
174
+ default:
175
+ base.url = url;
176
+ }
177
+ if (auth === "api-key" || def.defaultAuth === "api-key") {
178
+ if (def.id === "antigravity")
179
+ base.headers = apiKeyHeaders();
180
+ else if (auth === "api-key")
181
+ base.headers = apiKeyHeaders();
182
+ }
183
+ return base;
184
+ }
185
+ /** TOML section text for Codex (trailing newline included). */
186
+ export function tomlEntry(url) {
187
+ return (`[mcp_servers.kelocan]\n` +
188
+ `url = "${url}"\n` +
189
+ `bearer_token_env_var = "KELOCAN_API_KEY"\n`);
190
+ }
191
+ /** YAML block for Hermes (2-space child of `mcp_servers:`, trailing newline). */
192
+ export function yamlEntry(url, auth) {
193
+ const authLine = auth === "api-key" ? ` auth: api_key\n` : ` auth: oauth\n`;
194
+ const headers = auth === "api-key"
195
+ ? ` headers:\n Authorization: "Bearer ${KEY_REF}"\n`
196
+ : "";
197
+ return ` kelocan:\n url: "${url}"\n` + authLine + headers;
198
+ }
199
+ // ---------- TOML / YAML upsert & removal ----------
200
+ const TOML_HEADER = "[mcp_servers.kelocan]";
201
+ export function upsertTomlEntry(text, block) {
202
+ const lines = (text ?? "").split("\n");
203
+ const headerIdx = lines.findIndex((l) => l.trim() === TOML_HEADER);
204
+ if (headerIdx === -1) {
205
+ const prefix = text && !text.endsWith("\n") ? "\n" : "";
206
+ const sep = text && !text.endsWith("\n\n") ? "\n" : "";
207
+ return { text: (text ?? "") + prefix + sep + block, changed: true };
208
+ }
209
+ let end = headerIdx + 1;
210
+ while (end < lines.length && !/^\s*\[/.test(lines[end]))
211
+ end++;
212
+ const existing = lines.slice(headerIdx, end).join("\n").trimEnd() + "\n";
213
+ if (existing.trim() === block.trim())
214
+ return { text: text ?? "", changed: false };
215
+ const out = [...lines.slice(0, headerIdx), ...block.trimEnd().split("\n"), ...lines.slice(end)];
216
+ return { text: out.join("\n"), changed: true };
217
+ }
218
+ export function removeTomlEntry(text) {
219
+ const lines = text.split("\n");
220
+ const headerIdx = lines.findIndex((l) => l.trim() === TOML_HEADER);
221
+ if (headerIdx === -1)
222
+ return { text, changed: false };
223
+ let end = headerIdx + 1;
224
+ while (end < lines.length && !/^\s*\[/.test(lines[end]))
225
+ end++;
226
+ // drop a preceding blank separator line if present
227
+ const start = headerIdx > 0 && lines[headerIdx - 1].trim() === "" ? headerIdx - 1 : headerIdx;
228
+ const out = [...lines.slice(0, start), ...lines.slice(end)];
229
+ return { text: out.join("\n"), changed: true };
230
+ }
231
+ const YAML_CONTAINER = "mcp_servers";
232
+ const YAML_ENTRY_KEY = "kelocan";
233
+ function isTopLevelKey(line) {
234
+ return /^\S[^:]*:/.test(line);
235
+ }
236
+ /**
237
+ * Merge a `kelocan:` child mapping under top-level `mcp_servers:`.
238
+ * `entry` is the pre-rendered 2-space-indented block (yamlEntry()).
239
+ */
240
+ export function upsertYamlEntry(text, entry) {
241
+ const lines = (text ?? "").split("\n");
242
+ const containerIdx = lines.findIndex((l) => new RegExp(`^${YAML_CONTAINER}\\s*:`).test(l));
243
+ if (containerIdx === -1) {
244
+ const block = `${YAML_CONTAINER}:\n${entry}`;
245
+ const sep = text && !text.endsWith("\n") ? "\n" : "";
246
+ return { text: (text ?? "") + sep + (text ? "\n" : "") + block, changed: true };
247
+ }
248
+ // container block: until next top-level key
249
+ let blockEnd = lines.length;
250
+ for (let i = containerIdx + 1; i < lines.length; i++) {
251
+ if (isTopLevelKey(lines[i])) {
252
+ blockEnd = i;
253
+ break;
254
+ }
255
+ }
256
+ const entryIdx = lines
257
+ .slice(containerIdx + 1, blockEnd)
258
+ .findIndex((l) => /^\s+kelocan\s*:/.test(l));
259
+ if (entryIdx === -1) {
260
+ const out = [
261
+ ...lines.slice(0, blockEnd),
262
+ ...entry.trimEnd().split("\n"),
263
+ ...lines.slice(blockEnd),
264
+ ];
265
+ return { text: out.join("\n"), changed: true };
266
+ }
267
+ const absIdx = containerIdx + 1 + entryIdx;
268
+ const indent = /^\s*/.exec(lines[absIdx])[0];
269
+ let entryEnd = absIdx + 1;
270
+ while (entryEnd < blockEnd) {
271
+ if (lines[entryEnd].trim() === "") {
272
+ entryEnd++;
273
+ continue;
274
+ }
275
+ if ((/^\s*/.exec(lines[entryEnd])[0]).length <= indent.length)
276
+ break;
277
+ entryEnd++;
278
+ }
279
+ const existing = lines.slice(absIdx, entryEnd).join("\n").trimEnd() + "\n";
280
+ if (existing.trim() === entry.trim()) {
281
+ return { text: text ?? "", changed: false };
282
+ }
283
+ const out = [
284
+ ...lines.slice(0, absIdx),
285
+ ...entry.trimEnd().split("\n"),
286
+ ...lines.slice(entryEnd),
287
+ ];
288
+ return { text: out.join("\n"), changed: true };
289
+ }
290
+ export function removeYamlEntry(text) {
291
+ const lines = text.split("\n");
292
+ const containerIdx = lines.findIndex((l) => new RegExp(`^${YAML_CONTAINER}\\s*:`).test(l));
293
+ if (containerIdx === -1)
294
+ return { text, changed: false };
295
+ let blockEnd = lines.length;
296
+ for (let i = containerIdx + 1; i < lines.length; i++) {
297
+ if (isTopLevelKey(lines[i])) {
298
+ blockEnd = i;
299
+ break;
300
+ }
301
+ }
302
+ const relIdx = lines.slice(containerIdx + 1, blockEnd).findIndex((l) => /^\s+kelocan\s*:/.test(l));
303
+ if (relIdx === -1)
304
+ return { text, changed: false };
305
+ const absIdx = containerIdx + 1 + relIdx;
306
+ const indent = /^\s*/.exec(lines[absIdx])[0];
307
+ let entryEnd = absIdx + 1;
308
+ while (entryEnd < blockEnd) {
309
+ if (lines[entryEnd].trim() === "") {
310
+ entryEnd++;
311
+ continue;
312
+ }
313
+ const ind = /^\s*/.exec(lines[entryEnd])[0];
314
+ if (ind.length <= indent.length)
315
+ break;
316
+ entryEnd++;
317
+ }
318
+ // if kelocan was the only child, drop the now-empty `mcp_servers:` header too
319
+ const siblings = lines
320
+ .slice(containerIdx + 1, absIdx)
321
+ .concat(lines.slice(entryEnd, blockEnd))
322
+ .filter((l) => l.trim() !== "");
323
+ const cutStart = siblings.length === 0 ? containerIdx : absIdx;
324
+ const out = [...lines.slice(0, cutStart), ...lines.slice(entryEnd)];
325
+ return { text: out.join("\n"), changed: true };
326
+ }
package/dist/api.js ADDED
@@ -0,0 +1,86 @@
1
+ import { apiBase, apiKey, readConfig } from "./config.js";
2
+ import { CliError, authError } from "./errors.js";
3
+ function exitCodeFor(status, code) {
4
+ if (status === 401 || status === 403)
5
+ return 2;
6
+ if (code === "UNAUTHORIZED" || code === "INVALID_TOKEN" || code === "FORBIDDEN")
7
+ return 2;
8
+ if (status >= 500)
9
+ return 3;
10
+ return 1;
11
+ }
12
+ function formatDetails(details) {
13
+ if (!details)
14
+ return "";
15
+ if (Array.isArray(details)) {
16
+ const parts = details.map((d) => {
17
+ if (d && typeof d === "object") {
18
+ const rec = d;
19
+ const loc = Array.isArray(rec.loc) ? rec.loc.join(".") : "";
20
+ const msg = typeof rec.msg === "string" ? rec.msg : JSON.stringify(rec);
21
+ const input = rec.input !== undefined ? ` (got: ${JSON.stringify(rec.input)})` : "";
22
+ return `${loc ? loc + ": " : ""}${msg}${input}`;
23
+ }
24
+ return String(d);
25
+ });
26
+ return ` — ${parts.join("; ")}`;
27
+ }
28
+ try {
29
+ return ` — ${JSON.stringify(details)}`;
30
+ }
31
+ catch {
32
+ return "";
33
+ }
34
+ }
35
+ export async function request(path, opts = {}) {
36
+ const config = readConfig();
37
+ const base = apiBase(config);
38
+ const key = apiKey(config);
39
+ if (!key && !opts.anonymous) {
40
+ throw authError("Not authenticated. Run `kelocan login` or set KELOCAN_API_KEY / api_key in " +
41
+ "~/.config/kelocan/config.json");
42
+ }
43
+ const headers = {};
44
+ if (key)
45
+ headers.Authorization = `Bearer ${key}`;
46
+ let body;
47
+ if (opts.form) {
48
+ body = opts.form;
49
+ }
50
+ else if (opts.body !== undefined) {
51
+ headers["Content-Type"] = "application/json";
52
+ body = JSON.stringify(opts.body);
53
+ }
54
+ let res;
55
+ try {
56
+ res = await fetch(`${base}${path}`, {
57
+ method: opts.method ?? (body ? "POST" : "GET"),
58
+ headers,
59
+ body,
60
+ redirect: "follow",
61
+ });
62
+ }
63
+ catch (e) {
64
+ throw new CliError(`Cannot reach Kelocan server at ${base}: ${e instanceof Error ? e.message : e}`, 3);
65
+ }
66
+ let payload;
67
+ const text = await res.text();
68
+ if (text) {
69
+ try {
70
+ payload = JSON.parse(text);
71
+ }
72
+ catch {
73
+ payload = undefined;
74
+ }
75
+ }
76
+ if (!res.ok || payload?.success === false) {
77
+ const err = payload?.error;
78
+ const code = err?.code;
79
+ const detail = formatDetails(err?.details ?? (res.status === 422 ? payload : undefined));
80
+ const msg = err?.message ?? payload?.message ?? res.statusText;
81
+ const exit = exitCodeFor(res.status, code);
82
+ const hint = exit === 2 ? " (check your API key: kelocan login / KELOCAN_API_KEY)" : "";
83
+ throw new CliError(`${msg}${detail}${hint}`, exit);
84
+ }
85
+ return (payload?.data ?? payload);
86
+ }
package/dist/args.js ADDED
@@ -0,0 +1,60 @@
1
+ import { usageError } from "./errors.js";
2
+ /** Short flag aliases shared by all commands. */
3
+ const SHORT = { m: "message" };
4
+ export function parseArgs(argv) {
5
+ const _ = [];
6
+ const flags = {};
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const a = argv[i];
9
+ if (a === "--") {
10
+ _.push(...argv.slice(i + 1));
11
+ break;
12
+ }
13
+ if (a.startsWith("--")) {
14
+ const eq = a.indexOf("=");
15
+ if (eq !== -1) {
16
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
17
+ }
18
+ else {
19
+ const name = a.slice(2);
20
+ const next = argv[i + 1];
21
+ if (next !== undefined && !next.startsWith("-")) {
22
+ flags[name] = next;
23
+ i++;
24
+ }
25
+ else {
26
+ flags[name] = true;
27
+ }
28
+ }
29
+ }
30
+ else if (a.startsWith("-") && a.length > 1) {
31
+ const name = SHORT[a.slice(1)] ?? a.slice(1);
32
+ const next = argv[i + 1];
33
+ if (next !== undefined && !next.startsWith("-")) {
34
+ flags[name] = next;
35
+ i++;
36
+ }
37
+ else {
38
+ flags[name] = true;
39
+ }
40
+ }
41
+ else {
42
+ _.push(a);
43
+ }
44
+ }
45
+ return { _, flags };
46
+ }
47
+ export function need(flags, name, hint) {
48
+ const v = flags[name];
49
+ if (typeof v !== "string" || v.length === 0) {
50
+ throw usageError(`Missing required option --${name}. ${hint}`);
51
+ }
52
+ return v;
53
+ }
54
+ export function flagStr(flags, name) {
55
+ const v = flags[name];
56
+ return typeof v === "string" && v.length > 0 ? v : undefined;
57
+ }
58
+ export function flagBool(flags, name) {
59
+ return flags[name] === true || flags[name] === "true";
60
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "./args.js";
3
+ import { cmdBoardShow, cmdLogin, cmdLogout, cmdRunFinish, cmdRunStart, cmdSubtaskList, cmdSubtaskToggle, cmdTaskComment, cmdTaskCreate, cmdTaskList, cmdTaskMove, cmdTaskPrompt, cmdTaskShow, cmdWhoami, } from "./commands.js";
4
+ import { CliError, usageError } from "./errors.js";
5
+ import { HELP, ROOT_HELP } from "./help.js";
6
+ import { errLine, line } from "./output.js";
7
+ import { cmdDoctor, cmdInit, cmdMcpInstall, cmdSkillsInstall, cmdUninstall, } from "./setup.js";
8
+ const ROUTES = {
9
+ init: cmdInit,
10
+ doctor: cmdDoctor,
11
+ "mcp install": cmdMcpInstall,
12
+ "skills install": cmdSkillsInstall,
13
+ uninstall: cmdUninstall,
14
+ login: cmdLogin,
15
+ logout: cmdLogout,
16
+ whoami: cmdWhoami,
17
+ "board show": cmdBoardShow,
18
+ "task list": cmdTaskList,
19
+ "task show": cmdTaskShow,
20
+ "task prompt": cmdTaskPrompt,
21
+ "task create": cmdTaskCreate,
22
+ "task move": cmdTaskMove,
23
+ "task comment": cmdTaskComment,
24
+ "run start": cmdRunStart,
25
+ "run finish": cmdRunFinish,
26
+ "subtask list": cmdSubtaskList,
27
+ "subtask toggle": cmdSubtaskToggle,
28
+ };
29
+ async function main() {
30
+ const argv = process.argv.slice(2);
31
+ if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h" || argv[0] === "help") {
32
+ line(ROOT_HELP);
33
+ return;
34
+ }
35
+ const [group, sub, ...rest] = argv;
36
+ const key = sub && !sub.startsWith("-") ? `${group} ${sub}` : group;
37
+ const handler = ROUTES[key];
38
+ if (!handler) {
39
+ if (group === "--version" || group === "-v" || group === "version") {
40
+ line("kelocan 0.1.0");
41
+ return;
42
+ }
43
+ throw usageError(`Unknown command "${argv.join(" ")}". Run 'kelocan --help'.`);
44
+ }
45
+ const args = parseArgs(key === group ? [sub, ...rest].filter(Boolean) : rest);
46
+ if (args.flags.help || args.flags.h) {
47
+ line(HELP[key] ?? ROOT_HELP);
48
+ return;
49
+ }
50
+ await handler(args);
51
+ }
52
+ main().catch((e) => {
53
+ if (e instanceof CliError) {
54
+ errLine(`error: ${e.message}`);
55
+ process.exitCode = e.exitCode;
56
+ }
57
+ else {
58
+ errLine(`error: ${e instanceof Error ? e.message : e}`);
59
+ process.exitCode = 1;
60
+ }
61
+ });