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 +121 -0
- package/dist/agents.js +326 -0
- package/dist/api.js +86 -0
- package/dist/args.js +60 -0
- package/dist/cli.js +61 -0
- package/dist/commands.js +278 -0
- package/dist/config.js +70 -0
- package/dist/doctor.js +198 -0
- package/dist/errors.js +15 -0
- package/dist/help.js +164 -0
- package/dist/install.js +172 -0
- package/dist/jsonc.js +297 -0
- package/dist/login.js +93 -0
- package/dist/output.js +52 -0
- package/dist/resolve.js +74 -0
- package/dist/setup.js +222 -0
- package/dist/skills.js +78 -0
- package/dist/types.js +1 -0
- package/package.json +30 -0
- package/skills/kelocan/SKILL.md +54 -0
- package/skills/kelocan-review/SKILL.md +32 -0
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
|
+
});
|