@evanbobo/colco 0.0.0-stage → 0.12.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/src/cli.mjs ADDED
@@ -0,0 +1,295 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { AGENTS, agentByName, currentAgent } from "./agents/index.mjs";
4
+ import { callTool, ColcoError, VERSION } from "./api.mjs";
5
+ import { configPath, normaliseServer, readConfig, resolveConnection, TOKEN_ENV, writeConfig } from "./config.mjs";
6
+ import { homeDir } from "./home.mjs";
7
+ import { applyInstall, applyRemove, inspect, planInstall } from "./install.mjs";
8
+ import { cliConfirmation, readMarkers, recordMarker, TASK_STATUSES } from "./usage/markers.mjs";
9
+ import { buildReports, sendReports, totalTokens } from "./usage/report.mjs";
10
+ import { ask, askSecret, choose, multiSelect } from "./prompt.mjs";
11
+
12
+ /*
13
+ * The colco command. `add`, `list` and `remove` manage the skill for the chosen agents; `task start|done`
14
+ * move a task, record where it starts or ends in Colco's marker file and, on done, report the session's
15
+ * usage; `report` reports (or previews) without moving anything; `call` runs any Colco tool with the saved
16
+ * API key.
17
+ */
18
+
19
+ const HELP = `colco ${VERSION} — Colco(叩叩)协作的 Agent Skill 与用量上报
20
+
21
+ 用法:
22
+ colco add [-a <agent>]... [-p] [--copy] [--server <url>] [-y] [--force]
23
+ 安装 Skill;不带 -a 时列出可选 Agent 供勾选
24
+ colco list [-p] 查看各 Agent 的安装情况
25
+ colco remove [-a <agent>]... [-p]
26
+ 卸载 Skill
27
+ colco task start <id> 把任务改为进行中,开始记录用量
28
+ colco task done <id> [--status review|blocked|todo|done]
29
+ 结束任务(默认 done),并上报本会话的模型与 Token
30
+ colco report [--task <id>] [--dry-run] [--agent <name> --session <id>]
31
+ 重新统计本会话并上报;--dry-run 只预览
32
+ colco call <tool> ['<json>'] 用已保存的 API Key 调用任意 Colco 工具
33
+ colco whoami 检查连接
34
+
35
+ 选项:
36
+ -a, --agent <name> ${AGENTS.map((agent) => agent.name).join("、")}(可重复)
37
+ -p, --project 安装到当前项目(默认全局,对所有项目生效)
38
+ --copy 复制文件而不是创建链接
39
+ -y, --yes 不提问,使用默认值
40
+ --server <url> Colco 服务地址(也可设置 COLCO_SERVER)
41
+ API Key 读取 ${TOKEN_ENV},或 colco add 时保存在 ~/.colco/config.json(仅本人可读)。`;
42
+
43
+ /** Splits argv into positionals and flags; repeated flags collect into arrays. */
44
+ export function parseArgs(argv) {
45
+ const positionals = [];
46
+ const flags = { agent: [] };
47
+ const valued = new Map([["-a", "agent"], ["--agent", "agent"], ["--server", "server"], ["--task", "task"], ["--session", "session"], ["--status", "status"]]);
48
+ const booleans = new Map([["-p", "project"], ["--project", "project"], ["-g", "global"], ["--global", "global"], ["--copy", "copy"], ["-y", "yes"], ["--yes", "yes"], ["--force", "force"], ["--dry-run", "dryRun"], ["-h", "help"], ["--help", "help"], ["-v", "version"], ["--version", "version"]]);
49
+ for (let index = 0; index < argv.length; index += 1) {
50
+ const arg = argv[index];
51
+ const [name, inline] = arg.startsWith("--") && arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, undefined];
52
+ if (valued.has(name)) {
53
+ const value = inline ?? argv[(index += 1)];
54
+ if (value === undefined) throw new ColcoError(`${name} 需要一个值`);
55
+ const key = valued.get(name);
56
+ if (key === "agent") flags.agent.push(...value.split(",").filter(Boolean));
57
+ else flags[key] = value;
58
+ } else if (booleans.has(name)) {
59
+ flags[booleans.get(name)] = true;
60
+ } else if (arg.startsWith("-") && arg !== "-") {
61
+ throw new ColcoError(`未知选项:${arg}`);
62
+ } else {
63
+ positionals.push(arg);
64
+ }
65
+ }
66
+ return { positionals, flags };
67
+ }
68
+
69
+ /** A positive task id from text. */
70
+ function taskIdOf(value) {
71
+ const id = Number(value);
72
+ if (!/^\d+$/.test(String(value ?? "")) || !Number.isSafeInteger(id) || id < 1) throw new ColcoError("任务 id 必须是正整数");
73
+ return id;
74
+ }
75
+
76
+ /** Short token count: 6.4M, 78.2K. */
77
+ function short(n) {
78
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
79
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
80
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
81
+ return String(n);
82
+ }
83
+
84
+ /** The agents named on the command line, validated. */
85
+ function namedAgents(names) {
86
+ return names.map((name) => {
87
+ const agent = agentByName(name);
88
+ if (!agent) throw new ColcoError(`不支持的 Agent:${name}(可选 ${AGENTS.map((candidate) => candidate.name).join("、")})`);
89
+ return agent;
90
+ });
91
+ }
92
+
93
+ /** `colco add`: choose agents, scope and method, connect, then install. */
94
+ async function add(flags, io) {
95
+ const interactive = !flags.yes && process.stdin.isTTY;
96
+ let agents = namedAgents(flags.agent);
97
+ if (agents.length === 0) {
98
+ const detected = AGENTS.filter((agent) => agent.detect());
99
+ if (interactive) {
100
+ const picked = await multiSelect("为哪些 Agent 安装 Colco Skill?", AGENTS.map((agent) => ({
101
+ value: agent.name, label: agent.displayName, hint: agent.detect() ? "已检测到" : "未检测到", selected: agent.detect(),
102
+ })));
103
+ agents = namedAgents(picked);
104
+ } else {
105
+ agents = detected;
106
+ }
107
+ }
108
+ if (agents.length === 0) throw new ColcoError("没有选择 Agent:用 -a claude-code 或 -a codex 指定");
109
+
110
+ let scope = flags.project ? "project" : "global";
111
+ if (interactive && !flags.project && !flags.global) {
112
+ scope = await choose("安装范围", [{ value: "global", label: "全局:所有项目都能用(推荐)" }, { value: "project", label: "仅当前项目" }]);
113
+ }
114
+ let method = flags.copy ? "copy" : "symlink";
115
+ if (interactive && !flags.copy) {
116
+ method = await choose("安装方式", [{ value: "symlink", label: "链接到同一份副本,更新一次全部生效(推荐)" }, { value: "copy", label: "每个 Agent 各复制一份" }]);
117
+ }
118
+
119
+ const saved = readConfig();
120
+ let server = normaliseServer(flags.server) ?? resolveConnection().server;
121
+ if (!server && interactive) server = normaliseServer(await ask("Colco 服务地址,例如 https://colco.example.com"));
122
+ if (!server) throw new ColcoError("缺少 Colco 服务地址:加 --server <url>");
123
+ let token = process.env[TOKEN_ENV] || saved.token || null;
124
+ let tokenEntered = false;
125
+ if (!token && interactive) {
126
+ token = await askSecret("API Key(在 Colco 侧边栏「协作 → Agent」页创建,以 colco_ 开头)");
127
+ tokenEntered = true;
128
+ }
129
+ if (!token) throw new ColcoError(`缺少 API Key:设置 ${TOKEN_ENV},或在终端中运行 colco add`);
130
+ const me = await callTool({ server, token }, "whoami", {}, io.fetch);
131
+ writeConfig({ ...saved, server, ...(tokenEntered ? { token } : {}) });
132
+ io.out(`已连接:${me.displayName || me.username}`);
133
+
134
+ const response = await io.fetch(`${server}/api/agent/skill.md`).catch((error) => {
135
+ throw new ColcoError(`下载 Skill 失败:${error.message}`);
136
+ });
137
+ if (!response.ok) throw new ColcoError(`下载 Skill 失败:HTTP ${response.status}`);
138
+ const plan = planInstall({ agents, scope, method });
139
+ const { results, canonicalBackup } = applyInstall(plan, await response.text(), { force: flags.force === true });
140
+ io.out(`Skill 副本:${plan.canonical}`);
141
+ if (canonicalBackup) io.out(`原有的 ${plan.canonical} 已移到 ${canonicalBackup}`);
142
+ for (const result of results) {
143
+ const how = result.action === "canonical" ? "直接使用该副本" : result.action === "symlink" ? `链接 ${result.dir}` : `复制到 ${result.dir}`;
144
+ io.out(`${result.ok ? "✓" : "✗"} ${result.displayName}:${result.ok ? how : `${result.dir} ${result.message}`}`);
145
+ if (result.backup) io.out(` 原有的 ${result.dir} 已移到 ${result.backup}`);
146
+ }
147
+ const legacy = join(homeDir(), ".claude", "skills", "analytics-collab");
148
+ if (existsSync(legacy)) io.out(`提示:发现改名前的旧 Skill ${legacy},确认不再需要后可删除。`);
149
+ io.out(`配置:${configPath()}(${tokenEntered ? "已保存 API Key,仅本人可读" : `API Key 读取 ${TOKEN_ENV}`})`);
150
+ io.out("重启 Agent 后生效。完成任务时运行 colco task done <id> 会自动上报用了哪些模型、多少 Token。");
151
+ return results.every((result) => result.ok) ? 0 : 1;
152
+ }
153
+
154
+ /** `colco list` and `colco remove`. */
155
+ function listOrRemove(command, flags, io) {
156
+ const scope = flags.project ? "project" : "global";
157
+ const agents = flags.agent.length > 0 ? namedAgents(flags.agent) : AGENTS;
158
+ const plan = planInstall({ agents, scope });
159
+ if (command === "list") {
160
+ io.out(`范围:${scope === "project" ? "当前项目" : "全局"};副本:${plan.canonical}`);
161
+ const labels = { linked: "已安装(链接)", installed: "已安装", missing: "未安装", other: "有同名的其他 Skill" };
162
+ for (const target of inspect(plan)) {
163
+ const agent = agentByName(target.agent);
164
+ io.out(`${target.displayName.padEnd(12)} ${labels[target.state]}${agent.detect() ? "" : "(未检测到该 Agent)"}`);
165
+ }
166
+ return 0;
167
+ }
168
+ const removed = applyRemove(plan, { keepCanonical: flags.agent.length > 0 && flags.agent.length < AGENTS.length });
169
+ io.out(removed.length > 0 ? removed.map((path) => `已删除 ${path}`).join("\n") : "没有需要删除的内容");
170
+ return 0;
171
+ }
172
+
173
+ /**
174
+ * The agent, session log and session id this command belongs to, from flags or from the environment the agent
175
+ * gave this shell; null outside a recognisable agent session.
176
+ */
177
+ function resolveSession(flags) {
178
+ if (flags.agent.length > 0) {
179
+ const [agent] = namedAgents(flags.agent.slice(0, 1));
180
+ const path = flags.session ? agent.findSession(flags.session) : agent.currentSession();
181
+ if (!path) throw new ColcoError(`找不到 ${agent.displayName} 的会话${flags.session ? ` ${flags.session}` : ""}`);
182
+ return { agent, path, sessionId: agent.sessionId(path) };
183
+ }
184
+ const agent = currentAgent();
185
+ const path = agent?.currentSession();
186
+ return agent && path ? { agent, path, sessionId: agent.sessionId(path) } : null;
187
+ }
188
+
189
+ /** Prints the reports of a session. */
190
+ function printReports(built, io) {
191
+ for (const report of built.reports) {
192
+ const calls = report.models.reduce((sum, entry) => sum + entry.calls, 0);
193
+ io.out(`任务 ${report.taskId}:${short(totalTokens(report.models))} Token,${calls} 次调用`);
194
+ for (const entry of report.models) {
195
+ io.out(` ${entry.model} 调用 ${entry.calls} 输入 ${short(entry.input_tokens)} 输出 ${short(entry.output_tokens)} 缓存读 ${short(entry.cache_read_tokens)} 缓存写 ${short(entry.cache_write_tokens)}`);
196
+ }
197
+ }
198
+ if (built.unassigned.length > 0) io.out(`不属于任何任务:${short(totalTokens(built.unassigned))} Token`);
199
+ }
200
+
201
+ /** Reads the session and its markers, builds the reports and sends them unless this is a dry run. */
202
+ async function report(flags, io, { onlyTask = null, found = resolveSession(flags) } = {}) {
203
+ if (!found) {
204
+ io.out("colco: 未上报用量:当前不在可识别的 Agent 会话中(用 --agent 和 --session 指定)");
205
+ return flags.dryRun ? 1 : 0;
206
+ }
207
+ const session = found.agent.read(found.path);
208
+ const markers = readMarkers({ agent: found.agent.name, session: found.sessionId });
209
+ const built = buildReports({ ...session, sessionId: found.sessionId }, markers, { onlyTask });
210
+ io.out(`colco: ${found.agent.name} 会话 ${built.sessionId}`);
211
+ if (built.reports.length === 0) {
212
+ io.out("这个会话还没有开始过任务(colco task start <id>)");
213
+ return 0;
214
+ }
215
+ printReports(built, io);
216
+ if (flags.dryRun) return 0;
217
+ const results = await sendReports(resolveConnection(), found.agent.name, built, io.fetch);
218
+ for (const result of results) io.out(result.ok ? `已上报任务 ${result.taskId}` : `任务 ${result.taskId} 上报失败:${result.message}`);
219
+ return results.every((result) => result.ok) ? 0 : 1;
220
+ }
221
+
222
+ /** `colco task start|done <id>`. */
223
+ async function task(positionals, flags, io) {
224
+ const [action, rawId] = positionals;
225
+ if (action !== "start" && action !== "done") throw new ColcoError("用法:colco task start <id> 或 colco task done <id>");
226
+ const taskId = taskIdOf(rawId);
227
+ const status = action === "start" ? "in_progress" : flags.status ?? "done";
228
+ if (action === "done" && (!TASK_STATUSES.has(status) || status === "in_progress")) throw new ColcoError("--status 只能是 done、review、blocked、todo 或 backlog");
229
+ const found = resolveSession(flags);
230
+ const data = await callTool(resolveConnection(), "update_task", { task_id: taskId, status }, io.fetch);
231
+ const marker = found ? recordMarker({ agent: found.agent.name, session: found.sessionId, taskId, status: data.task.status }) : null;
232
+ io.out(cliConfirmation(taskId, data.task.status, marker?.id));
233
+ io.out(`「${data.task.title}」`);
234
+ if (!marker) io.out("colco: 未记录用量:当前不在可识别的 Agent 会话中(用 --agent 和 --session 指定)");
235
+ if (action === "start" || !marker) return 0;
236
+ return report(flags, io, { found });
237
+ }
238
+
239
+ /** Runs the CLI; returns the exit code. `io` carries output and the fetch used for every request. */
240
+ export async function main(argv, output = {}) {
241
+ const io = {
242
+ out: (line) => process.stdout.write(`${line}\n`),
243
+ err: (line) => process.stderr.write(`${line}\n`),
244
+ fetch: (...args) => fetch(...args),
245
+ ...output,
246
+ };
247
+ try {
248
+ const { positionals, flags } = parseArgs(argv);
249
+ const [command, ...rest] = positionals;
250
+ if (flags.version) {
251
+ io.out(VERSION);
252
+ return 0;
253
+ }
254
+ if (!command || flags.help || command === "help") {
255
+ io.out(HELP);
256
+ return 0;
257
+ }
258
+ switch (command) {
259
+ case "add":
260
+ case "install":
261
+ return await add(flags, io);
262
+ case "list":
263
+ case "ls":
264
+ case "remove":
265
+ case "rm":
266
+ return listOrRemove(command === "ls" ? "list" : command === "rm" ? "remove" : command, flags, io);
267
+ case "task":
268
+ return await task(rest, flags, io);
269
+ case "report":
270
+ return await report(flags, io, { onlyTask: flags.task === undefined ? null : taskIdOf(flags.task) });
271
+ case "whoami": {
272
+ const me = await callTool(resolveConnection(), "whoami", {}, io.fetch);
273
+ io.out(`${me.displayName || me.username}(${me.username})`);
274
+ return 0;
275
+ }
276
+ case "call": {
277
+ const [tool, json = "{}"] = rest;
278
+ if (!tool) throw new ColcoError("用法:colco call <tool> '<json>'");
279
+ let args;
280
+ try {
281
+ args = JSON.parse(json);
282
+ } catch {
283
+ throw new ColcoError("参数必须是 JSON");
284
+ }
285
+ io.out(JSON.stringify(await callTool(resolveConnection(), tool, args, io.fetch), null, 2));
286
+ return 0;
287
+ }
288
+ default:
289
+ throw new ColcoError(`未知命令:${command}(运行 colco help 查看用法)`);
290
+ }
291
+ } catch (error) {
292
+ io.err(`colco: ${error instanceof Error ? error.message : String(error)}`);
293
+ return 1;
294
+ }
295
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,59 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homeDir } from "./home.mjs";
4
+
5
+ /*
6
+ * Where to reach Colco and with which API key. The environment wins over the saved file, so a key can be
7
+ * kept out of the file entirely. The file is created readable by its owner only. The key is never printed.
8
+ */
9
+
10
+ export const TOKEN_ENV = "COLCO_AGENT_TOKEN";
11
+ export const SERVER_ENV = "COLCO_SERVER";
12
+
13
+ /** Colco's own folder: the configuration and installer backups. */
14
+ export function colcoHome(env = process.env) {
15
+ return env.COLCO_HOME || join(homeDir(env), ".colco");
16
+ }
17
+
18
+ /** Path of the saved configuration. */
19
+ export function configPath(env = process.env) {
20
+ return join(colcoHome(env), "config.json");
21
+ }
22
+
23
+ /** The saved configuration, or an empty one. */
24
+ export function readConfig(env = process.env) {
25
+ const path = configPath(env);
26
+ if (!existsSync(path)) return {};
27
+ try {
28
+ const value = JSON.parse(readFileSync(path, "utf8"));
29
+ return value && typeof value === "object" ? value : {};
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ /** Saves the configuration with owner-only permissions. */
36
+ export function writeConfig(config, env = process.env) {
37
+ const path = configPath(env);
38
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
39
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
40
+ chmodSync(path, 0o600);
41
+ }
42
+
43
+ /** Normalises a server address to an origin without a trailing slash, or null when it is not http(s). */
44
+ export function normaliseServer(value) {
45
+ if (typeof value !== "string" || !value.trim()) return null;
46
+ try {
47
+ const url = new URL(value.trim());
48
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
49
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** The effective server and API key. */
56
+ export function resolveConnection(env = process.env) {
57
+ const saved = readConfig(env);
58
+ return { server: normaliseServer(env[SERVER_ENV]) ?? normaliseServer(saved.server), token: env[TOKEN_ENV] || saved.token || null };
59
+ }
package/src/home.mjs ADDED
@@ -0,0 +1,6 @@
1
+ import { homedir } from "node:os";
2
+
3
+ /** The user's home folder from `env` (HOME, then USERPROFILE), so callers and tests can point it elsewhere. */
4
+ export function homeDir(env = process.env) {
5
+ return env.HOME || env.USERPROFILE || homedir();
6
+ }
@@ -0,0 +1,164 @@
1
+ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { colcoHome } from "./config.mjs";
3
+ import { homeDir } from "./home.mjs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /*
8
+ * Installs the Colco skill the way the skills CLI does: one canonical copy in `.agents/skills/colco`
9
+ * (global under the home directory, or inside the project), and each selected agent's skills folder linked
10
+ * to it, or given its own copy. The canonical copy holds SKILL.md and this package under scripts/, so the
11
+ * skill runs `colco` locally without downloading anything. Only folders that are ours are replaced.
12
+ */
13
+
14
+ export const SKILL_NAME = "colco";
15
+ /** The command the server's SKILL.md uses; the installed copy runs the bundled script instead. */
16
+ export const NPX_COMMAND = "npx -y @evanbobo/colco";
17
+ export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
18
+
19
+ /** The canonical skill folder for a scope. */
20
+ export function canonicalDir(scope, cwd = process.cwd(), env = process.env) {
21
+ return scope === "project" ? join(cwd, ".agents", "skills", SKILL_NAME) : join(homeDir(env), ".agents", "skills", SKILL_NAME);
22
+ }
23
+
24
+ /** The skill folder of one agent for a scope. */
25
+ export function agentSkillDir(agent, scope, cwd = process.cwd(), env = process.env) {
26
+ return scope === "project" ? join(cwd, agent.projectSkillsDir, SKILL_NAME) : join(agent.globalSkillsDir(env), SKILL_NAME);
27
+ }
28
+
29
+ /** Where everything goes; agents whose folder is the canonical one need no link. */
30
+ export function planInstall({ agents, scope = "global", method = "symlink", cwd = process.cwd(), env = process.env }) {
31
+ const canonical = canonicalDir(scope, cwd, env);
32
+ return {
33
+ canonical,
34
+ scope,
35
+ targets: agents.map((agent) => {
36
+ const dir = agentSkillDir(agent, scope, cwd, env);
37
+ return { agent: agent.name, displayName: agent.displayName, dir, action: resolve(dir) === resolve(canonical) ? "canonical" : method };
38
+ }),
39
+ };
40
+ }
41
+
42
+ /** Points the skill's commands at the bundled script. */
43
+ export function localizeSkill(markdown, scriptPath) {
44
+ return markdown.split(NPX_COMMAND).join(`node "${scriptPath}"`);
45
+ }
46
+
47
+ /**
48
+ * Whether a path is a Colco skill folder (its SKILL.md is named colco) or a link to the canonical copy or to
49
+ * another Colco folder, so replacing it loses nothing of the user's. Other links, broken or looping ones
50
+ * included, are not ours.
51
+ */
52
+ function isOurs(path, canonical, depth = 0) {
53
+ try {
54
+ if (lstatSync(path).isSymbolicLink()) {
55
+ const target = resolve(dirname(path), readlinkSync(path));
56
+ if (target === resolve(canonical)) return true;
57
+ return depth < 8 && existsSync(target) && isOurs(target, canonical, depth + 1);
58
+ }
59
+ const skill = join(path, "SKILL.md");
60
+ return existsSync(skill) && /^name:\s*colco\s*$/m.test(readFileSync(skill, "utf8").slice(0, 600));
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /** Whether anything exists at a path, including a broken link. */
67
+ function present(path) {
68
+ try {
69
+ lstatSync(path);
70
+ return true;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /** Moves a folder that is not ours into ~/.colco/backups and returns where it went. */
77
+ function moveAside(path, env) {
78
+ const backups = join(colcoHome(env), "backups");
79
+ mkdirSync(backups, { recursive: true });
80
+ const name = relative(homeDir(env), path).replace(/[\\/:]+/g, "_").replace(/^_+/, "") || "colco";
81
+ let target = join(backups, `${name}-${Date.now()}`);
82
+ for (let n = 1; present(target); n += 1) target = join(backups, `${name}-${Date.now()}-${n}`);
83
+ try {
84
+ renameSync(path, target);
85
+ } catch {
86
+ cpSync(path, target, { recursive: true, verbatimSymlinks: true });
87
+ rmSync(path, { recursive: true, force: true });
88
+ }
89
+ return target;
90
+ }
91
+
92
+ /**
93
+ * Writes the canonical copy and links or copies it into each agent's folder. Returns one result per
94
+ * target; a folder that is not ours is left alone, or with `force` moved to ~/.colco/backups (never deleted).
95
+ */
96
+ export function applyInstall(plan, skillMarkdown, { force = false, env = process.env } = {}) {
97
+ const scriptPath = join(plan.canonical, "scripts", "bin", "colco.mjs");
98
+ let canonicalBackup = null;
99
+ if (present(plan.canonical) && !isOurs(plan.canonical, plan.canonical)) {
100
+ if (!force) throw new Error(`${plan.canonical} 已存在且不是 Colco 的 Skill;确认后加 --force(原目录会移到 ~/.colco/backups)`);
101
+ canonicalBackup = moveAside(plan.canonical, env);
102
+ }
103
+ rmSync(plan.canonical, { recursive: true, force: true });
104
+ mkdirSync(join(plan.canonical, "scripts"), { recursive: true });
105
+ for (const part of ["bin", "src", "package.json", "LICENSE", "NOTICE"]) {
106
+ const from = join(PACKAGE_ROOT, part);
107
+ if (existsSync(from)) cpSync(from, join(plan.canonical, "scripts", part), { recursive: true });
108
+ }
109
+ writeFileSync(join(plan.canonical, "SKILL.md"), localizeSkill(skillMarkdown, scriptPath));
110
+
111
+ const results = [];
112
+ for (const target of plan.targets) {
113
+ if (target.action === "canonical") {
114
+ results.push({ ...target, ok: true });
115
+ continue;
116
+ }
117
+ let backup = null;
118
+ if (present(target.dir) && !isOurs(target.dir, plan.canonical)) {
119
+ if (!force) {
120
+ results.push({ ...target, ok: false, message: "已存在且不是 Colco 的 Skill,未覆盖(加 --force 会把它移到 ~/.colco/backups)" });
121
+ continue;
122
+ }
123
+ backup = moveAside(target.dir, env);
124
+ }
125
+ rmSync(target.dir, { recursive: true, force: true });
126
+ mkdirSync(dirname(target.dir), { recursive: true });
127
+ let action = target.action;
128
+ if (action === "symlink") {
129
+ try {
130
+ symlinkSync(relative(dirname(target.dir), plan.canonical), target.dir, "dir");
131
+ } catch {
132
+ action = "copy";
133
+ }
134
+ }
135
+ if (action === "copy") cpSync(plan.canonical, target.dir, { recursive: true });
136
+ results.push({ ...target, action, ok: true, backup });
137
+ }
138
+ return { scriptPath, results, canonicalBackup };
139
+ }
140
+
141
+ /** Removes the agents' links or copies, and the canonical copy when no agent is left using it. */
142
+ export function applyRemove(plan, { keepCanonical = false } = {}) {
143
+ const removed = [];
144
+ for (const target of plan.targets) {
145
+ if (target.action === "canonical" || !present(target.dir) || !isOurs(target.dir, plan.canonical)) continue;
146
+ rmSync(target.dir, { recursive: true, force: true });
147
+ removed.push(target.dir);
148
+ }
149
+ if (!keepCanonical && present(plan.canonical) && isOurs(plan.canonical, plan.canonical)) {
150
+ rmSync(plan.canonical, { recursive: true, force: true });
151
+ removed.push(plan.canonical);
152
+ }
153
+ return removed;
154
+ }
155
+
156
+ /** Install state of each target: linked, copied, missing, or someone else's folder. */
157
+ export function inspect(plan) {
158
+ return plan.targets.map((target) => {
159
+ const dir = target.action === "canonical" ? plan.canonical : target.dir;
160
+ if (!present(dir)) return { ...target, state: "missing" };
161
+ if (!isOurs(dir, plan.canonical)) return { ...target, state: "other" };
162
+ return { ...target, state: lstatSync(dir).isSymbolicLink() ? "linked" : "installed" };
163
+ });
164
+ }
package/src/prompt.mjs ADDED
@@ -0,0 +1,83 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ /*
4
+ * Minimal terminal prompts with no dependencies: a numbered multi-select, a single choice, free text and a
5
+ * hidden secret. Every prompt has a non-interactive equivalent flag, so agents and CI never need a TTY.
6
+ */
7
+
8
+ /** Asks one line of text; Enter keeps the default. */
9
+ export async function ask(question, fallback = "") {
10
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
11
+ try {
12
+ const answer = (await rl.question(fallback ? `${question} (${fallback}): ` : `${question}: `)).trim();
13
+ return answer || fallback;
14
+ } finally {
15
+ rl.close();
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Numbered multi-select. `options` are `{ value, label, hint, selected }`; the answer is a list such as
21
+ * "1,3" or "all", and Enter keeps the preselected ones.
22
+ */
23
+ export async function multiSelect(question, options) {
24
+ process.stdout.write(`${question}\n`);
25
+ options.forEach((option, index) => {
26
+ process.stdout.write(` ${option.selected ? "[x]" : "[ ]"} ${index + 1}. ${option.label}${option.hint ? ` ${option.hint}` : ""}\n`);
27
+ });
28
+ for (;;) {
29
+ const answer = (await ask("输入编号,逗号分隔;all 表示全部;回车保留已勾选")).toLowerCase();
30
+ if (!answer) return options.filter((option) => option.selected).map((option) => option.value);
31
+ if (answer === "all") return options.map((option) => option.value);
32
+ const picked = answer.split(/[\s,,]+/).filter(Boolean).map(Number);
33
+ if (picked.length > 0 && picked.every((n) => Number.isInteger(n) && n >= 1 && n <= options.length)) {
34
+ return [...new Set(picked)].map((n) => options[n - 1].value);
35
+ }
36
+ process.stdout.write("编号无效,请重试。\n");
37
+ }
38
+ }
39
+
40
+ /** Single choice among `options` (`{ value, label }`); Enter takes the first. */
41
+ export async function choose(question, options) {
42
+ process.stdout.write(`${question}\n`);
43
+ options.forEach((option, index) => process.stdout.write(` ${index + 1}. ${option.label}${index === 0 ? "(默认)" : ""}\n`));
44
+ for (;;) {
45
+ const answer = await ask("输入编号", "1");
46
+ const n = Number(answer);
47
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1].value;
48
+ process.stdout.write("编号无效,请重试。\n");
49
+ }
50
+ }
51
+
52
+ /** Reads a secret without echoing it. */
53
+ export function askSecret(question) {
54
+ return new Promise((resolvePromise, reject) => {
55
+ const { stdin, stdout } = process;
56
+ if (!stdin.isTTY) {
57
+ reject(new Error("需要在终端中输入 API Key,或先设置 COLCO_AGENT_TOKEN"));
58
+ return;
59
+ }
60
+ stdout.write(`${question}: `);
61
+ stdin.setRawMode(true);
62
+ stdin.resume();
63
+ stdin.setEncoding("utf8");
64
+ let value = "";
65
+ const done = (error) => {
66
+ stdin.setRawMode(false);
67
+ stdin.pause();
68
+ stdin.off("data", onData);
69
+ stdout.write("\n");
70
+ if (error) reject(error);
71
+ else resolvePromise(value.trim());
72
+ };
73
+ const onData = (chunk) => {
74
+ for (const char of chunk) {
75
+ if (char === "\r" || char === "\n") return done();
76
+ if (char === "\u0003") return done(new Error("已取消"));
77
+ if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
78
+ else if (char >= " ") value += char;
79
+ }
80
+ };
81
+ stdin.on("data", onData);
82
+ });
83
+ }