@yijiancaotang/cli 0.0.0-stage → 1.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.
Files changed (60) hide show
  1. package/README.md +311 -2
  2. package/bin/caotang.cjs +15 -0
  3. package/dist/agent-identity.js +14 -0
  4. package/dist/auth.js +340 -0
  5. package/dist/calendar-contract.js +358 -0
  6. package/dist/calendar-help.js +12 -0
  7. package/dist/calendar.js +64 -0
  8. package/dist/capability-result.js +85 -0
  9. package/dist/cli.js +257 -0
  10. package/dist/command-help.js +105 -0
  11. package/dist/commands.js +356 -0
  12. package/dist/config.js +14 -0
  13. package/dist/connection-copy.js +40 -0
  14. package/dist/context.js +30 -0
  15. package/dist/contracts.js +1 -0
  16. package/dist/create-bundle.js +103 -0
  17. package/dist/create-file-failure.js +18 -0
  18. package/dist/credential-store.js +594 -0
  19. package/dist/doctor.js +221 -0
  20. package/dist/errors.js +214 -0
  21. package/dist/generated/knowledge-growth.js +37 -0
  22. package/dist/habit-day.js +58 -0
  23. package/dist/habit-read.js +32 -0
  24. package/dist/habit-wire.js +87 -0
  25. package/dist/http.js +211 -0
  26. package/dist/input-validation.js +76 -0
  27. package/dist/installation.js +761 -0
  28. package/dist/knowledge-growth.js +85 -0
  29. package/dist/local-commands.js +229 -0
  30. package/dist/local-lock.js +245 -0
  31. package/dist/local-records.js +149 -0
  32. package/dist/local-types.js +3 -0
  33. package/dist/managed-skill-baseline.js +35 -0
  34. package/dist/missing-sections.js +72 -0
  35. package/dist/onboarding.js +48 -0
  36. package/dist/output.js +54 -0
  37. package/dist/pending-requests.js +45 -0
  38. package/dist/platforms.js +239 -0
  39. package/dist/query-daily.js +70 -0
  40. package/dist/query-search.js +43 -0
  41. package/dist/query-validation.js +45 -0
  42. package/dist/reviewed-managed-bundle-baseline.js +356 -0
  43. package/dist/schedule-contract.js +289 -0
  44. package/dist/schedule-help.js +35 -0
  45. package/dist/schedule-start-wire.js +80 -0
  46. package/dist/schedule.js +70 -0
  47. package/dist/skill-assets.js +136 -0
  48. package/dist/upload.js +130 -0
  49. package/dist/web-citations.js +68 -0
  50. package/dist/write-permissions.js +41 -0
  51. package/dist/write-routing.js +26 -0
  52. package/package.json +27 -4
  53. package/skills/caotang-context/SKILL.md +52 -0
  54. package/skills/caotang-first-connect/SKILL.md +74 -0
  55. package/skills/caotang-project-progress/SKILL.md +91 -0
  56. package/skills/caotang-save-knowledge/SKILL.md +42 -0
  57. package/skills/caotang-schedule/SKILL.md +54 -0
  58. package/skills/caotang-search/SKILL.md +79 -0
  59. package/skills/caotang-workday/SKILL.md +81 -0
  60. package/skills/manifest.json +33 -0
