@evanbobo/colco 0.0.0-stage → 0.18.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,301 @@
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 { project, runGit } from "./project.mjs";
9
+ import { cliConfirmation, readMarkers, recordMarker, TASK_STATUSES } from "./usage/markers.mjs";
10
+ import { buildReports, sendReports, totalTokens } from "./usage/report.mjs";
11
+ import { ask, askSecret, choose, multiSelect } from "./prompt.mjs";
12
+
13
+ /*
14
+ * The colco command. `add`, `list` and `remove` manage the skill for the chosen agents; `task start|done`
15
+ * move a task, record where it starts or ends in Colco's marker file and, on done, report the session's
16
+ * usage; `report` reports (or previews) without moving anything; `call` runs any Colco tool with the saved
17
+ * API key; `project` finds the projects that use this directory's git repository.
18
+ */
19
+
20
+ const HELP = `colco ${VERSION} — Colco(叩叩)协作的 Agent Skill 与用量上报
21
+
22
+ 用法:
23
+ colco add [-a <agent>]... [-p] [--copy] [--server <url>] [-y] [--force]
24
+ 安装 Skill;不带 -a 时列出可选 Agent 供勾选
25
+ colco list [-p] 查看各 Agent 的安装情况
26
+ colco remove [-a <agent>]... [-p]
27
+ 卸载 Skill
28
+ colco task start <id> 把任务改为进行中,开始记录用量
29
+ colco task done <id> [--status review|blocked|todo|done]
30
+ 结束任务(默认 done),并上报本会话的模型与 Token
31
+ colco report [--task <id>] [--dry-run] [--agent <name> --session <id>]
32
+ 重新统计本会话并上报;--dry-run 只预览
33
+ colco call <tool> ['<json>'] 用已保存的 API Key 调用任意 Colco 工具
34
+ colco project [--json] 按当前目录的 git 远程地址和分支找到对应的 Colco 项目
35
+ colco whoami 检查连接
36
+
37
+ 选项:
38
+ -a, --agent <name> ${AGENTS.map((agent) => agent.name).join("、")}(可重复)
39
+ -p, --project 安装到当前项目(默认全局,对所有项目生效)
40
+ --copy 复制文件而不是创建链接
41
+ -y, --yes 不提问,使用默认值
42
+ --server <url> Colco 服务地址(也可设置 COLCO_SERVER)
43
+ --json colco project 以 JSON 输出
44
+ API Key 读取 ${TOKEN_ENV},或 colco add 时保存在 ~/.colco/config.json(仅本人可读)。`;
45
+
46
+ /** Splits argv into positionals and flags; repeated flags collect into arrays. */
47
+ export function parseArgs(argv) {
48
+ const positionals = [];
49
+ const flags = { agent: [] };
50
+ const valued = new Map([["-a", "agent"], ["--agent", "agent"], ["--server", "server"], ["--task", "task"], ["--session", "session"], ["--status", "status"]]);
51
+ const booleans = new Map([["-p", "project"], ["--project", "project"], ["-g", "global"], ["--global", "global"], ["--copy", "copy"], ["-y", "yes"], ["--yes", "yes"], ["--force", "force"], ["--dry-run", "dryRun"], ["--json", "json"], ["-h", "help"], ["--help", "help"], ["-v", "version"], ["--version", "version"]]);
52
+ for (let index = 0; index < argv.length; index += 1) {
53
+ const arg = argv[index];
54
+ const [name, inline] = arg.startsWith("--") && arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, undefined];
55
+ if (valued.has(name)) {
56
+ const value = inline ?? argv[(index += 1)];
57
+ if (value === undefined) throw new ColcoError(`${name} 需要一个值`);
58
+ const key = valued.get(name);
59
+ if (key === "agent") flags.agent.push(...value.split(",").filter(Boolean));
60
+ else flags[key] = value;
61
+ } else if (booleans.has(name)) {
62
+ flags[booleans.get(name)] = true;
63
+ } else if (arg.startsWith("-") && arg !== "-") {
64
+ throw new ColcoError(`未知选项:${arg}`);
65
+ } else {
66
+ positionals.push(arg);
67
+ }
68
+ }
69
+ return { positionals, flags };
70
+ }
71
+
72
+ /** A positive task id from text. */
73
+ function taskIdOf(value) {
74
+ const id = Number(value);
75
+ if (!/^\d+$/.test(String(value ?? "")) || !Number.isSafeInteger(id) || id < 1) throw new ColcoError("任务 id 必须是正整数");
76
+ return id;
77
+ }
78
+
79
+ /** Short token count: 6.4M, 78.2K. */
80
+ function short(n) {
81
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
82
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
83
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
84
+ return String(n);
85
+ }
86
+
87
+ /** The agents named on the command line, validated. */
88
+ function namedAgents(names) {
89
+ return names.map((name) => {
90
+ const agent = agentByName(name);
91
+ if (!agent) throw new ColcoError(`不支持的 Agent:${name}(可选 ${AGENTS.map((candidate) => candidate.name).join("、")})`);
92
+ return agent;
93
+ });
94
+ }
95
+
96
+ /** `colco add`: choose agents, scope and method, connect, then install. */
97
+ async function add(flags, io) {
98
+ const interactive = !flags.yes && process.stdin.isTTY;
99
+ let agents = namedAgents(flags.agent);
100
+ if (agents.length === 0) {
101
+ const detected = AGENTS.filter((agent) => agent.detect());
102
+ if (interactive) {
103
+ const picked = await multiSelect("为哪些 Agent 安装 Colco Skill?", AGENTS.map((agent) => ({
104
+ value: agent.name, label: agent.displayName, hint: agent.detect() ? "已检测到" : "未检测到", selected: agent.detect(),
105
+ })));
106
+ agents = namedAgents(picked);
107
+ } else {
108
+ agents = detected;
109
+ }
110
+ }
111
+ if (agents.length === 0) throw new ColcoError("没有选择 Agent:用 -a claude-code 或 -a codex 指定");
112
+
113
+ let scope = flags.project ? "project" : "global";
114
+ if (interactive && !flags.project && !flags.global) {
115
+ scope = await choose("安装范围", [{ value: "global", label: "全局:所有项目都能用(推荐)" }, { value: "project", label: "仅当前项目" }]);
116
+ }
117
+ let method = flags.copy ? "copy" : "symlink";
118
+ if (interactive && !flags.copy) {
119
+ method = await choose("安装方式", [{ value: "symlink", label: "链接到同一份副本,更新一次全部生效(推荐)" }, { value: "copy", label: "每个 Agent 各复制一份" }]);
120
+ }
121
+
122
+ const saved = readConfig();
123
+ let server = normaliseServer(flags.server) ?? resolveConnection().server;
124
+ if (!server && interactive) server = normaliseServer(await ask("Colco 服务地址,例如 https://colco.example.com"));
125
+ if (!server) throw new ColcoError("缺少 Colco 服务地址:加 --server <url>");
126
+ let token = process.env[TOKEN_ENV] || saved.token || null;
127
+ let tokenEntered = false;
128
+ if (!token && interactive) {
129
+ token = await askSecret("API Key(在 Colco 侧边栏「协作 → Agent」页创建,以 colco_ 开头)");
130
+ tokenEntered = true;
131
+ }
132
+ if (!token) throw new ColcoError(`缺少 API Key:设置 ${TOKEN_ENV},或在终端中运行 colco add`);
133
+ const me = await callTool({ server, token }, "whoami", {}, io.fetch);
134
+ writeConfig({ ...saved, server, ...(tokenEntered ? { token } : {}) });
135
+ io.out(`已连接:${me.displayName || me.username}`);
136
+
137
+ const response = await io.fetch(`${server}/api/agent/skill.md`).catch((error) => {
138
+ throw new ColcoError(`下载 Skill 失败:${error.message}`);
139
+ });
140
+ if (!response.ok) throw new ColcoError(`下载 Skill 失败:HTTP ${response.status}`);
141
+ const plan = planInstall({ agents, scope, method });
142
+ const { results, canonicalBackup } = applyInstall(plan, await response.text(), { force: flags.force === true });
143
+ io.out(`Skill 副本:${plan.canonical}`);
144
+ if (canonicalBackup) io.out(`原有的 ${plan.canonical} 已移到 ${canonicalBackup}`);
145
+ for (const result of results) {
146
+ const how = result.action === "canonical" ? "直接使用该副本" : result.action === "symlink" ? `链接 ${result.dir}` : `复制到 ${result.dir}`;
147
+ io.out(`${result.ok ? "✓" : "✗"} ${result.displayName}:${result.ok ? how : `${result.dir} ${result.message}`}`);
148
+ if (result.backup) io.out(` 原有的 ${result.dir} 已移到 ${result.backup}`);
149
+ }
150
+ const legacy = join(homeDir(), ".claude", "skills", "analytics-collab");
151
+ if (existsSync(legacy)) io.out(`提示:发现改名前的旧 Skill ${legacy},确认不再需要后可删除。`);
152
+ io.out(`配置:${configPath()}(${tokenEntered ? "已保存 API Key,仅本人可读" : `API Key 读取 ${TOKEN_ENV}`})`);
153
+ io.out("重启 Agent 后生效。完成任务时运行 colco task done <id> 会自动上报用了哪些模型、多少 Token。");
154
+ return results.every((result) => result.ok) ? 0 : 1;
155
+ }
156
+
157
+ /** `colco list` and `colco remove`. */
158
+ function listOrRemove(command, flags, io) {
159
+ const scope = flags.project ? "project" : "global";
160
+ const agents = flags.agent.length > 0 ? namedAgents(flags.agent) : AGENTS;
161
+ const plan = planInstall({ agents, scope });
162
+ if (command === "list") {
163
+ io.out(`范围:${scope === "project" ? "当前项目" : "全局"};副本:${plan.canonical}`);
164
+ const labels = { linked: "已安装(链接)", installed: "已安装", missing: "未安装", other: "有同名的其他 Skill" };
165
+ for (const target of inspect(plan)) {
166
+ const agent = agentByName(target.agent);
167
+ io.out(`${target.displayName.padEnd(12)} ${labels[target.state]}${agent.detect() ? "" : "(未检测到该 Agent)"}`);
168
+ }
169
+ return 0;
170
+ }
171
+ const removed = applyRemove(plan, { keepCanonical: flags.agent.length > 0 && flags.agent.length < AGENTS.length });
172
+ io.out(removed.length > 0 ? removed.map((path) => `已删除 ${path}`).join("\n") : "没有需要删除的内容");
173
+ return 0;
174
+ }
175
+
176
+ /**
177
+ * The agent, session log and session id this command belongs to, from flags or from the environment the agent
178
+ * gave this shell; null outside a recognisable agent session.
179
+ */
180
+ function resolveSession(flags) {
181
+ if (flags.agent.length > 0) {
182
+ const [agent] = namedAgents(flags.agent.slice(0, 1));
183
+ const path = flags.session ? agent.findSession(flags.session) : agent.currentSession();
184
+ if (!path) throw new ColcoError(`找不到 ${agent.displayName} 的会话${flags.session ? ` ${flags.session}` : ""}`);
185
+ return { agent, path, sessionId: agent.sessionId(path) };
186
+ }
187
+ const agent = currentAgent();
188
+ const path = agent?.currentSession();
189
+ return agent && path ? { agent, path, sessionId: agent.sessionId(path) } : null;
190
+ }
191
+
192
+ /** Prints the reports of a session. */
193
+ function printReports(built, io) {
194
+ for (const report of built.reports) {
195
+ const calls = report.models.reduce((sum, entry) => sum + entry.calls, 0);
196
+ io.out(`任务 ${report.taskId}:${short(totalTokens(report.models))} Token,${calls} 次调用`);
197
+ for (const entry of report.models) {
198
+ io.out(` ${entry.model} 调用 ${entry.calls} 输入 ${short(entry.input_tokens)} 输出 ${short(entry.output_tokens)} 缓存读 ${short(entry.cache_read_tokens)} 缓存写 ${short(entry.cache_write_tokens)}`);
199
+ }
200
+ }
201
+ if (built.unassigned.length > 0) io.out(`不属于任何任务:${short(totalTokens(built.unassigned))} Token`);
202
+ }
203
+
204
+ /** Reads the session and its markers, builds the reports and sends them unless this is a dry run. */
205
+ async function report(flags, io, { onlyTask = null, found = resolveSession(flags) } = {}) {
206
+ if (!found) {
207
+ io.out("colco: 未上报用量:当前不在可识别的 Agent 会话中(用 --agent 和 --session 指定)");
208
+ return flags.dryRun ? 1 : 0;
209
+ }
210
+ const session = found.agent.read(found.path);
211
+ const markers = readMarkers({ agent: found.agent.name, session: found.sessionId });
212
+ const built = buildReports({ ...session, sessionId: found.sessionId }, markers, { onlyTask });
213
+ io.out(`colco: ${found.agent.name} 会话 ${built.sessionId}`);
214
+ if (built.reports.length === 0) {
215
+ io.out("这个会话还没有开始过任务(colco task start <id>)");
216
+ return 0;
217
+ }
218
+ printReports(built, io);
219
+ if (flags.dryRun) return 0;
220
+ const results = await sendReports(resolveConnection(), found.agent.name, built, io.fetch);
221
+ for (const result of results) io.out(result.ok ? `已上报任务 ${result.taskId}` : `任务 ${result.taskId} 上报失败:${result.message}`);
222
+ return results.every((result) => result.ok) ? 0 : 1;
223
+ }
224
+
225
+ /** `colco task start|done <id>`. */
226
+ async function task(positionals, flags, io) {
227
+ const [action, rawId] = positionals;
228
+ if (action !== "start" && action !== "done") throw new ColcoError("用法:colco task start <id> 或 colco task done <id>");
229
+ const taskId = taskIdOf(rawId);
230
+ const status = action === "start" ? "in_progress" : flags.status ?? "done";
231
+ if (action === "done" && (!TASK_STATUSES.has(status) || status === "in_progress")) throw new ColcoError("--status 只能是 done、review、blocked、todo 或 backlog");
232
+ const found = resolveSession(flags);
233
+ const data = await callTool(resolveConnection(), "update_task", { task_id: taskId, status }, io.fetch);
234
+ const marker = found ? recordMarker({ agent: found.agent.name, session: found.sessionId, taskId, status: data.task.status }) : null;
235
+ io.out(cliConfirmation(taskId, data.task.status, marker?.id));
236
+ io.out(`「${data.task.title}」`);
237
+ if (!marker) io.out("colco: 未记录用量:当前不在可识别的 Agent 会话中(用 --agent 和 --session 指定)");
238
+ if (action === "start" || !marker) return 0;
239
+ return report(flags, io, { found });
240
+ }
241
+
242
+ /** Runs the CLI; returns the exit code. `io` carries output, the fetch used for every request and the git runner. */
243
+ export async function main(argv, output = {}) {
244
+ const io = {
245
+ out: (line) => process.stdout.write(`${line}\n`),
246
+ err: (line) => process.stderr.write(`${line}\n`),
247
+ fetch: (...args) => fetch(...args),
248
+ git: runGit,
249
+ ...output,
250
+ };
251
+ try {
252
+ const { positionals, flags } = parseArgs(argv);
253
+ const [command, ...rest] = positionals;
254
+ if (flags.version) {
255
+ io.out(VERSION);
256
+ return 0;
257
+ }
258
+ if (!command || flags.help || command === "help") {
259
+ io.out(HELP);
260
+ return 0;
261
+ }
262
+ switch (command) {
263
+ case "add":
264
+ case "install":
265
+ return await add(flags, io);
266
+ case "list":
267
+ case "ls":
268
+ case "remove":
269
+ case "rm":
270
+ return listOrRemove(command === "ls" ? "list" : command === "rm" ? "remove" : command, flags, io);
271
+ case "task":
272
+ return await task(rest, flags, io);
273
+ case "report":
274
+ return await report(flags, io, { onlyTask: flags.task === undefined ? null : taskIdOf(flags.task) });
275
+ case "project":
276
+ return await project(flags, io, resolveConnection());
277
+ case "whoami": {
278
+ const me = await callTool(resolveConnection(), "whoami", {}, io.fetch);
279
+ io.out(`${me.displayName || me.username}(${me.username})`);
280
+ return 0;
281
+ }
282
+ case "call": {
283
+ const [tool, json = "{}"] = rest;
284
+ if (!tool) throw new ColcoError("用法:colco call <tool> '<json>'");
285
+ let args;
286
+ try {
287
+ args = JSON.parse(json);
288
+ } catch {
289
+ throw new ColcoError("参数必须是 JSON");
290
+ }
291
+ io.out(JSON.stringify(await callTool(resolveConnection(), tool, args, io.fetch), null, 2));
292
+ return 0;
293
+ }
294
+ default:
295
+ throw new ColcoError(`未知命令:${command}(运行 colco help 查看用法)`);
296
+ }
297
+ } catch (error) {
298
+ io.err(`colco: ${error instanceof Error ? error.message : String(error)}`);
299
+ return 1;
300
+ }
301
+ }
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
+ }
@@ -0,0 +1,78 @@
1
+ import { execFile } from "node:child_process";
2
+ import { callTool, ColcoError } from "./api.mjs";
3
+
4
+ /*
5
+ * `colco project`: which Colco projects use the code in this directory. It reads the git remote (origin,
6
+ * else the first remote) and the current branch, and asks Colco's find_projects_by_repository. Git runs
7
+ * through execFile with an argument list, never a shell, so nothing in a remote or branch name is executed.
8
+ */
9
+
10
+ const NOT_A_REPOSITORY = "当前目录不是 git 仓库:请在代码目录中运行 colco project";
11
+
12
+ /** Runs git with `args` in the current directory; resolves trimmed stdout, rejects with git's error. */
13
+ export function runGit(args) {
14
+ return new Promise((resolve, reject) => {
15
+ execFile("git", args, { encoding: "utf8", timeout: 10_000, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout) => {
16
+ if (error) reject(error);
17
+ else resolve(String(stdout).trim());
18
+ });
19
+ });
20
+ }
21
+
22
+ /**
23
+ * Removes `user:password@`, query and fragment from a URL-style remote, so no credential leaves the machine or
24
+ * reaches the screen. scp-style remotes (`git@host:path`) are left as they are; their user is not a secret.
25
+ */
26
+ export function stripCredentials(remote) {
27
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(remote)) return remote;
28
+ return remote.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/]*@/i, "$1").replace(/[?#].*$/, "");
29
+ }
30
+
31
+ /** The directory's remote URL and current branch (null when HEAD is detached or has no commit yet). */
32
+ export async function readGitContext(git) {
33
+ let inside;
34
+ try {
35
+ inside = await git(["rev-parse", "--is-inside-work-tree"]);
36
+ } catch (error) {
37
+ if (error?.code === "ENOENT") throw new ColcoError("没有找到 git 命令:请先安装 git");
38
+ throw new ColcoError(NOT_A_REPOSITORY);
39
+ }
40
+ if (inside !== "true") throw new ColcoError(NOT_A_REPOSITORY);
41
+ let remote = await git(["remote", "get-url", "origin"]).catch(() => "");
42
+ if (!remote) {
43
+ const [first] = (await git(["remote"]).catch(() => "")).split("\n").map((name) => name.trim()).filter(Boolean);
44
+ if (first) remote = await git(["remote", "get-url", "--", first]).catch(() => "");
45
+ }
46
+ if (!remote) throw new ColcoError("这个 git 仓库还没有远程地址(git remote),Colco 无法据此找到项目");
47
+ const head = await git(["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "");
48
+ return { remote: stripCredentials(remote), branch: head && head !== "HEAD" ? head : null };
49
+ }
50
+
51
+ /** Prints the projects that use this directory's repository, or why there are none; `--json` prints the data. */
52
+ export async function project(flags, io, connection) {
53
+ const { remote, branch } = await readGitContext(io.git);
54
+ const data = await callTool(connection, "find_projects_by_repository", { url: remote, ...(branch ? { branch } : {}) }, io.fetch);
55
+ if (flags.json) {
56
+ io.out(JSON.stringify({ remote, branch, ...data }, null, 2));
57
+ return 0;
58
+ }
59
+ io.out(`仓库:${data.repository?.url ?? remote}${branch ? `(当前分支 ${branch})` : ""}`);
60
+ if (!data.repository) {
61
+ io.out("这个仓库还没有登记到 Colco,没有项目使用它。");
62
+ io.out("用 colco call list_projects 查看你的项目;项目负责人可以用 link_repository 把仓库关联到项目。");
63
+ return 0;
64
+ }
65
+ if (data.projects.length === 0) {
66
+ io.out(`仓库「${data.repository.name}」已登记,但你能访问的项目都没有使用它。`);
67
+ io.out("用 colco call list_projects 查看你的项目。");
68
+ return 0;
69
+ }
70
+ const idWidth = Math.max(...data.projects.map((item) => String(item.projectId).length));
71
+ const nameWidth = Math.max(...data.projects.map((item) => item.projectName.length));
72
+ for (const item of data.projects) {
73
+ const archived = item.projectStatus === "archived" ? "(已归档)" : "";
74
+ io.out(`${item.branchMatched ? "*" : " "} ${String(item.projectId).padStart(idWidth)} ${item.projectName.padEnd(nameWidth)} ${item.branch}${archived}`);
75
+ }
76
+ if (data.projects.some((item) => item.branchMatched)) io.out("* 项目使用的就是当前分支");
77
+ return 0;
78
+ }