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/dist/login.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { platform } from "node:os";
|
|
5
|
+
import { apiBase, readConfig, webBase, writeConfig } from "./config.js";
|
|
6
|
+
import { CliError } from "./errors.js";
|
|
7
|
+
import { errLine, line } from "./output.js";
|
|
8
|
+
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
function openBrowser(url) {
|
|
10
|
+
const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
|
|
11
|
+
const args = platform() === "win32" ? ["/c", "start", "", url] : [url];
|
|
12
|
+
const child = execFile(cmd, args, () => { });
|
|
13
|
+
child.unref();
|
|
14
|
+
}
|
|
15
|
+
/** Verify an API key against GET /users/me. Returns the user on success. */
|
|
16
|
+
export async function verifyKey(base, key) {
|
|
17
|
+
let res;
|
|
18
|
+
try {
|
|
19
|
+
res = await fetch(`${base}/users/me`, {
|
|
20
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
throw new CliError(`Cannot reach Kelocan server at ${base}: ${e instanceof Error ? e.message : e}`, 3);
|
|
25
|
+
}
|
|
26
|
+
const body = (await res.json().catch(() => null));
|
|
27
|
+
if (!res.ok || !body?.data) {
|
|
28
|
+
const msg = body?.error?.message ?? body?.message ?? res.statusText;
|
|
29
|
+
if (res.status === 401 || res.status === 403) {
|
|
30
|
+
throw new CliError(`API key rejected by server: ${msg}`, 2);
|
|
31
|
+
}
|
|
32
|
+
throw new CliError(`Login check failed: ${msg}`, res.status >= 500 ? 3 : 1);
|
|
33
|
+
}
|
|
34
|
+
return body.data;
|
|
35
|
+
}
|
|
36
|
+
/** Browser loopback flow: serve /callback on 127.0.0.1, open {web}/cli/authorize. */
|
|
37
|
+
export async function browserLogin(name) {
|
|
38
|
+
const state = randomBytes(24).toString("hex");
|
|
39
|
+
const result = await new Promise((resolve, reject) => {
|
|
40
|
+
const server = createServer((req, res) => {
|
|
41
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
42
|
+
if (url.pathname !== "/callback") {
|
|
43
|
+
res.writeHead(404).end("Not found");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const key = url.searchParams.get("key");
|
|
47
|
+
const cbState = url.searchParams.get("state");
|
|
48
|
+
if (!key || cbState !== state) {
|
|
49
|
+
res.writeHead(400, { "Content-Type": "text/html" }).end("<h1>Kelocan CLI</h1><p>Invalid callback (state mismatch). You can close this tab.</p>");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
res.writeHead(200, { "Content-Type": "text/html" }).end("<h1>Kelocan CLI</h1><p>Authorization complete — you can close this tab and return to the terminal.</p>");
|
|
53
|
+
resolve({ key });
|
|
54
|
+
server.close();
|
|
55
|
+
});
|
|
56
|
+
server.on("error", (e) => reject(new CliError(`Cannot start local callback server: ${e.message}`, 3)));
|
|
57
|
+
server.listen(0, "127.0.0.1", () => {
|
|
58
|
+
const addr = server.address();
|
|
59
|
+
if (!addr || typeof addr === "string") {
|
|
60
|
+
reject(new CliError("Cannot determine callback port", 3));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const port = addr.port;
|
|
64
|
+
const authUrl = `${webBase()}/cli/authorize?port=${port}&state=${state}` +
|
|
65
|
+
`&name=${encodeURIComponent(name)}`;
|
|
66
|
+
line(`Opening browser to authorize this device...`);
|
|
67
|
+
line(`If it does not open, visit:\n${authUrl}\n`);
|
|
68
|
+
openBrowser(authUrl);
|
|
69
|
+
});
|
|
70
|
+
setTimeout(() => {
|
|
71
|
+
server.close();
|
|
72
|
+
reject(new CliError("Login timed out after 5 minutes", 1));
|
|
73
|
+
}, LOGIN_TIMEOUT_MS).unref();
|
|
74
|
+
});
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
export async function saveLogin(opts) {
|
|
78
|
+
// Persist URL settings first so apiBase()/webBase() resolve correctly.
|
|
79
|
+
const config = readConfig();
|
|
80
|
+
if (opts.url)
|
|
81
|
+
config.url = opts.url.replace(/\/+$/, "").replace(/\/api\/v1$/, "");
|
|
82
|
+
if (opts.webUrl)
|
|
83
|
+
config.web_url = opts.webUrl;
|
|
84
|
+
writeConfig(config);
|
|
85
|
+
const base = apiBase(config);
|
|
86
|
+
const user = await verifyKey(base, opts.apiKey);
|
|
87
|
+
config.api_key = opts.apiKey;
|
|
88
|
+
writeConfig(config);
|
|
89
|
+
return user;
|
|
90
|
+
}
|
|
91
|
+
export function warnNoKey() {
|
|
92
|
+
errLine("hint: run `kelocan login` to authenticate");
|
|
93
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { flagBool, flagStr } from "./args.js";
|
|
2
|
+
export function outputOpts(args) {
|
|
3
|
+
const fields = flagStr(args.flags, "fields")
|
|
4
|
+
?.split(",")
|
|
5
|
+
.map((s) => s.trim())
|
|
6
|
+
.filter(Boolean);
|
|
7
|
+
return { json: flagBool(args.flags, "json"), fields };
|
|
8
|
+
}
|
|
9
|
+
function pick(item, fields) {
|
|
10
|
+
if (item === null || typeof item !== "object")
|
|
11
|
+
return item;
|
|
12
|
+
const src = item;
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const f of fields)
|
|
15
|
+
out[f] = src[f];
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
export function emit(data, opts, text) {
|
|
19
|
+
if (opts.json) {
|
|
20
|
+
let out = data;
|
|
21
|
+
if (opts.fields?.length) {
|
|
22
|
+
out = Array.isArray(data)
|
|
23
|
+
? data.map((d) => pick(d, opts.fields))
|
|
24
|
+
: pick(data, opts.fields);
|
|
25
|
+
}
|
|
26
|
+
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (opts.fields?.length && data !== null && typeof data === "object") {
|
|
30
|
+
// --fields without --json: print values, one per line (per item).
|
|
31
|
+
const items = Array.isArray(data) ? data : [data];
|
|
32
|
+
for (const item of items) {
|
|
33
|
+
const src = item;
|
|
34
|
+
process.stdout.write(opts.fields.map((f) => formatValue(src[f])).join("\t") + "\n");
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
text();
|
|
39
|
+
}
|
|
40
|
+
export function formatValue(v) {
|
|
41
|
+
if (v === null || v === undefined)
|
|
42
|
+
return "";
|
|
43
|
+
if (typeof v === "object")
|
|
44
|
+
return JSON.stringify(v);
|
|
45
|
+
return String(v);
|
|
46
|
+
}
|
|
47
|
+
export function line(s) {
|
|
48
|
+
process.stdout.write(s + "\n");
|
|
49
|
+
}
|
|
50
|
+
export function errLine(s) {
|
|
51
|
+
process.stderr.write(s + "\n");
|
|
52
|
+
}
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { request } from "./api.js";
|
|
2
|
+
import { usageError } from "./errors.js";
|
|
3
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4
|
+
const WORKFLOW_ROLES = ["backlog", "active", "review", "done"];
|
|
5
|
+
export function isUuid(s) {
|
|
6
|
+
return UUID_RE.test(s);
|
|
7
|
+
}
|
|
8
|
+
/** Resolve a board reference: UUID or (case-insensitive) name. */
|
|
9
|
+
export async function resolveBoard(ref) {
|
|
10
|
+
if (isUuid(ref)) {
|
|
11
|
+
return request(`/boards/${ref}`);
|
|
12
|
+
}
|
|
13
|
+
const boards = await request("/boards");
|
|
14
|
+
const matches = boards.filter((b) => b.name.toLowerCase() === ref.toLowerCase());
|
|
15
|
+
if (matches.length === 1)
|
|
16
|
+
return request(`/boards/${matches[0].id}`);
|
|
17
|
+
if (matches.length > 1) {
|
|
18
|
+
throw usageError(`Ambiguous board name "${ref}". Candidates:\n` +
|
|
19
|
+
matches.map((b) => ` ${b.id} ${b.name}`).join("\n") +
|
|
20
|
+
`\nUse the UUID instead.`);
|
|
21
|
+
}
|
|
22
|
+
const names = boards.map((b) => b.name).join(", ") || "(none)";
|
|
23
|
+
throw usageError(`Board "${ref}" not found. Available boards: ${names}`);
|
|
24
|
+
}
|
|
25
|
+
/** Resolve a column reference inside a board: UUID, name, or workflow role. */
|
|
26
|
+
export function resolveColumn(board, ref) {
|
|
27
|
+
const columns = board.columns ?? [];
|
|
28
|
+
if (isUuid(ref)) {
|
|
29
|
+
const col = columns.find((c) => c.id.toLowerCase() === ref.toLowerCase());
|
|
30
|
+
if (!col)
|
|
31
|
+
throw usageError(`Column ${ref} not found on board "${board.name}".`);
|
|
32
|
+
return col;
|
|
33
|
+
}
|
|
34
|
+
const lower = ref.toLowerCase();
|
|
35
|
+
if (WORKFLOW_ROLES.includes(lower)) {
|
|
36
|
+
const col = columns.find((c) => c.workflow_role === lower);
|
|
37
|
+
if (!col) {
|
|
38
|
+
const roles = columns.map((c) => `${c.name} (${c.workflow_role})`).join(", ");
|
|
39
|
+
throw usageError(`No column with workflow role "${lower}" on board "${board.name}". Columns: ${roles}`);
|
|
40
|
+
}
|
|
41
|
+
return col;
|
|
42
|
+
}
|
|
43
|
+
const matches = columns.filter((c) => c.name.toLowerCase() === lower);
|
|
44
|
+
if (matches.length === 1)
|
|
45
|
+
return matches[0];
|
|
46
|
+
if (matches.length > 1) {
|
|
47
|
+
throw usageError(`Ambiguous column "${ref}". Candidates: ` +
|
|
48
|
+
matches.map((c) => `${c.id} (${c.workflow_role})`).join(", "));
|
|
49
|
+
}
|
|
50
|
+
const names = columns.map((c) => `${c.name} [${c.workflow_role}]`).join(", ");
|
|
51
|
+
throw usageError(`Column "${ref}" not found on board "${board.name}". Valid columns: ${names}`);
|
|
52
|
+
}
|
|
53
|
+
/** Resolve label refs (name or UUID) to IDs using the board's label list. */
|
|
54
|
+
export async function resolveLabels(boardId, refs) {
|
|
55
|
+
if (!refs.length)
|
|
56
|
+
return [];
|
|
57
|
+
const labels = await request(`/boards/${boardId}/labels`);
|
|
58
|
+
return refs.map((ref) => {
|
|
59
|
+
if (isUuid(ref)) {
|
|
60
|
+
const hit = labels.find((l) => l.id.toLowerCase() === ref.toLowerCase());
|
|
61
|
+
if (!hit)
|
|
62
|
+
throw usageError(`Label ${ref} not found on board.`);
|
|
63
|
+
return hit.id;
|
|
64
|
+
}
|
|
65
|
+
const hits = labels.filter((l) => l.name.toLowerCase() === ref.toLowerCase());
|
|
66
|
+
if (hits.length === 1)
|
|
67
|
+
return hits[0].id;
|
|
68
|
+
if (hits.length > 1) {
|
|
69
|
+
throw usageError(`Ambiguous label "${ref}". Candidates: ${hits.map((l) => l.id).join(", ")}`);
|
|
70
|
+
}
|
|
71
|
+
const names = labels.map((l) => l.name).join(", ") || "(none)";
|
|
72
|
+
throw usageError(`Label "${ref}" not found. Available labels: ${names}`);
|
|
73
|
+
});
|
|
74
|
+
}
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { agentById, detectAll, } from "./agents.js";
|
|
4
|
+
import { apiKey } from "./config.js";
|
|
5
|
+
import { runDoctor } from "./doctor.js";
|
|
6
|
+
import { usageError } from "./errors.js";
|
|
7
|
+
import { installAgent, installMcpConfig, mcpUrl, probeMcp, uninstallMcpConfig, } from "./install.js";
|
|
8
|
+
import { browserLogin, saveLogin } from "./login.js";
|
|
9
|
+
import { emit, errLine, line, outputOpts } from "./output.js";
|
|
10
|
+
import { flagBool, flagStr } from "./args.js";
|
|
11
|
+
import { request } from "./api.js";
|
|
12
|
+
import { installSkills, removeSkills } from "./skills.js";
|
|
13
|
+
// ---------- small prompt helpers ----------
|
|
14
|
+
function ask(question) {
|
|
15
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
rl.question(question, (answer) => {
|
|
18
|
+
rl.close();
|
|
19
|
+
resolve(answer.trim());
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function isTty() {
|
|
24
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
25
|
+
}
|
|
26
|
+
function parseAgentList(raw) {
|
|
27
|
+
return raw
|
|
28
|
+
.split(",")
|
|
29
|
+
.map((s) => s.trim())
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.map((s) => agentById(s).id);
|
|
32
|
+
}
|
|
33
|
+
function parseAuth(args, fallback = "oauth") {
|
|
34
|
+
const a = flagStr(args.flags, "auth");
|
|
35
|
+
if (!a)
|
|
36
|
+
return fallback;
|
|
37
|
+
if (a !== "oauth" && a !== "api-key") {
|
|
38
|
+
throw usageError(`Invalid --auth "${a}". Valid: oauth | api-key`);
|
|
39
|
+
}
|
|
40
|
+
return a;
|
|
41
|
+
}
|
|
42
|
+
function resolveTargets(args, all) {
|
|
43
|
+
const raw = flagStr(args.flags, "agent");
|
|
44
|
+
if (!raw) {
|
|
45
|
+
throw usageError(`Missing --agent <name|all>. Valid agents: ${all.map((d) => d.def.id).join(", ")}, all`);
|
|
46
|
+
}
|
|
47
|
+
if (raw === "all")
|
|
48
|
+
return all;
|
|
49
|
+
const ids = new Set(parseAgentList(raw));
|
|
50
|
+
return all.filter((d) => ids.has(d.def.id));
|
|
51
|
+
}
|
|
52
|
+
// ---------- kelocan init ----------
|
|
53
|
+
export async function cmdInit(args) {
|
|
54
|
+
const nonInteractive = flagStr(args.flags, "agent") !== undefined || !isTty();
|
|
55
|
+
const authFlag = flagStr(args.flags, "auth");
|
|
56
|
+
line("Kelocan agent setup — installs the kelocan MCP server + agent skills.\n");
|
|
57
|
+
// 1. login
|
|
58
|
+
if (!apiKey()) {
|
|
59
|
+
const inlineKey = flagStr(args.flags, "api-key");
|
|
60
|
+
if (inlineKey) {
|
|
61
|
+
const user = await saveLogin({ apiKey: inlineKey });
|
|
62
|
+
line(`Logged in as ${user.name}`);
|
|
63
|
+
}
|
|
64
|
+
else if (!nonInteractive || flagBool(args.flags, "login")) {
|
|
65
|
+
if (nonInteractive) {
|
|
66
|
+
throw usageError("Not authenticated. Run `kelocan login` first (or pass --api-key klc_...).");
|
|
67
|
+
}
|
|
68
|
+
const { key } = await browserLogin(flagStr(args.flags, "name") ?? hostname());
|
|
69
|
+
const user = await saveLogin({ apiKey: key });
|
|
70
|
+
line(`Logged in as ${user.name}`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
throw usageError("Not authenticated. Run `kelocan login` first (or pass --api-key).");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const user = await request("/users/me");
|
|
78
|
+
line(`Logged in as ${user.name} <${user.email ?? ""}>`);
|
|
79
|
+
}
|
|
80
|
+
// 2. pick agents
|
|
81
|
+
const all = detectAll();
|
|
82
|
+
let targets;
|
|
83
|
+
const agentFlag = flagStr(args.flags, "agent");
|
|
84
|
+
if (agentFlag) {
|
|
85
|
+
targets = resolveTargets(args, all);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const detected = all.filter((d) => d.detected);
|
|
89
|
+
line("\nDetected agents:");
|
|
90
|
+
all.forEach((d, i) => {
|
|
91
|
+
const mark = d.detected ? "*" : " ";
|
|
92
|
+
line(` ${mark} ${i + 1}. ${d.def.name.padEnd(14)} ${d.via.join(", ") || "(not found)"}`);
|
|
93
|
+
});
|
|
94
|
+
const def = detected.map((d) => all.indexOf(d) + 1).join(",");
|
|
95
|
+
const answer = await ask(`\nInstall into which agents? comma-separated numbers or 'all' [${def || "none"}]: `);
|
|
96
|
+
const sel = answer || def;
|
|
97
|
+
if (!sel || sel === "none") {
|
|
98
|
+
line("No agents selected — nothing to do.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
targets =
|
|
102
|
+
sel === "all"
|
|
103
|
+
? all
|
|
104
|
+
: sel
|
|
105
|
+
.split(",")
|
|
106
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
107
|
+
.filter((n) => Number.isInteger(n) && n >= 1 && n <= all.length)
|
|
108
|
+
.map((n) => all[n - 1]);
|
|
109
|
+
if (targets.length === 0) {
|
|
110
|
+
throw usageError("No valid agents selected.");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// 3. auth mode (one question max)
|
|
114
|
+
const auth = parseAuth(args, "oauth");
|
|
115
|
+
line(`\nMCP URL: ${mcpUrl()}`);
|
|
116
|
+
line(`Auth mode: ${authFlag ?? "default per agent"}`);
|
|
117
|
+
// 4. install
|
|
118
|
+
const reports = [];
|
|
119
|
+
for (const det of targets) {
|
|
120
|
+
const report = installAgent(det, auth);
|
|
121
|
+
reports.push(report);
|
|
122
|
+
for (const w of report.warnings)
|
|
123
|
+
errLine(` warning: ${w}`);
|
|
124
|
+
const cfg = report.config.action === "cli"
|
|
125
|
+
? `configured via ${det.def.name} CLI`
|
|
126
|
+
: report.config.action === "unchanged"
|
|
127
|
+
? `MCP already configured`
|
|
128
|
+
: `MCP written to ${report.config.configPath}` +
|
|
129
|
+
(report.config.backup ? ` (backup: ${report.config.backup})` : "");
|
|
130
|
+
line(`\n${det.def.name}: ${cfg}`);
|
|
131
|
+
for (const s of report.skills) {
|
|
132
|
+
for (const r of s.results) {
|
|
133
|
+
if (r.action !== "unchanged")
|
|
134
|
+
line(` skill ${r.name}: ${r.action} → ${r.path}`);
|
|
135
|
+
}
|
|
136
|
+
if (s.results.every((r) => r.action === "unchanged")) {
|
|
137
|
+
line(` skills already installed in ${s.dir}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// 5. verify
|
|
142
|
+
line("\nVerifying...");
|
|
143
|
+
const boards = await request("/boards");
|
|
144
|
+
line(` API ok — ${boards.length} accessible board(s)`);
|
|
145
|
+
try {
|
|
146
|
+
const status = await probeMcp(mcpUrl(), apiKey());
|
|
147
|
+
line(` MCP ${status}`);
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
errLine(` MCP probe failed: ${e instanceof Error ? e.message : e}`);
|
|
151
|
+
}
|
|
152
|
+
line("\nDone. Next steps:");
|
|
153
|
+
line(" • restart your agent so it picks up the new MCP server");
|
|
154
|
+
line(" • in the agent, the 'kelocan' skills teach the board workflow");
|
|
155
|
+
line(" • run `kelocan doctor` anytime to re-check the setup");
|
|
156
|
+
}
|
|
157
|
+
// ---------- non-interactive commands ----------
|
|
158
|
+
export async function cmdMcpInstall(args) {
|
|
159
|
+
const auth = parseAuth(args);
|
|
160
|
+
const all = detectAll();
|
|
161
|
+
const targets = resolveTargets(args, all);
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const det of targets) {
|
|
164
|
+
const res = installAgentMcpOnly(det, auth);
|
|
165
|
+
out.push(res);
|
|
166
|
+
if (res.action === "written") {
|
|
167
|
+
line(`${det.def.id}: wrote ${res.configPath}${res.backup ? ` (backup: ${res.backup})` : ""}`);
|
|
168
|
+
}
|
|
169
|
+
else if (res.action === "cli") {
|
|
170
|
+
line(`${det.def.id}: configured via ${det.def.name} CLI`);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
line(`${det.def.id}: already configured`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
emit(out, outputOpts(args), () => { });
|
|
177
|
+
}
|
|
178
|
+
function installAgentMcpOnly(det, auth) {
|
|
179
|
+
return installMcpConfig(det.def, auth);
|
|
180
|
+
}
|
|
181
|
+
export async function cmdSkillsInstall(args) {
|
|
182
|
+
const all = detectAll();
|
|
183
|
+
const targets = resolveTargets(args, all);
|
|
184
|
+
for (const det of targets) {
|
|
185
|
+
for (const dir of det.skillDirs) {
|
|
186
|
+
const results = installSkills(dir);
|
|
187
|
+
for (const r of results) {
|
|
188
|
+
line(`${det.def.id}: skill ${r.name} ${r.action} → ${r.path}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export async function cmdUninstall(args) {
|
|
194
|
+
const all = detectAll();
|
|
195
|
+
const targets = resolveTargets(args, all);
|
|
196
|
+
for (const det of targets) {
|
|
197
|
+
const cfg = uninstallMcpConfig(det.def);
|
|
198
|
+
const removedSkills = det.skillDirs.flatMap((dir) => removeSkills(dir));
|
|
199
|
+
line(`${det.def.id}: MCP ${cfg.action}` +
|
|
200
|
+
(cfg.backup ? ` (backup: ${cfg.backup})` : "") +
|
|
201
|
+
(removedSkills.length ? `; removed skills: ${removedSkills.join(", ")}` : "; no skills found"));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export async function cmdDoctor(args) {
|
|
205
|
+
const report = await runDoctor();
|
|
206
|
+
emit(report, outputOpts(args), () => {
|
|
207
|
+
const icon = { ok: "ok", info: "--", warn: "!!", fail: "XX" };
|
|
208
|
+
line("Server:");
|
|
209
|
+
for (const f of report.server) {
|
|
210
|
+
line(` [${icon[f.level]}] ${f.message}${f.fix ? ` → ${f.fix}` : ""}`);
|
|
211
|
+
}
|
|
212
|
+
for (const a of report.agents) {
|
|
213
|
+
line(`\n${a.name} (${a.agent}):`);
|
|
214
|
+
for (const f of a.findings) {
|
|
215
|
+
line(` [${icon[f.level]}] ${f.message}${f.fix ? ` → ${f.fix}` : ""}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
line(`\n${report.ok ? "All checks passed." : "Some checks failed — see fixes above."}`);
|
|
219
|
+
});
|
|
220
|
+
if (!report.ok)
|
|
221
|
+
process.exitCode = 1;
|
|
222
|
+
}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
6
|
+
/** Skill names bundled with the package (skills/<name>/SKILL.md at repo root). */
|
|
7
|
+
export const SKILL_NAMES = ["kelocan", "kelocan-review"];
|
|
8
|
+
function packageRoot() {
|
|
9
|
+
// dist/skills.js -> <pkg>/dist -> <pkg>; src/skills.ts -> <pkg>/src -> <pkg>
|
|
10
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
11
|
+
}
|
|
12
|
+
export function bundledSkillsDir() {
|
|
13
|
+
return join(packageRoot(), "skills");
|
|
14
|
+
}
|
|
15
|
+
export function bundledSkillSource(name) {
|
|
16
|
+
const path = join(bundledSkillsDir(), name, "SKILL.md");
|
|
17
|
+
if (!existsSync(path)) {
|
|
18
|
+
throw new CliError(`Bundled skill "${name}" not found at ${path}`);
|
|
19
|
+
}
|
|
20
|
+
return readFileSync(path, "utf8");
|
|
21
|
+
}
|
|
22
|
+
export function bundledSkillHash(name) {
|
|
23
|
+
return createHash("sha256").update(bundledSkillSource(name)).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
/** Install all bundled skills into `targetDir` (e.g. ~/.agents/skills). Idempotent. */
|
|
26
|
+
export function installSkills(targetDir) {
|
|
27
|
+
const results = [];
|
|
28
|
+
for (const name of SKILL_NAMES) {
|
|
29
|
+
const dest = join(targetDir, name, "SKILL.md");
|
|
30
|
+
const src = bundledSkillSource(name);
|
|
31
|
+
let action = "installed";
|
|
32
|
+
if (existsSync(dest)) {
|
|
33
|
+
const cur = readFileSync(dest, "utf8");
|
|
34
|
+
if (cur === src)
|
|
35
|
+
action = "unchanged";
|
|
36
|
+
else
|
|
37
|
+
action = "updated";
|
|
38
|
+
}
|
|
39
|
+
if (action !== "unchanged") {
|
|
40
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
41
|
+
writeFileSync(dest, src);
|
|
42
|
+
}
|
|
43
|
+
results.push({ name, path: dest, action });
|
|
44
|
+
}
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
/** Remove only the kelocan skills from `targetDir`. Returns removed paths. */
|
|
48
|
+
export function removeSkills(targetDir) {
|
|
49
|
+
const removed = [];
|
|
50
|
+
for (const name of SKILL_NAMES) {
|
|
51
|
+
const dir = join(targetDir, name);
|
|
52
|
+
if (existsSync(dir)) {
|
|
53
|
+
rmSync(dir, { recursive: true });
|
|
54
|
+
removed.push(dir);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return removed;
|
|
58
|
+
}
|
|
59
|
+
/** Compare an installed SKILL.md against the bundled copy. */
|
|
60
|
+
export function skillStatus(targetDir, name) {
|
|
61
|
+
const dest = join(targetDir, name, "SKILL.md");
|
|
62
|
+
if (!existsSync(dest))
|
|
63
|
+
return "missing";
|
|
64
|
+
const cur = readFileSync(dest, "utf8");
|
|
65
|
+
if (cur === bundledSkillSource(name))
|
|
66
|
+
return "current";
|
|
67
|
+
// Differ only by our own version marker? treat as outdated; else modified.
|
|
68
|
+
return cur.includes("name: " + name) ? "outdated" : "modified";
|
|
69
|
+
}
|
|
70
|
+
/** All skills present under a bundled skills dir (for `npx skills add` parity). */
|
|
71
|
+
export function bundledSkillNames() {
|
|
72
|
+
const dir = bundledSkillsDir();
|
|
73
|
+
if (!existsSync(dir))
|
|
74
|
+
return [];
|
|
75
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
76
|
+
.filter((d) => d.isDirectory() && existsSync(join(dir, d.name, "SKILL.md")))
|
|
77
|
+
.map((d) => d.name);
|
|
78
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kelocan",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Kelocan CLI — thin client & agent workflow commands for the Kelocan kanban board",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"kelocan": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"skills",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
21
|
+
"test": "tsx --test test/*.test.ts",
|
|
22
|
+
"test:e2e": "npm run build && tsx --test test/e2e.test.ts",
|
|
23
|
+
"prepublishOnly": "npm run build && npm test"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^22.10.0",
|
|
27
|
+
"tsx": "^4.19.2",
|
|
28
|
+
"typescript": "^5.7.2"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kelocan
|
|
3
|
+
description: Work on tasks from a Kelocan kanban board. Use when the user asks to work on a Kelocan task/card, fetch a task's agent prompt, report progress, move cards between columns, manage sub-tasks, or run the agent workflow — via the kelocan MCP server or the `kelocan` CLI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Kelocan
|
|
7
|
+
|
|
8
|
+
Kelocan is a kanban board for orchestrating AI-agent work. You can reach it two ways:
|
|
9
|
+
|
|
10
|
+
- **MCP server** `kelocan` (streamable HTTP, OAuth 2.1) — installed by `kelocan init`.
|
|
11
|
+
- **CLI** `kelocan` — a thin REST client (`npx kelocan <cmd>`), same capabilities.
|
|
12
|
+
|
|
13
|
+
Both are equivalent; use whichever is available in this environment. The board,
|
|
14
|
+
task ids and rules below are placeholders — always follow the task's own prompt.
|
|
15
|
+
|
|
16
|
+
## Golden rule: the task prompt is the source of truth
|
|
17
|
+
|
|
18
|
+
This skill only teaches the mechanics of reaching the board. **It deliberately
|
|
19
|
+
does not define the work rules** (columns, branching, PRs, review flow). Those
|
|
20
|
+
rules live in the task's rendered agent prompt:
|
|
21
|
+
|
|
22
|
+
- MCP: call `get_agent_prompt` with the `task_id`.
|
|
23
|
+
- CLI: `kelocan task prompt <task-id>`.
|
|
24
|
+
|
|
25
|
+
Fetch that prompt first, then follow it **verbatim**. A typical prompt instructs
|
|
26
|
+
the agent to (names may differ per board):
|
|
27
|
+
|
|
28
|
+
1. `get_board` / `kelocan board show <board>` to learn the columns.
|
|
29
|
+
2. `move_task` → the "active" column, then `start_agent_run` (keep the `run_id`).
|
|
30
|
+
CLI: `kelocan task move <id> --to active`, `kelocan run start <id> --agent "<name>"`.
|
|
31
|
+
3. Work on the task. Report progress periodically with `add_comment` /
|
|
32
|
+
`kelocan task comment <id> -m "..."`. Log every bug found as a new task,
|
|
33
|
+
don't fix out of scope.
|
|
34
|
+
4. On success: open a PR per the prompt's rules, `finish_agent_run`
|
|
35
|
+
(`succeeded`), then `move_task` → "Review".
|
|
36
|
+
On blocked: `add_comment` the question, `finish_agent_run` (`failed`), leave
|
|
37
|
+
the task in place.
|
|
38
|
+
|
|
39
|
+
## Useful CLI reference
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
kelocan login # browser auth (or --api-key klc_...)
|
|
43
|
+
kelocan board show <board> # columns & task counts
|
|
44
|
+
kelocan task list <board> # --column / --label / --sprint filters
|
|
45
|
+
kelocan task show <id> # detail
|
|
46
|
+
kelocan task prompt <id> # the agent prompt — fetch this first
|
|
47
|
+
kelocan run start <id> --agent N # prints run_id
|
|
48
|
+
kelocan run finish <run> --outcome succeeded -m "summary"
|
|
49
|
+
kelocan task comment <id> -m "..." # progress report
|
|
50
|
+
kelocan task move <id> --to review # column name, role, or uuid
|
|
51
|
+
kelocan subtask list|toggle
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Every command supports `--json` and `--fields a,b,c` for low-token output.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: kelocan-review
|
|
3
|
+
description: Review tasks sitting in the Review column of a Kelocan board. Use when the user says "review", "review task(s)", or asks to check the work an agent produced on the Kelocan board. Always fetch the official review prompt via get_agent_prompt / `kelocan task prompt` — never invent review rules.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Kelocan Review
|
|
7
|
+
|
|
8
|
+
When asked to review Kelocan work:
|
|
9
|
+
|
|
10
|
+
1. **Find the tasks.** List the board's Review column:
|
|
11
|
+
- CLI: `kelocan task list <board> --column review`
|
|
12
|
+
- MCP: `list_tasks` with the review column name.
|
|
13
|
+
("Review" without context → review every task in that column, one by one.)
|
|
14
|
+
|
|
15
|
+
2. **Fetch the official review prompt for each task.** The server renders the
|
|
16
|
+
*review* variant of the agent prompt automatically when the task sits in a
|
|
17
|
+
`review`-role column:
|
|
18
|
+
- MCP: `get_agent_prompt` with the `task_id`.
|
|
19
|
+
- CLI: `kelocan task prompt <task-id>`.
|
|
20
|
+
|
|
21
|
+
3. **Execute that prompt verbatim.** It defines the review checklist, how to
|
|
22
|
+
inspect the PR/evidence, how to write findings as task comments, and where
|
|
23
|
+
to move the card afterwards (back to active with feedback, or to Done).
|
|
24
|
+
|
|
25
|
+
4. **One task at a time.** Finish each review (comment + column move) before
|
|
26
|
+
starting the next. Don't batch-approve.
|
|
27
|
+
|
|
28
|
+
## What this skill deliberately does NOT contain
|
|
29
|
+
|
|
30
|
+
Acceptance criteria, code-review checklists, and merge rules belong to the
|
|
31
|
+
task's own prompt and project conventions. If the review prompt is missing or
|
|
32
|
+
unclear, comment on the task asking for clarification instead of guessing.
|