@@ -0,0 +1,761 @@
1
+ import { constants, lstatSync, fstatSync, openSync, closeSync, readSync, writeFileSync, fsyncSync, fchmodSync, ftruncateSync, mkdirSync, readdirSync, rmdirSync } from "node:fs";
2
+ import { access, lstat, stat, realpath, open, readdir } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { CliError, asRecord } from "./errors.js";
7
+ import { PLATFORM_IDS, isPlatformId, platformSupported } from "./local-types.js";
8
+ import { bindSkillAgent, completeSkillInventory } from "./skill-assets.js";
9
+ import { acquireLocalLock } from "./local-lock.js";
10
+ import { LEGACY_MANAGED_SKILLS } from "./managed-skill-baseline.js";
11
+ import { REVIEWED_MANAGED_BUNDLE_1_1_0 } from "./reviewed-managed-bundle-baseline.js";
12
+ const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
13
+ const missing = (error) => asRecord(error)?.code === "ENOENT";
14
+ const unsafe = () => new CliError("installation_unsafe", "本地安装位置未通过安全检查。", "请人工检查属主、权限、符号链接与目标目录;不要自动删除、覆盖或修改凭据。");
15
+ const permissionDenied = (path) => new CliError("installation_permission_denied", "没有权限访问本地安装目标目录。", "请由本人核对详情中目标目录的访问权限;不要自动提权或放宽权限。", 4, { target_directory: path });
16
+ const conflict = () => new CliError("installation_conflict", "已有命令行文件与当前候选不一致。", "保留已有文件,先人工核对安装来源;不要强制覆盖或删除。");
17
+ const skillConflict = () => new CliError("skill_conflict", "已有同名技能与当前候选不一致。", "保留原技能,人工核对冲突;不要覆盖个人修改。");
18
+ const sourceInvalid = () => new CliError("installation_source_invalid", "当前命令行构建或技能资源不完整。", "使用维护者提供的完整且已审核的构建,不要运行不明安装脚本。", 7);
19
+ const packageRoot = () => fileURLToPath(new URL("../", import.meta.url));
20
+ const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
21
+ const unchanged = (a, b) => same(a, b) && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
22
+ // No await or user callback while cwd is pinned: relative operations use the checked directory inode.
23
+ function pinned(directory, action, failure = unsafe) {
24
+ const original = process.cwd();
25
+ try {
26
+ process.chdir(directory.path);
27
+ const current = lstatSync(".");
28
+ if (!current.isDirectory() || !same(current, directory.identity) || current.uid !== directory.identity.uid || (current.mode & 0o7777) !== (directory.identity.mode & 0o7777))
29
+ throw failure();
30
+ return action();
31
+ }
32
+ finally {
33
+ process.chdir(original);
34
+ }
35
+ }
36
+ function readDescriptor(fd, max) {
37
+ const bytes = Buffer.alloc(max + 1);
38
+ let length = 0;
39
+ while (length < bytes.length) {
40
+ const read = readSync(fd, bytes, length, bytes.length - length, length);
41
+ if (!read)
42
+ break;
43
+ length += read;
44
+ }
45
+ return bytes.subarray(0, length);
46
+ }
47
+ export class OwnedTree {
48
+ home;
49
+ uid;
50
+ constructor(home) { if (!isAbsolute(home) || typeof process.getuid !== "function")
51
+ throw unsafe(); this.home = resolve(home); this.uid = process.getuid(); }
52
+ within(path) {
53
+ const absolute = resolve(path), rel = relative(this.home, absolute);
54
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
55
+ throw unsafe();
56
+ return absolute;
57
+ }
58
+ checkDirectory(info, path) {
59
+ if (!info.isDirectory() || info.uid !== this.uid || (info.mode & 0o022) !== 0)
60
+ throw unsafe();
61
+ const rel = relative(this.home, path);
62
+ if ((rel === ".caotang" || rel.startsWith(`.caotang${sep}`)) && (info.mode & 0o7777) !== 0o700)
63
+ throw unsafe();
64
+ }
65
+ async verify(directories) {
66
+ for (const directory of directories) {
67
+ const info = await lstat(directory.path);
68
+ this.checkDirectory(info, directory.path);
69
+ if (!same(info, directory.identity) || !same(await directory.handle.stat(), directory.identity))
70
+ throw unsafe();
71
+ }
72
+ }
73
+ async directory(path, create, action) {
74
+ const absolute = this.within(path), rel = relative(this.home, absolute), directories = [];
75
+ try {
76
+ let current = this.home;
77
+ for (const part of [null, ...(rel ? rel.split(sep) : [])]) {
78
+ if (part !== null)
79
+ current = join(current, part);
80
+ let info;
81
+ try {
82
+ info = await lstat(current);
83
+ }
84
+ catch (error) {
85
+ if (!missing(error))
86
+ throw error;
87
+ if (current === this.home)
88
+ throw unsafe();
89
+ if (!create)
90
+ return null;
91
+ await this.verify(directories);
92
+ const parent = directories.at(-1);
93
+ if (!parent)
94
+ throw unsafe();
95
+ pinned(parent, () => {
96
+ try {
97
+ mkdirSync(part, { mode: 0o700 });
98
+ }
99
+ catch (created) {
100
+ if (asRecord(created)?.code !== "EEXIST")
101
+ throw created;
102
+ }
103
+ });
104
+ info = await lstat(current);
105
+ }
106
+ this.checkDirectory(info, current);
107
+ if (await realpath(current) !== current)
108
+ throw unsafe();
109
+ const handle = await open(current, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
110
+ directories.push({ path: current, handle, identity: info });
111
+ if (!same(info, await handle.stat()))
112
+ throw unsafe();
113
+ }
114
+ await this.verify(directories);
115
+ const value = await action(directories);
116
+ await this.verify(directories);
117
+ return value;
118
+ }
119
+ catch (error) {
120
+ if (!(error instanceof CliError) && ["EACCES", "EPERM"].includes(String(asRecord(error)?.code)))
121
+ throw permissionDenied(absolute);
122
+ throw error;
123
+ }
124
+ finally {
125
+ for (const directory of directories.reverse())
126
+ await directory.handle.close();
127
+ }
128
+ }
129
+ checkFile(info, max) {
130
+ if (!info.isFile() || info.uid !== this.uid || info.nlink !== 1 || (info.mode & 0o022) !== 0 || info.size > max)
131
+ throw unsafe();
132
+ }
133
+ readFile(name, max, executable = false, mode) {
134
+ const check = (info) => { this.checkFile(info, max); if (executable && (info.mode & 0o100) === 0 || mode !== undefined && (info.mode & 0o7777) !== mode)
135
+ throw unsafe(); };
136
+ let before;
137
+ try {
138
+ before = lstatSync(name);
139
+ }
140
+ catch (error) {
141
+ if (missing(error))
142
+ return null;
143
+ throw error;
144
+ }
145
+ check(before);
146
+ const fd = openSync(name, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
147
+ try {
148
+ const opened = fstatSync(fd);
149
+ check(opened);
150
+ if (!unchanged(before, opened))
151
+ throw unsafe();
152
+ const bytes = readDescriptor(fd, max), after = fstatSync(fd), current = lstatSync(name);
153
+ check(after);
154
+ check(current);
155
+ if (bytes.length !== opened.size || !unchanged(opened, after) || !unchanged(after, current))
156
+ throw unsafe();
157
+ return bytes;
158
+ }
159
+ finally {
160
+ closeSync(fd);
161
+ }
162
+ }
163
+ async read(path, max = 1048576, mode) {
164
+ return this.directory(dirname(path), false, async (directories) => {
165
+ return pinned(directories.at(-1), () => this.readFile(basename(path), max, false, mode));
166
+ });
167
+ }
168
+ async list(path) {
169
+ return this.directory(path, false, async (directories) => pinned(directories.at(-1), () => readdirSync(".").sort()));
170
+ }
171
+ async createDirectory(path) {
172
+ return this.directory(dirname(path), true, async (directories) => {
173
+ await this.verify(directories);
174
+ return pinned(directories.at(-1), () => {
175
+ try {
176
+ mkdirSync(basename(path), { mode: 0o700 });
177
+ }
178
+ catch (error) {
179
+ if (asRecord(error)?.code === "EEXIST")
180
+ return null;
181
+ throw error;
182
+ }
183
+ const result = lstatSync(basename(path));
184
+ this.checkDirectory(result, path);
185
+ return result;
186
+ });
187
+ });
188
+ }
189
+ async writeNew(path, bytes, mode) {
190
+ const result = await this.directory(dirname(path), true, async (directories) => {
191
+ return pinned(directories.at(-1), () => {
192
+ const name = basename(path);
193
+ let fd;
194
+ try {
195
+ fd = openSync(name, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0);
196
+ }
197
+ catch (error) {
198
+ if (asRecord(error)?.code !== "EEXIST")
199
+ throw error;
200
+ if (!this.readFile(name, 1048576, (mode & 0o100) !== 0)?.equals(bytes))
201
+ throw conflict();
202
+ return false;
203
+ }
204
+ try {
205
+ const initial = fstatSync(fd);
206
+ this.checkFile(initial, bytes.length);
207
+ if (initial.size !== 0 || (initial.mode & 0o7777) !== 0)
208
+ throw unsafe();
209
+ writeFileSync(fd, bytes);
210
+ fsyncSync(fd);
211
+ const written = fstatSync(fd), current = lstatSync(name);
212
+ this.checkFile(written, bytes.length);
213
+ this.checkFile(current, bytes.length);
214
+ if (!same(initial, written) || !unchanged(written, current) || written.size !== bytes.length || !readDescriptor(fd, bytes.length).equals(bytes))
215
+ throw unsafe();
216
+ fchmodSync(fd, mode);
217
+ fsyncSync(fd);
218
+ const final = fstatSync(fd), published = lstatSync(name);
219
+ this.checkFile(final, bytes.length);
220
+ this.checkFile(published, bytes.length);
221
+ if (!same(initial, final) || !unchanged(final, published) || (final.mode & 0o7777) !== mode || final.size !== bytes.length || final.mtimeMs !== written.mtimeMs
222
+ || !readDescriptor(fd, bytes.length).equals(bytes) || !unchanged(final, fstatSync(fd)) || !unchanged(final, lstatSync(name)))
223
+ throw unsafe();
224
+ return true;
225
+ }
226
+ catch (error) {
227
+ // Only this newly-created inode may be demoted; the pathname may now name somebody else's file.
228
+ try {
229
+ fchmodSync(fd, 0);
230
+ fsyncSync(fd);
231
+ }
232
+ catch { /* Filesystem failure can prevent rollback; keep the installation failed. */ }
233
+ throw error;
234
+ }
235
+ finally {
236
+ closeSync(fd);
237
+ }
238
+ });
239
+ });
240
+ return result === true;
241
+ }
242
+ // Never truncate on open. Only an exact, single-link managed inode may change;
243
+ // the caller durably preserves its public before/after bytes before calling.
244
+ async replaceManaged(entry, rollback = false) {
245
+ const result = await this.directory(dirname(entry.path), false, async (directories) => pinned(directories.at(-1), () => {
246
+ const name = basename(entry.path), before = lstatSync(name);
247
+ const check = (info) => { this.checkFile(info, 1048576); if ((info.mode & 0o7777) !== entry.mode)
248
+ throw unsafe(); };
249
+ check(before);
250
+ const fd = openSync(name, constants.O_RDWR | constants.O_NOFOLLOW | constants.O_NONBLOCK);
251
+ try {
252
+ const opened = fstatSync(fd);
253
+ check(opened);
254
+ if (!unchanged(before, opened) || rollback && (!entry.identity || !same(entry.identity, opened)))
255
+ throw unsafe();
256
+ const expected = rollback ? entry.after : entry.before, next = rollback ? entry.before : entry.after;
257
+ if (!readDescriptor(fd, 1048576).equals(expected) || !unchanged(opened, fstatSync(fd)) || !unchanged(opened, lstatSync(name)))
258
+ throw unsafe();
259
+ if (!rollback)
260
+ entry.identity = opened;
261
+ writeFileSync(fd, next);
262
+ ftruncateSync(fd, next.length);
263
+ fsyncSync(fd);
264
+ const written = fstatSync(fd), named = lstatSync(name);
265
+ check(written);
266
+ check(named);
267
+ if (!same(opened, written) || !unchanged(written, named) || written.size !== next.length || !readDescriptor(fd, 1048576).equals(next)
268
+ || !unchanged(written, fstatSync(fd)) || !unchanged(written, lstatSync(name)))
269
+ throw unsafe();
270
+ return true;
271
+ }
272
+ finally {
273
+ closeSync(fd);
274
+ }
275
+ }));
276
+ if (result !== true)
277
+ throw unsafe();
278
+ }
279
+ async removeDirectory(path, expected) {
280
+ await this.directory(dirname(path), false, async (directories) => {
281
+ pinned(directories.at(-1), () => {
282
+ const info = lstatSync(basename(path));
283
+ this.checkDirectory(info, path);
284
+ if (!same(info, expected))
285
+ throw unsafe();
286
+ rmdirSync(basename(path));
287
+ });
288
+ });
289
+ }
290
+ }
291
+ async function readSource(path, max = 1048576) {
292
+ try {
293
+ const parent = dirname(path), identity = await lstat(parent);
294
+ if (!identity.isDirectory() || await realpath(parent) !== parent)
295
+ throw sourceInvalid();
296
+ return pinned({ path: parent, identity }, () => {
297
+ const valid = (info) => { if (!info.isFile() || info.nlink !== 1 || info.size < 1 || info.size > max)
298
+ throw sourceInvalid(); };
299
+ const name = basename(path), before = lstatSync(name);
300
+ valid(before);
301
+ const fd = openSync(name, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
302
+ try {
303
+ const opened = fstatSync(fd);
304
+ valid(opened);
305
+ if (!unchanged(before, opened))
306
+ throw sourceInvalid();
307
+ const bytes = readDescriptor(fd, max), after = fstatSync(fd), current = lstatSync(name);
308
+ valid(after);
309
+ valid(current);
310
+ if (bytes.length !== opened.size || !unchanged(opened, after) || !unchanged(after, current))
311
+ throw sourceInvalid();
312
+ return bytes;
313
+ }
314
+ finally {
315
+ closeSync(fd);
316
+ }
317
+ }, sourceInvalid);
318
+ }
319
+ catch {
320
+ throw sourceInvalid();
321
+ }
322
+ }
323
+ function managedShim(directory) {
324
+ return Buffer.from(`#!/usr/bin/env node\n// caotang managed command v1\nimport(${JSON.stringify(pathToFileURL(join(directory, "bin", "caotang.cjs")).href)}).catch(()=>{process.stderr.write(JSON.stringify({code:"cli_unavailable",message:"本地命令行暂不可用。",next_action:"请人工核对本地安装,不要重复业务写入。"})+"\\n");process.exitCode=7;});\n`);
325
+ }
326
+ async function bundle(ctx, tree) {
327
+ if (!completeSkillInventory(ctx.assets.map(asset => asset.name)) || ctx.assets.some(asset => hash(asset.content) !== asset.sha256 || !asset.content || Buffer.byteLength(asset.content) > 65536))
328
+ throw sourceInvalid();
329
+ const source = resolve(ctx.packageRoot ?? packageRoot());
330
+ if (await realpath(source) !== source)
331
+ throw sourceInvalid();
332
+ const info = asRecord(JSON.parse((await readSource(join(source, "package.json"), 32768)).toString("utf8")));
333
+ if (!info || info.name !== "@yijiancaotang/cli" || (info.private !== true && asRecord(info.publishConfig)?.access !== "public") || typeof info.version !== "string" || !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/i.test(info.version))
334
+ throw sourceInvalid();
335
+ const runtimeManifest = { name: info.name, version: info.version, private: true, type: "module", engines: { node: ">=20" }, bin: { caotang: "bin/caotang.cjs" } };
336
+ const files = [{ path: "package.json", bytes: Buffer.from(JSON.stringify(runtimeManifest)), mode: 0o600 }, { path: "bin/caotang.cjs", bytes: await readSource(join(source, "bin", "caotang.cjs")), mode: 0o700 }];
337
+ const dist = join(source, "dist");
338
+ if (!(await lstat(dist)).isDirectory() || await realpath(dist) !== dist)
339
+ throw sourceInvalid();
340
+ const modules = (await readdir(dist)).filter(name => name.endsWith(".js")).sort();
341
+ // The shipped runtime has one nested generated dependency. Keep the 64-file
342
+ // JS limit across both categories; do not recursively trust dist subtrees.
343
+ if (!modules.includes("cli.js") || modules.length + 1 > 64)
344
+ throw sourceInvalid();
345
+ for (const name of modules) {
346
+ if (!/^[a-z][a-z0-9-]*\.js$/.test(name))
347
+ throw sourceInvalid();
348
+ files.push({ path: `dist/${name}`, bytes: await readSource(join(dist, name)), mode: 0o600 });
349
+ }
350
+ files.push({ path: "dist/generated/knowledge-growth.js", bytes: await readSource(join(dist, "generated", "knowledge-growth.js")), mode: 0o600 });
351
+ files.push({ path: "skills/manifest.json", bytes: Buffer.from(JSON.stringify({ formatVersion: 1, skills: ctx.assets.map(({ name, description }) => ({ name, description })) })), mode: 0o600 });
352
+ for (const asset of ctx.assets)
353
+ files.push({ path: `skills/${asset.name}/SKILL.md`, bytes: Buffer.from(asset.content), mode: 0o600 });
354
+ files.sort((a, b) => a.path.localeCompare(b.path));
355
+ if (files.reduce((total, file) => total + file.bytes.length, 0) > 5242880)
356
+ throw sourceInvalid();
357
+ const digest = createHash("sha256");
358
+ for (const file of files)
359
+ digest.update(`${file.path}\0${file.mode}\0${file.bytes.length}\0`).update(file.bytes);
360
+ const sha = digest.digest("hex"), directory = join(tree.home, ".caotang", "cli", "releases", sha), command = join(tree.home, ".local", "bin", "caotang");
361
+ const shim = managedShim(directory);
362
+ return { files, sha, directory, command, shim, version: info.version };
363
+ }
364
+ function validateTargets(ctx, tree) {
365
+ if (ctx.targets.length > PLATFORM_IDS.length || new Set(ctx.targets.map(target => target.id)).size !== ctx.targets.length)
366
+ throw unsafe();
367
+ for (const target of ctx.targets) {
368
+ if (!isPlatformId(target.id) || !platformSupported(target.id, ctx.operatingSystem) || !isAbsolute(target.skillDirectory) || tree.within(target.skillDirectory) === tree.home)
369
+ throw unsafe();
370
+ }
371
+ }
372
+ async function skillState(tree, target, asset) {
373
+ const dir = join(target.skillDirectory, asset.name), entries = await tree.list(dir);
374
+ if (entries === null || entries.length === 0)
375
+ return "missing";
376
+ if (entries.length !== 1 || entries[0] !== "SKILL.md")
377
+ return "modified";
378
+ const bytes = await tree.read(join(dir, "SKILL.md"), 65536);
379
+ return bytes && hash(bytes) === asset.sha256 ? "installed" : "modified";
380
+ }
381
+ async function bundleState(tree, value) {
382
+ if (await tree.list(value.directory) === null)
383
+ return "missing";
384
+ const expected = new Set(value.files.map(file => file.path));
385
+ async function walk(dir, prefix = "") {
386
+ const entries = await tree.list(dir);
387
+ if (!entries)
388
+ return false;
389
+ for (const name of entries) {
390
+ const path = prefix + name;
391
+ if (value.files.some(file => file.path.startsWith(path + "/"))) {
392
+ if (!await walk(join(dir, name), path + "/"))
393
+ return false;
394
+ }
395
+ else if (!expected.has(path))
396
+ return false;
397
+ }
398
+ return true;
399
+ }
400
+ if (!await walk(value.directory))
401
+ return "modified";
402
+ for (const file of value.files) {
403
+ const bytes = await tree.read(join(value.directory, file.path));
404
+ if (!bytes?.equals(file.bytes))
405
+ return "modified";
406
+ }
407
+ return "installed";
408
+ }
409
+ async function onPath(command, env) {
410
+ for (const entry of (env.PATH ?? "").split(delimiter)) {
411
+ if (!isAbsolute(entry))
412
+ continue;
413
+ try {
414
+ if (await realpath(join(entry, "caotang")) === command && ((await stat(command)).mode & 0o100) !== 0) {
415
+ await access(command, constants.X_OK);
416
+ return true;
417
+ }
418
+ }
419
+ catch { }
420
+ }
421
+ return false;
422
+ }
423
+ // The fixed 1.1.0 baseline is admitted only after its complete public runtime
424
+ // inventory verifies. Never infer provenance from a version or receipt.
425
+ async function reviewedV110Bundle(tree, value) {
426
+ const pin = REVIEWED_MANAGED_BUNDLE_1_1_0, directory = join(tree.home, ".caotang", "cli", "releases", pin.sha);
427
+ const children = new Map([["", new Set()]]);
428
+ for (const file of pin.files) {
429
+ const parts = file.path.split("/");
430
+ let parent = "";
431
+ for (const [index, part] of parts.entries()) {
432
+ children.get(parent).add(part);
433
+ if (index < parts.length - 1) {
434
+ parent = parent ? `${parent}/${part}` : part;
435
+ if (!children.has(parent))
436
+ children.set(parent, new Set());
437
+ }
438
+ }
439
+ }
440
+ const verifyInventory = async () => {
441
+ for (const [path, expected] of children)
442
+ if ((await tree.list(join(directory, path)))?.join(",") !== [...expected].sort().join(","))
443
+ throw conflict();
444
+ };
445
+ await verifyInventory();
446
+ const files = [], digest = createHash("sha256");
447
+ for (const file of pin.files) {
448
+ const bytes = await tree.read(join(directory, file.path), file.bytes, file.mode);
449
+ if (!bytes || bytes.length !== file.bytes || hash(bytes) !== file.sha256)
450
+ throw conflict();
451
+ files.push({ path: file.path, bytes, mode: file.mode });
452
+ digest.update(`${file.path}\0${file.mode}\0${bytes.length}\0`).update(bytes);
453
+ }
454
+ if (digest.digest("hex") !== pin.sha)
455
+ throw conflict();
456
+ await verifyInventory();
457
+ const manifest = JSON.parse(files.find(file => file.path === "skills/manifest.json").bytes.toString("utf8"));
458
+ if (!completeSkillInventory(manifest.skills.map(item => item.name)))
459
+ throw conflict();
460
+ const assets = manifest.skills.map(item => { const bytes = files.find(file => file.path === `skills/${item.name}/SKILL.md`).bytes; return { ...item, content: bytes.toString("utf8"), sha256: hash(bytes) }; });
461
+ return { bundle: { files, sha: pin.sha, version: pin.version, directory, command: value.command, shim: managedShim(directory) }, assets };
462
+ }
463
+ // Keep the reviewed 1.0.0 six-skill baseline unchanged. A directory digest alone
464
+ // cannot authorize replacing somebody's same-name skill.
465
+ async function legacyBundles(ctx, tree, value) {
466
+ if (!["1.0.1", "1.1.0"].includes(value.version) || ctx.agentName === undefined)
467
+ return [];
468
+ const releases = join(tree.home, ".caotang", "cli", "releases"), entries = await tree.list(releases) ?? [];
469
+ if (entries.length > 128)
470
+ throw unsafe();
471
+ const found = [];
472
+ const runtime = Buffer.from(JSON.stringify({ name: "@yijiancaotang/cli", version: "1.0.0", private: true, type: "module", engines: { node: ">=20" }, bin: { caotang: "bin/caotang.cjs" } }));
473
+ const manifest = Buffer.from(JSON.stringify({ formatVersion: 1, skills: LEGACY_MANAGED_SKILLS.map(({ name, description }) => ({ name, description })) }));
474
+ for (const sha of entries) {
475
+ if (!/^[a-f0-9]{64}$/.test(sha) || sha === value.sha)
476
+ continue;
477
+ const directory = join(releases, sha), packageBytes = await tree.read(join(directory, "package.json"), 32768, 0o600);
478
+ if (!packageBytes?.equals(runtime))
479
+ continue;
480
+ const root = await tree.list(directory), bin = await tree.list(join(directory, "bin")), dist = await tree.list(join(directory, "dist"));
481
+ const skills = await tree.list(join(directory, "skills")), generated = await tree.list(join(directory, "dist/generated"));
482
+ if (root?.join(",") !== "bin,dist,package.json,skills" || bin?.join(",") !== "caotang.cjs" || generated?.join(",") !== "knowledge-growth.js"
483
+ || !dist || dist.length > 64 || !dist.includes("cli.js") || !dist.includes("generated") || dist.some(name => name !== "generated" && !/^[a-z][a-z0-9-]*\.js$/.test(name))
484
+ || skills?.join(",") !== [...LEGACY_MANAGED_SKILLS.map(item => item.name), "manifest.json"].sort().join(","))
485
+ throw conflict();
486
+ if (!(await tree.read(join(directory, "skills/manifest.json"), 16384, 0o600))?.equals(manifest))
487
+ throw conflict();
488
+ const files = [{ path: "package.json", bytes: runtime, mode: 0o600 }, { path: "skills/manifest.json", bytes: manifest, mode: 0o600 }];
489
+ const assets = [];
490
+ for (const item of LEGACY_MANAGED_SKILLS) {
491
+ if ((await tree.list(join(directory, "skills", item.name)))?.join(",") !== "SKILL.md")
492
+ throw conflict();
493
+ const bytes = await tree.read(join(directory, "skills", item.name, "SKILL.md"), 65536, 0o600);
494
+ if (!bytes || hash(bytes) !== item.sha256)
495
+ throw conflict();
496
+ assets.push({ ...item, content: bytes.toString("utf8") });
497
+ files.push({ path: `skills/${item.name}/SKILL.md`, bytes, mode: 0o600 });
498
+ }
499
+ for (const path of ["bin/caotang.cjs", ...dist.filter(name => name !== "generated").map(name => `dist/${name}`), "dist/generated/knowledge-growth.js"]) {
500
+ const mode = path === "bin/caotang.cjs" ? 0o700 : 0o600, bytes = await tree.read(join(directory, path), 1048576, mode);
501
+ if (!bytes?.length)
502
+ throw conflict();
503
+ files.push({ path, bytes, mode });
504
+ }
505
+ files.sort((a, b) => a.path.localeCompare(b.path));
506
+ if (files.reduce((total, file) => total + file.bytes.length, 0) > 5242880)
507
+ throw conflict();
508
+ const digest = createHash("sha256");
509
+ for (const file of files)
510
+ digest.update(`${file.path}\0${file.mode}\0${file.bytes.length}\0`).update(file.bytes);
511
+ if (digest.digest("hex") !== sha)
512
+ throw conflict();
513
+ found.push({ bundle: { files, sha, version: "1.0.0", directory, command: value.command, shim: managedShim(directory) }, assets });
514
+ }
515
+ if (value.version === "1.1.0" && value.sha !== REVIEWED_MANAGED_BUNDLE_1_1_0.sha && entries.includes(REVIEWED_MANAGED_BUNDLE_1_1_0.sha))
516
+ found.push(await reviewedV110Bundle(tree, value));
517
+ return found;
518
+ }
519
+ async function installPlan(ctx, tree, value) {
520
+ const missing = [], modified = [];
521
+ const newlyInstalledPlatforms = [], upgradedPlatforms = [], replacements = [];
522
+ for (const target of ctx.targets) {
523
+ let absent = 0;
524
+ for (const raw of ctx.assets) {
525
+ const asset = bindSkillAgent(raw, ctx.agentName), dir = join(target.skillDirectory, asset.name), entries = await tree.list(dir);
526
+ if (entries === null || entries.length === 0) {
527
+ missing.push({ target, asset });
528
+ absent++;
529
+ continue;
530
+ }
531
+ if (entries.length !== 1 || entries[0] !== "SKILL.md")
532
+ throw skillConflict();
533
+ const bytes = await tree.read(join(dir, "SKILL.md"), 65536);
534
+ if (!bytes)
535
+ throw skillConflict();
536
+ if (!bytes.equals(Buffer.from(asset.content)))
537
+ modified.push({ target, asset, bytes });
538
+ }
539
+ if (absent === ctx.assets.length)
540
+ newlyInstalledPlatforms.push(target.id);
541
+ }
542
+ const command = await tree.read(value.command);
543
+ if (command && ((await lstat(value.command)).mode & 0o100) === 0)
544
+ throw unsafe();
545
+ const legacy = modified.length || command && !command.equals(value.shim) ? await legacyBundles(ctx, tree, value) : [];
546
+ for (const item of modified) {
547
+ const known = legacy.some(source => source.assets.some(asset => asset.name === item.asset.name && Buffer.from(bindSkillAgent(asset, ctx.agentName).content).equals(item.bytes)));
548
+ if (!known)
549
+ throw skillConflict();
550
+ const path = join(item.target.skillDirectory, item.asset.name, "SKILL.md");
551
+ if (!(await tree.read(path, 65536, 0o600))?.equals(item.bytes))
552
+ throw unsafe();
553
+ replacements.push({ path, before: item.bytes, after: Buffer.from(item.asset.content), mode: 0o600, label: `${item.target.id}:${item.asset.name}` });
554
+ if (!upgradedPlatforms.includes(item.target.id))
555
+ upgradedPlatforms.push(item.target.id);
556
+ }
557
+ if (command && !command.equals(value.shim)) {
558
+ if (!legacy.some(source => source.bundle.shim.equals(command)))
559
+ throw conflict();
560
+ if (!(await tree.read(value.command, 1048576, 0o700))?.equals(command))
561
+ throw unsafe();
562
+ // Always switch the shared stable command after every selected skill succeeds.
563
+ replacements.push({ path: value.command, before: command, after: value.shim, mode: 0o700, label: "command" });
564
+ }
565
+ return { missing, replacements, newlyInstalledPlatforms, upgradedPlatforms };
566
+ }
567
+ export async function installLocal(ctx) {
568
+ const tree = new OwnedTree(ctx.homeDir), changed = [];
569
+ let changesPossible = false;
570
+ try {
571
+ validateTargets(ctx, tree);
572
+ if (!ctx.targets.length && !ctx.agentName)
573
+ throw new CliError("no_supported_platform", "未识别到支持的AI客户端。", "核对Claude Code、Codex、OpenClaw,或macOS上的Kimi Code、Kimi工作模式、WorkBuddy后再接入;不会自动安装客户端。");
574
+ const value = await bundle(ctx, tree);
575
+ await tree.directory(tree.home, false, async () => { });
576
+ await installPlan(ctx, tree, value);
577
+ if (await bundleState(tree, value) === "modified")
578
+ throw conflict();
579
+ const lockPath = join(tree.home, ".caotang", ".setup.lock");
580
+ changesPossible = true;
581
+ const release = await tree.directory(dirname(lockPath), true, async (directories) => acquireLocalLock(lockPath, directories.at(-1), {
582
+ unsafe,
583
+ busy: () => new CliError("installation_busy", "另一项本地安装正在进行或锁状态无法确认。", "等待本地安装结束;状态不明时保留锁,由你人工核查,不自动删除。"),
584
+ }));
585
+ if (!release)
586
+ throw unsafe();
587
+ try {
588
+ const state = await bundleState(tree, value);
589
+ if (state === "modified")
590
+ throw conflict();
591
+ const originalPlan = await installPlan(ctx, tree, value);
592
+ if (state === "missing") {
593
+ const created = await tree.createDirectory(value.directory);
594
+ if (!created)
595
+ throw conflict();
596
+ changed.push("bundle");
597
+ for (const file of value.files)
598
+ await tree.writeNew(join(value.directory, file.path), file.bytes, file.mode);
599
+ if (await bundleState(tree, value) !== "installed")
600
+ throw conflict();
601
+ }
602
+ for (const { target, asset } of originalPlan.missing) {
603
+ const dir = join(target.skillDirectory, asset.name), created = await tree.createDirectory(dir);
604
+ if (!created && (await tree.list(dir))?.length !== 0)
605
+ throw skillConflict();
606
+ changed.push(`${target.id}:${asset.name}`);
607
+ await tree.writeNew(join(dir, "SKILL.md"), Buffer.from(asset.content), 0o600);
608
+ if (await skillState(tree, target, asset) !== "installed")
609
+ throw skillConflict();
610
+ }
611
+ const plan = await installPlan(ctx, tree, value), attempted = [];
612
+ let recoveryJournal;
613
+ if (plan.replacements.length) {
614
+ const snapshot = Buffer.from(JSON.stringify({ formatVersion: 1, targetBundleSha: value.sha, files: plan.replacements.map(entry => ({ path: relative(tree.home, entry.path), mode: entry.mode, before: entry.before.toString("base64"), after: entry.after.toString("base64") })) }));
615
+ if (snapshot.length > 1048576)
616
+ throw unsafe();
617
+ recoveryJournal = join(tree.home, ".caotang", "cli", "upgrades", hash(snapshot) + ".json");
618
+ await tree.writeNew(recoveryJournal, snapshot, 0o600);
619
+ if (!(await tree.read(recoveryJournal, 1048576, 0o600))?.equals(snapshot))
620
+ throw unsafe();
621
+ await tree.directory(dirname(recoveryJournal), false, async (directories) => {
622
+ for (const directory of directories)
623
+ await directory.handle.sync();
624
+ });
625
+ }
626
+ try {
627
+ for (const entry of plan.replacements) {
628
+ attempted.push(entry);
629
+ changed.push(entry.label);
630
+ await tree.replaceManaged(entry);
631
+ }
632
+ if (await tree.writeNew(value.command, value.shim, 0o700))
633
+ changed.push("command");
634
+ if (!(await tree.read(value.command))?.equals(value.shim) || ((await lstat(value.command)).mode & 0o100) === 0 || await bundleState(tree, value) !== "installed")
635
+ throw conflict();
636
+ for (const target of ctx.targets)
637
+ for (const asset of ctx.assets)
638
+ if (await skillState(tree, target, bindSkillAgent(asset, ctx.agentName)) !== "installed")
639
+ throw skillConflict();
640
+ }
641
+ catch (error) {
642
+ if (!recoveryJournal)
643
+ throw error;
644
+ let restored = true;
645
+ for (const entry of attempted.reverse()) {
646
+ try {
647
+ const current = await tree.read(entry.path, 1048576, entry.mode);
648
+ if (!current?.equals(entry.before)) {
649
+ if (!entry.identity) {
650
+ restored = false;
651
+ continue;
652
+ }
653
+ await tree.replaceManaged(entry, true);
654
+ }
655
+ if (!(await tree.read(entry.path, 1048576, entry.mode))?.equals(entry.before)
656
+ || entry.identity && !same(await lstat(entry.path), entry.identity))
657
+ restored = false;
658
+ }
659
+ catch {
660
+ restored = false;
661
+ }
662
+ }
663
+ const recovery = { upgrade_status: restored ? "rolled_back" : "unknown", recovery_journal: recoveryJournal };
664
+ if (restored && error instanceof CliError && error.code === "installation_permission_denied") {
665
+ Object.assign(error.details, recovery);
666
+ throw error;
667
+ }
668
+ throw new CliError(restored ? "installation_upgrade_failed" : "installation_upgrade_unknown", restored ? "本地升级未完成,已核对并恢复本轮改动的原文件。" : "无法确认本地升级结果,部分受管文件可能尚未恢复。", "保留旧版本与恢复原件,请本人核对 recovery_journal;不要强制覆盖、删除、重新授权或重复业务写入。", 7, recovery);
669
+ }
670
+ return { changed, bundleSha: value.sha, bundleDirectory: value.directory, newlyInstalledPlatforms: originalPlan.newlyInstalledPlatforms, upgradedPlatforms: originalPlan.upgradedPlatforms };
671
+ }
672
+ finally {
673
+ release();
674
+ }
675
+ }
676
+ catch (error) {
677
+ const safe = error instanceof CliError ? error
678
+ : ["EPERM", "EACCES"].includes(String(asRecord(error)?.code)) ? permissionDenied(join(tree.home, ".caotang")) : unsafe();
679
+ throw new CliError(safe.code, safe.message, safe.nextAction, safe.exitCode, { ...safe.details, local_changes_possible: changesPossible, local_changes: changed });
680
+ }
681
+ }
682
+ export async function inspectLocalInstallation(ctx) {
683
+ const tree = new OwnedTree(ctx.homeDir);
684
+ validateTargets(ctx, tree);
685
+ const value = await bundle(ctx, tree), issues = [];
686
+ const issue = (code, message, next_action) => issues.push({ code, message, next_action });
687
+ let installed = false;
688
+ const current = await tree.read(value.command);
689
+ if (!current)
690
+ issue("command_not_installed", "稳定命令行尚未安装。", "运行 setup 完成本地安装。");
691
+ else if (!current.equals(value.shim) || await bundleState(tree, value) !== "installed" || ((await lstat(value.command)).mode & 0o100) === 0)
692
+ issue("command_modified", "已有命令行与当前候选不一致。", "保留现有文件并人工核对,不自动覆盖。");
693
+ else
694
+ installed = true;
695
+ const on_path = installed && await onPath(value.command, ctx.env);
696
+ if (!ctx.targets.some(target => target.detected))
697
+ issue("no_supported_platform", "尚未识别到支持的AI客户端。", "核对Claude Code、Codex、OpenClaw,或macOS上的Kimi Code、Kimi工作模式、WorkBuddy程序入口,再运行doctor。");
698
+ const platforms = [];
699
+ for (const target of ctx.targets) {
700
+ const skills = [];
701
+ for (const asset of ctx.assets) {
702
+ let status;
703
+ try {
704
+ status = await skillState(tree, target, bindSkillAgent(asset, ctx.agentName));
705
+ }
706
+ catch {
707
+ status = "unsafe";
708
+ }
709
+ skills.push({ name: asset.name, status });
710
+ if (target.detected && status !== "installed")
711
+ issue(status === "missing" ? "skill_missing" : status === "modified" ? "skill_modified" : "skill_unsafe", "本地技能缺失、已修改或不满足安全要求。", status === "missing" ? `运行 setup --platform ${target.id} 安装缺失技能。` : "保留现有技能并人工核对,不自动覆盖或修复。");
712
+ }
713
+ platforms.push({ id: target.id, detected: target.detected, skill_directory: target.skillDirectory, skills });
714
+ }
715
+ return { command: { installed, on_path, version: value.version, ...(installed ? { executable_path: value.command } : {}) }, platforms, issues };
716
+ }
717
+ // Read-only evidence for known platform paths, never an installation/overwrite
718
+ // permit. Only the current name's exact public templates count as its skills.
719
+ export async function inspectManagedOrigins(ctx) {
720
+ if (ctx.agentName === undefined)
721
+ throw new CliError("agent_name_required", "请用你自己的名字再运行 setup --agent <你的名字>。", "之后每条命令都带同一个 --agent <你的名字>;不会借用其他 AI 的连接。");
722
+ const tree = new OwnedTree(ctx.homeDir);
723
+ validateTargets(ctx, tree);
724
+ const value = await bundle(ctx, tree), assets = ctx.assets.map(asset => bindSkillAgent(asset, ctx.agentName));
725
+ let legacy = [];
726
+ try {
727
+ legacy = await legacyBundles(ctx, tree, value);
728
+ }
729
+ catch { /* Unverified old files provide no evidence; never promote them. */ }
730
+ const platforms = [];
731
+ for (const target of ctx.targets) {
732
+ const skills = [];
733
+ for (const asset of assets) {
734
+ let status;
735
+ try {
736
+ status = await skillState(tree, target, asset);
737
+ if (status === "modified") {
738
+ const dir = join(target.skillDirectory, asset.name), entries = await tree.list(dir);
739
+ if (entries?.join(",") === "SKILL.md") {
740
+ const bytes = await tree.read(join(dir, "SKILL.md"), 65536, 0o600);
741
+ if (bytes && legacy.some(source => source.assets.some(old => old.name === asset.name && Buffer.from(bindSkillAgent(old, ctx.agentName).content).equals(bytes))))
742
+ status = "outdated";
743
+ }
744
+ }
745
+ }
746
+ catch {
747
+ status = "unsafe";
748
+ }
749
+ skills.push({ name: asset.name, status });
750
+ }
751
+ platforms.push({ id: target.id, skill_directory: target.skillDirectory, ownKnownCount: skills.filter(skill => skill.status === "installed" || skill.status === "outdated").length, skills });
752
+ }
753
+ let command = { legacyManaged: false, on_path: false };
754
+ try {
755
+ const bytes = await tree.read(value.command, 1048576, 0o700), old = legacy.find(source => bytes?.equals(source.bundle.shim));
756
+ if (old)
757
+ command = { legacyManaged: true, on_path: await onPath(value.command, ctx.env), version: old.bundle.version, executable_path: value.command };
758
+ }
759
+ catch { /* An unsafe command must not be called a verified old command. */ }
760
+ return { platforms, command };
761
+ }