@yijiancaotang/cli 1.0.0 → 1.2.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 (50) hide show
  1. package/README.md +70 -26
  2. package/dist/auth.js +7 -4
  3. package/dist/calendar-contract.js +358 -0
  4. package/dist/calendar-help.js +12 -0
  5. package/dist/calendar.js +64 -0
  6. package/dist/capability-result.js +5 -0
  7. package/dist/cli.js +58 -10
  8. package/dist/command-help.js +18 -8
  9. package/dist/commands.js +11 -0
  10. package/dist/doctor.js +13 -5
  11. package/dist/generated/agent-operations.js +12 -0
  12. package/dist/habit-read.js +3 -1
  13. package/dist/habit-wire.js +36 -5
  14. package/dist/handoff-read.js +29 -0
  15. package/dist/handoff-wire.js +42 -0
  16. package/dist/http.js +11 -1
  17. package/dist/input-validation.js +14 -0
  18. package/dist/installation.js +334 -45
  19. package/dist/local-commands.js +133 -16
  20. package/dist/local-records.js +150 -0
  21. package/dist/local-types.js +1 -0
  22. package/dist/managed-skill-baseline.js +35 -0
  23. package/dist/missing-sections.js +1 -0
  24. package/dist/onboarding.js +4 -20
  25. package/dist/output.js +7 -1
  26. package/dist/platforms.js +69 -12
  27. package/dist/project-binding-cli.js +110 -0
  28. package/dist/project-binding.js +370 -0
  29. package/dist/query-search.js +3 -2
  30. package/dist/reviewed-managed-bundle-baseline.js +356 -0
  31. package/dist/schedule-contract.js +289 -0
  32. package/dist/schedule-help.js +35 -0
  33. package/dist/schedule-start-wire.js +80 -0
  34. package/dist/schedule.js +70 -0
  35. package/dist/setup-guidance.js +40 -0
  36. package/dist/skill-assets.js +1 -1
  37. package/dist/skills-export.js +206 -0
  38. package/dist/web-citations.js +1 -1
  39. package/dist/work-context-human.js +67 -0
  40. package/dist/work-context-wire.js +391 -0
  41. package/dist/write-routing.js +26 -0
  42. package/package.json +2 -2
  43. package/skills/caotang-context/SKILL.md +20 -7
  44. package/skills/caotang-first-connect/SKILL.md +48 -34
  45. package/skills/caotang-project-progress/SKILL.md +6 -2
  46. package/skills/caotang-save-knowledge/SKILL.md +6 -4
  47. package/skills/caotang-schedule/SKILL.md +54 -0
  48. package/skills/caotang-search/SKILL.md +11 -0
  49. package/skills/caotang-workday/SKILL.md +56 -9
  50. package/skills/manifest.json +28 -6
@@ -0,0 +1,42 @@
1
+ import { verifiedHabit } from "./habit-wire.js";
2
+ import { validDate } from "./query-validation.js";
3
+ const MAX_HANDOFF_BYTES = 64 * 1024;
4
+ const deferredSections = [
5
+ "project_overview", "project_next_steps", "knowledge_index",
6
+ "recent_connections", "conditional_reminders", "project_selection"
7
+ ];
8
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
9
+ function utcTimestamp(value) {
10
+ if (typeof value !== "string")
11
+ return false;
12
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?Z$/.exec(value);
13
+ return !!match && validDate(match[1])
14
+ && Number(match[2]) <= 23 && Number(match[3]) <= 59 && Number(match[4]) <= 59;
15
+ }
16
+ export function verifiedHandoff(value) {
17
+ try {
18
+ if (Buffer.byteLength(JSON.stringify(value) ?? "", "utf8") > MAX_HANDOFF_BYTES)
19
+ return;
20
+ const body = record(value);
21
+ if (!body || body.schema_version !== 1 || body.mode !== "handoff"
22
+ || body.coverage !== "existing_habit_sections" || !utcTimestamp(body.as_of))
23
+ return;
24
+ const deferred = body.deferred_sections;
25
+ if (!Array.isArray(deferred) || deferred.length !== deferredSections.length
26
+ || new Set(deferred).size !== deferredSections.length
27
+ || deferred.some(section => !deferredSections.includes(section)))
28
+ return;
29
+ const context = verifiedHabit(body.context, true);
30
+ if (!context || typeof context.owner_id !== "string" || !Array.isArray(context.missing_sections)
31
+ || context.missing_sections.some(gap => record(gap)?.section === "calendar_start" || record(gap)?.reason === "record_failed"))
32
+ return;
33
+ return {
34
+ schema_version: 1, mode: "handoff", as_of: body.as_of,
35
+ coverage: "existing_habit_sections", context,
36
+ deferred_sections: [...deferredSections]
37
+ };
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
package/dist/http.js CHANGED
@@ -3,6 +3,8 @@ import { isPagedQueryPath, isQueryParameterError, isProjectIDParameterError } fr
3
3
  import { isSearchParameterError } from "./query-search.js";
4
4
  import { isDailyParameterError } from "./query-daily.js";
5
5
  import { isCreatePath } from "./create-bundle.js";
6
+ import { isScheduleHeaderPath, scheduleHeaders } from "./schedule-contract.js";
7
+ import { isCalendarHeaderPath, calendarHeaders } from "./calendar-contract.js";
6
8
  const maxJSONBytes = 4 * 1024 * 1024;
7
9
  const invalidResponse = () => new CliError("invalid_response", "一间草堂返回了无法确认的响应。", "请先核对网页中的实际结果,不要自动重复写入。", 7);
8
10
  const invalidRequest = () => new CliError("invalid_request", "请求参数无效。", "请检查命令帮助和输入数据。");
@@ -100,7 +102,15 @@ export class ApiClient {
100
102
  }
101
103
  if (options.body && typeof options.body === "object" && "device_code" in options.body && typeof options.body.device_code === "string")
102
104
  this.secrets.add(options.body.device_code);
103
- if (options.headers !== undefined) {
105
+ if (isScheduleHeaderPath(url.pathname, method)) {
106
+ for (const [name, value] of Object.entries(scheduleHeaders(url.pathname, method, options.headers)))
107
+ headers.set(name, value);
108
+ }
109
+ else if (isCalendarHeaderPath(url.pathname, method)) {
110
+ for (const [name, value] of Object.entries(calendarHeaders(url.pathname, method, options.headers)))
111
+ headers.set(name, value);
112
+ }
113
+ else if (options.headers !== undefined) {
104
114
  if (!options.headers || Object.keys(options.headers).some((key) => key !== "Idempotency-Key"))
105
115
  throw invalidRequest();
106
116
  const key = options.headers["Idempotency-Key"];
@@ -1,3 +1,4 @@
1
+ import { validWorkContextProjectID } from "./work-context-wire.js";
1
2
  import { asRecord, CliError } from "./errors.js";
2
3
  import { commandHelp } from "./command-help.js";
3
4
  export const trimGoSpace = (value) => value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, "");
@@ -63,8 +64,21 @@ export function validateCommandArguments(command, flags) {
63
64
  return;
64
65
  if (Object.entries(flags).some(([key, value]) => value !== undefined && !help.options.includes(`--${key}`)))
65
66
  throw flagError(command);
67
+ if (help.command === "context" && flags.handoff === true && Object.keys(flags).some(key => key !== "handoff" && key !== "project" && flags[key] !== undefined))
68
+ throw flagError(command);
69
+ if (help.command === "context" && flags.project !== undefined && (flags.handoff !== true || !validWorkContextProjectID(flags.project)))
70
+ throw invalidInput(command, { invalid: ["--project"] });
66
71
  if (help.command === "context" && flags.start === true && Object.keys(flags).some(key => key !== "start" && flags[key] !== undefined))
67
72
  throw flagError(command);
73
+ if (["project bind", "project binding", "project unbind"].includes(help.command)) {
74
+ if (flags.confirm !== undefined && (typeof flags.confirm !== "string" || !/^[a-f0-9]{64}$/.test(flags.confirm)))
75
+ throw invalidInput(command, { invalid: ["--confirm"] });
76
+ for (const flag of ["local-workspace", "include-subdirs"])
77
+ if (flags[flag] !== undefined && flags[flag] !== true)
78
+ throw flagError(command);
79
+ if (help.command === "project bind" && command.length >= 3 && !validWorkContextProjectID(command[2]))
80
+ throw invalidInput(command, { invalid: ["<id>"] });
81
+ }
68
82
  const count = (help.usage.match(/<[^>]+>/g) ?? []).length;
69
83
  const words = help.command.split(" ").length;
70
84
  const positionalCount = help.command === "upload" ? 1 : count;
@@ -1,12 +1,14 @@
1
- import { constants, lstatSync, fstatSync, openSync, closeSync, readSync, writeFileSync, fsyncSync, fchmodSync, mkdirSync, readdirSync, rmdirSync } from "node:fs";
1
+ import { constants, lstatSync, fstatSync, openSync, closeSync, readSync, writeFileSync, fsyncSync, fchmodSync, ftruncateSync, mkdirSync, readdirSync, rmdirSync } from "node:fs";
2
2
  import { access, lstat, stat, realpath, open, readdir } from "node:fs/promises";
3
3
  import { createHash } from "node:crypto";
4
4
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { CliError, asRecord } from "./errors.js";
7
- import { PLATFORM_IDS, isPlatformId, platformSupported } from "./local-types.js";
7
+ import { PLATFORM_IDS, isSkillTargetId, platformSupported } from "./local-types.js";
8
8
  import { bindSkillAgent, completeSkillInventory } from "./skill-assets.js";
9
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";
10
12
  const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
11
13
  const missing = (error) => asRecord(error)?.code === "ENOENT";
12
14
  const unsafe = () => new CliError("installation_unsafe", "本地安装位置未通过安全检查。", "请人工检查属主、权限、符号链接与目标目录;不要自动删除、覆盖或修改凭据。");
@@ -128,8 +130,8 @@ export class OwnedTree {
128
130
  if (!info.isFile() || info.uid !== this.uid || info.nlink !== 1 || (info.mode & 0o022) !== 0 || info.size > max)
129
131
  throw unsafe();
130
132
  }
131
- readFile(name, max, executable = false) {
132
- const check = (info) => { this.checkFile(info, max); if (executable && (info.mode & 0o100) === 0)
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)
133
135
  throw unsafe(); };
134
136
  let before;
135
137
  try {
@@ -158,9 +160,9 @@ export class OwnedTree {
158
160
  closeSync(fd);
159
161
  }
160
162
  }
161
- async read(path, max = 1048576) {
163
+ async read(path, max = 1048576, mode) {
162
164
  return this.directory(dirname(path), false, async (directories) => {
163
- return pinned(directories.at(-1), () => this.readFile(basename(path), max));
165
+ return pinned(directories.at(-1), () => this.readFile(basename(path), max, false, mode));
164
166
  });
165
167
  }
166
168
  async list(path) {
@@ -237,6 +239,43 @@ export class OwnedTree {
237
239
  });
238
240
  return result === true;
239
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
+ }
240
279
  async removeDirectory(path, expected) {
241
280
  await this.directory(dirname(path), false, async (directories) => {
242
281
  pinned(directories.at(-1), () => {
@@ -281,6 +320,9 @@ async function readSource(path, max = 1048576) {
281
320
  throw sourceInvalid();
282
321
  }
283
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
+ }
284
326
  async function bundle(ctx, tree) {
285
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))
286
328
  throw sourceInvalid();
@@ -296,16 +338,35 @@ async function bundle(ctx, tree) {
296
338
  if (!(await lstat(dist)).isDirectory() || await realpath(dist) !== dist)
297
339
  throw sourceInvalid();
298
340
  const modules = (await readdir(dist)).filter(name => name.endsWith(".js")).sort();
299
- // The shipped runtime has one nested generated dependency. Keep the 64-file
300
- // JS limit across both categories; do not recursively trust dist subtrees.
301
- if (!modules.includes("cli.js") || modules.length + 1 > 64)
341
+ // Only reviewed generated dependencies may enter a stable runtime bundle.
342
+ // Old fixtures/releases with no operations import retain the original closure.
343
+ if (!modules.includes("cli.js"))
302
344
  throw sourceInvalid();
303
345
  for (const name of modules) {
304
346
  if (!/^[a-z][a-z0-9-]*\.js$/.test(name))
305
347
  throw sourceInvalid();
306
348
  files.push({ path: `dist/${name}`, bytes: await readSource(join(dist, name)), mode: 0o600 });
307
349
  }
308
- files.push({ path: "dist/generated/knowledge-growth.js", bytes: await readSource(join(dist, "generated", "knowledge-growth.js")), mode: 0o600 });
350
+ const generatedModules = ["knowledge-growth.js"];
351
+ const operationsPath = join(dist, "generated", "agent-operations.js");
352
+ let hasOperations = false;
353
+ try {
354
+ await lstat(operationsPath);
355
+ hasOperations = true;
356
+ }
357
+ catch (error) {
358
+ if (!missing(error))
359
+ throw sourceInvalid();
360
+ }
361
+ const requiresOperations = files.some(file => file.path.startsWith("dist/") && file.bytes.toString("utf8").includes("generated/agent-operations.js"));
362
+ if (requiresOperations && !hasOperations)
363
+ throw sourceInvalid();
364
+ if (hasOperations)
365
+ generatedModules.push("agent-operations.js");
366
+ if (modules.length + generatedModules.length > 64)
367
+ throw sourceInvalid();
368
+ for (const name of generatedModules)
369
+ files.push({ path: `dist/generated/${name}`, bytes: await readSource(join(dist, "generated", name)), mode: 0o600 });
309
370
  files.push({ path: "skills/manifest.json", bytes: Buffer.from(JSON.stringify({ formatVersion: 1, skills: ctx.assets.map(({ name, description }) => ({ name, description })) })), mode: 0o600 });
310
371
  for (const asset of ctx.assets)
311
372
  files.push({ path: `skills/${asset.name}/SKILL.md`, bytes: Buffer.from(asset.content), mode: 0o600 });
@@ -316,14 +377,14 @@ async function bundle(ctx, tree) {
316
377
  for (const file of files)
317
378
  digest.update(`${file.path}\0${file.mode}\0${file.bytes.length}\0`).update(file.bytes);
318
379
  const sha = digest.digest("hex"), directory = join(tree.home, ".caotang", "cli", "releases", sha), command = join(tree.home, ".local", "bin", "caotang");
319
- const shim = 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`);
380
+ const shim = managedShim(directory);
320
381
  return { files, sha, directory, command, shim, version: info.version };
321
382
  }
322
383
  function validateTargets(ctx, tree) {
323
- if (ctx.targets.length > PLATFORM_IDS.length || new Set(ctx.targets.map(target => target.id)).size !== ctx.targets.length)
384
+ if (ctx.targets.length > PLATFORM_IDS.length + 1 || new Set(ctx.targets.map(target => target.id)).size !== ctx.targets.length)
324
385
  throw unsafe();
325
386
  for (const target of ctx.targets) {
326
- if (!isPlatformId(target.id) || !platformSupported(target.id, ctx.operatingSystem) || !isAbsolute(target.skillDirectory) || tree.within(target.skillDirectory) === tree.home)
387
+ if (!isSkillTargetId(target.id) || target.id !== "generic" && !platformSupported(target.id, ctx.operatingSystem) || !isAbsolute(target.skillDirectory) || tree.within(target.skillDirectory) === tree.home)
327
388
  throw unsafe();
328
389
  }
329
390
  }
@@ -378,25 +439,160 @@ async function onPath(command, env) {
378
439
  }
379
440
  return false;
380
441
  }
442
+ // The fixed 1.1.0 baseline is admitted only after its complete public runtime
443
+ // inventory verifies. Never infer provenance from a version or receipt.
444
+ async function reviewedV110Bundle(tree, value) {
445
+ const pin = REVIEWED_MANAGED_BUNDLE_1_1_0, directory = join(tree.home, ".caotang", "cli", "releases", pin.sha);
446
+ const children = new Map([["", new Set()]]);
447
+ for (const file of pin.files) {
448
+ const parts = file.path.split("/");
449
+ let parent = "";
450
+ for (const [index, part] of parts.entries()) {
451
+ children.get(parent).add(part);
452
+ if (index < parts.length - 1) {
453
+ parent = parent ? `${parent}/${part}` : part;
454
+ if (!children.has(parent))
455
+ children.set(parent, new Set());
456
+ }
457
+ }
458
+ }
459
+ const verifyInventory = async () => {
460
+ for (const [path, expected] of children)
461
+ if ((await tree.list(join(directory, path)))?.join(",") !== [...expected].sort().join(","))
462
+ throw conflict();
463
+ };
464
+ await verifyInventory();
465
+ const files = [], digest = createHash("sha256");
466
+ for (const file of pin.files) {
467
+ const bytes = await tree.read(join(directory, file.path), file.bytes, file.mode);
468
+ if (!bytes || bytes.length !== file.bytes || hash(bytes) !== file.sha256)
469
+ throw conflict();
470
+ files.push({ path: file.path, bytes, mode: file.mode });
471
+ digest.update(`${file.path}\0${file.mode}\0${bytes.length}\0`).update(bytes);
472
+ }
473
+ if (digest.digest("hex") !== pin.sha)
474
+ throw conflict();
475
+ await verifyInventory();
476
+ const manifest = JSON.parse(files.find(file => file.path === "skills/manifest.json").bytes.toString("utf8"));
477
+ if (!completeSkillInventory(manifest.skills.map(item => item.name)))
478
+ throw conflict();
479
+ 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) }; });
480
+ return { bundle: { files, sha: pin.sha, version: pin.version, directory, command: value.command, shim: managedShim(directory) }, assets };
481
+ }
482
+ // Keep the reviewed 1.0.0 six-skill baseline unchanged. A directory digest alone
483
+ // cannot authorize replacing somebody's same-name skill.
484
+ async function legacyBundles(ctx, tree, value) {
485
+ if (!["1.0.1", "1.1.0", "1.1.1", "1.2.0"].includes(value.version) || ctx.agentName === undefined)
486
+ return [];
487
+ const releases = join(tree.home, ".caotang", "cli", "releases"), entries = await tree.list(releases) ?? [];
488
+ if (entries.length > 128)
489
+ throw unsafe();
490
+ const found = [];
491
+ 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" } }));
492
+ const manifest = Buffer.from(JSON.stringify({ formatVersion: 1, skills: LEGACY_MANAGED_SKILLS.map(({ name, description }) => ({ name, description })) }));
493
+ for (const sha of entries) {
494
+ if (!/^[a-f0-9]{64}$/.test(sha) || sha === value.sha)
495
+ continue;
496
+ const directory = join(releases, sha), packageBytes = await tree.read(join(directory, "package.json"), 32768, 0o600);
497
+ if (!packageBytes?.equals(runtime))
498
+ continue;
499
+ const root = await tree.list(directory), bin = await tree.list(join(directory, "bin")), dist = await tree.list(join(directory, "dist"));
500
+ const skills = await tree.list(join(directory, "skills")), generated = await tree.list(join(directory, "dist/generated"));
501
+ if (root?.join(",") !== "bin,dist,package.json,skills" || bin?.join(",") !== "caotang.cjs" || generated?.join(",") !== "knowledge-growth.js"
502
+ || !dist || dist.length > 64 || !dist.includes("cli.js") || !dist.includes("generated") || dist.some(name => name !== "generated" && !/^[a-z][a-z0-9-]*\.js$/.test(name))
503
+ || skills?.join(",") !== [...LEGACY_MANAGED_SKILLS.map(item => item.name), "manifest.json"].sort().join(","))
504
+ throw conflict();
505
+ if (!(await tree.read(join(directory, "skills/manifest.json"), 16384, 0o600))?.equals(manifest))
506
+ throw conflict();
507
+ const files = [{ path: "package.json", bytes: runtime, mode: 0o600 }, { path: "skills/manifest.json", bytes: manifest, mode: 0o600 }];
508
+ const assets = [];
509
+ for (const item of LEGACY_MANAGED_SKILLS) {
510
+ if ((await tree.list(join(directory, "skills", item.name)))?.join(",") !== "SKILL.md")
511
+ throw conflict();
512
+ const bytes = await tree.read(join(directory, "skills", item.name, "SKILL.md"), 65536, 0o600);
513
+ if (!bytes || hash(bytes) !== item.sha256)
514
+ throw conflict();
515
+ assets.push({ ...item, content: bytes.toString("utf8") });
516
+ files.push({ path: `skills/${item.name}/SKILL.md`, bytes, mode: 0o600 });
517
+ }
518
+ for (const path of ["bin/caotang.cjs", ...dist.filter(name => name !== "generated").map(name => `dist/${name}`), "dist/generated/knowledge-growth.js"]) {
519
+ const mode = path === "bin/caotang.cjs" ? 0o700 : 0o600, bytes = await tree.read(join(directory, path), 1048576, mode);
520
+ if (!bytes?.length)
521
+ throw conflict();
522
+ files.push({ path, bytes, mode });
523
+ }
524
+ files.sort((a, b) => a.path.localeCompare(b.path));
525
+ if (files.reduce((total, file) => total + file.bytes.length, 0) > 5242880)
526
+ throw conflict();
527
+ const digest = createHash("sha256");
528
+ for (const file of files)
529
+ digest.update(`${file.path}\0${file.mode}\0${file.bytes.length}\0`).update(file.bytes);
530
+ if (digest.digest("hex") !== sha)
531
+ throw conflict();
532
+ found.push({ bundle: { files, sha, version: "1.0.0", directory, command: value.command, shim: managedShim(directory) }, assets });
533
+ }
534
+ if (["1.1.0", "1.1.1", "1.2.0"].includes(value.version) && value.sha !== REVIEWED_MANAGED_BUNDLE_1_1_0.sha && entries.includes(REVIEWED_MANAGED_BUNDLE_1_1_0.sha))
535
+ found.push(await reviewedV110Bundle(tree, value));
536
+ return found;
537
+ }
538
+ async function installPlan(ctx, tree, value) {
539
+ const missing = [], modified = [];
540
+ const newlyInstalledPlatforms = [], upgradedPlatforms = [], replacements = [];
541
+ for (const target of ctx.targets) {
542
+ let absent = 0;
543
+ for (const raw of ctx.assets) {
544
+ const asset = bindSkillAgent(raw, ctx.agentName), dir = join(target.skillDirectory, asset.name), entries = await tree.list(dir);
545
+ if (entries === null || entries.length === 0) {
546
+ missing.push({ target, asset });
547
+ absent++;
548
+ continue;
549
+ }
550
+ if (entries.length !== 1 || entries[0] !== "SKILL.md")
551
+ throw skillConflict();
552
+ const bytes = await tree.read(join(dir, "SKILL.md"), 65536);
553
+ if (!bytes)
554
+ throw skillConflict();
555
+ if (!bytes.equals(Buffer.from(asset.content)))
556
+ modified.push({ target, asset, bytes });
557
+ }
558
+ if (absent === ctx.assets.length)
559
+ newlyInstalledPlatforms.push(target.id);
560
+ }
561
+ const command = await tree.read(value.command);
562
+ if (command && ((await lstat(value.command)).mode & 0o100) === 0)
563
+ throw unsafe();
564
+ const legacy = modified.length || command && !command.equals(value.shim) ? await legacyBundles(ctx, tree, value) : [];
565
+ for (const item of modified) {
566
+ const known = legacy.some(source => source.assets.some(asset => asset.name === item.asset.name && Buffer.from(bindSkillAgent(asset, ctx.agentName).content).equals(item.bytes)));
567
+ if (!known)
568
+ throw skillConflict();
569
+ const path = join(item.target.skillDirectory, item.asset.name, "SKILL.md");
570
+ if (!(await tree.read(path, 65536, 0o600))?.equals(item.bytes))
571
+ throw unsafe();
572
+ replacements.push({ path, before: item.bytes, after: Buffer.from(item.asset.content), mode: 0o600, label: `${item.target.id}:${item.asset.name}` });
573
+ if (!upgradedPlatforms.includes(item.target.id))
574
+ upgradedPlatforms.push(item.target.id);
575
+ }
576
+ if (command && !command.equals(value.shim)) {
577
+ if (!legacy.some(source => source.bundle.shim.equals(command)))
578
+ throw conflict();
579
+ if (!(await tree.read(value.command, 1048576, 0o700))?.equals(command))
580
+ throw unsafe();
581
+ // Always switch the shared stable command after every selected skill succeeds.
582
+ replacements.push({ path: value.command, before: command, after: value.shim, mode: 0o700, label: "command" });
583
+ }
584
+ return { missing, replacements, newlyInstalledPlatforms, upgradedPlatforms };
585
+ }
381
586
  export async function installLocal(ctx) {
382
587
  const tree = new OwnedTree(ctx.homeDir), changed = [];
383
588
  let changesPossible = false;
384
- const newlyInstalledPlatforms = [];
385
589
  try {
386
590
  validateTargets(ctx, tree);
387
591
  if (!ctx.targets.length && !ctx.agentName)
388
592
  throw new CliError("no_supported_platform", "未识别到支持的AI客户端。", "核对Claude Code、Codex、OpenClaw,或macOS上的Kimi Code、Kimi工作模式、WorkBuddy后再接入;不会自动安装客户端。");
389
593
  const value = await bundle(ctx, tree);
390
594
  await tree.directory(tree.home, false, async () => { });
391
- for (const target of ctx.targets)
392
- for (const asset of ctx.assets)
393
- if (await skillState(tree, target, bindSkillAgent(asset, ctx.agentName)) === "modified")
394
- throw skillConflict();
395
- const existingCommand = await tree.read(value.command);
396
- if (existingCommand && !existingCommand.equals(value.shim))
397
- throw conflict();
398
- if (existingCommand && ((await lstat(value.command)).mode & 0o100) === 0)
399
- throw unsafe();
595
+ await installPlan(ctx, tree, value);
400
596
  if (await bundleState(tree, value) === "modified")
401
597
  throw conflict();
402
598
  const lockPath = join(tree.home, ".caotang", ".setup.lock");
@@ -411,13 +607,7 @@ export async function installLocal(ctx) {
411
607
  const state = await bundleState(tree, value);
412
608
  if (state === "modified")
413
609
  throw conflict();
414
- for (const target of ctx.targets) {
415
- const statuses = await Promise.all(ctx.assets.map(asset => skillState(tree, target, bindSkillAgent(asset, ctx.agentName))));
416
- if (statuses.includes("modified"))
417
- throw skillConflict();
418
- if (statuses.every(status => status === "missing"))
419
- newlyInstalledPlatforms.push(target.id);
420
- }
610
+ const originalPlan = await installPlan(ctx, tree, value);
421
611
  if (state === "missing") {
422
612
  const created = await tree.createDirectory(value.directory);
423
613
  if (!created)
@@ -428,25 +618,79 @@ export async function installLocal(ctx) {
428
618
  if (await bundleState(tree, value) !== "installed")
429
619
  throw conflict();
430
620
  }
431
- for (const target of ctx.targets)
432
- for (const asset of ctx.assets) {
433
- if (await skillState(tree, target, bindSkillAgent(asset, ctx.agentName)) === "installed")
434
- continue;
435
- const dir = join(target.skillDirectory, asset.name), created = await tree.createDirectory(dir);
436
- if (!created && (await tree.list(dir))?.length !== 0)
437
- throw skillConflict();
438
- changed.push(`${target.id}:${asset.name}`);
439
- await tree.writeNew(join(dir, "SKILL.md"), Buffer.from(bindSkillAgent(asset, ctx.agentName).content), 0o600);
440
- if (await skillState(tree, target, bindSkillAgent(asset, ctx.agentName)) !== "installed")
441
- throw skillConflict();
621
+ for (const { target, asset } of originalPlan.missing) {
622
+ const dir = join(target.skillDirectory, asset.name), created = await tree.createDirectory(dir);
623
+ if (!created && (await tree.list(dir))?.length !== 0)
624
+ throw skillConflict();
625
+ changed.push(`${target.id}:${asset.name}`);
626
+ await tree.writeNew(join(dir, "SKILL.md"), Buffer.from(asset.content), 0o600);
627
+ if (await skillState(tree, target, asset) !== "installed")
628
+ throw skillConflict();
629
+ }
630
+ const plan = await installPlan(ctx, tree, value), attempted = [];
631
+ let recoveryJournal;
632
+ if (plan.replacements.length) {
633
+ 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") })) }));
634
+ if (snapshot.length > 1048576)
635
+ throw unsafe();
636
+ recoveryJournal = join(tree.home, ".caotang", "cli", "upgrades", hash(snapshot) + ".json");
637
+ await tree.writeNew(recoveryJournal, snapshot, 0o600);
638
+ if (!(await tree.read(recoveryJournal, 1048576, 0o600))?.equals(snapshot))
639
+ throw unsafe();
640
+ await tree.directory(dirname(recoveryJournal), false, async (directories) => {
641
+ for (const directory of directories)
642
+ await directory.handle.sync();
643
+ });
644
+ }
645
+ try {
646
+ for (const entry of plan.replacements) {
647
+ attempted.push(entry);
648
+ changed.push(entry.label);
649
+ await tree.replaceManaged(entry);
650
+ }
651
+ if (await tree.writeNew(value.command, value.shim, 0o700))
652
+ changed.push("command");
653
+ if (!(await tree.read(value.command))?.equals(value.shim) || ((await lstat(value.command)).mode & 0o100) === 0 || await bundleState(tree, value) !== "installed")
654
+ throw conflict();
655
+ for (const target of ctx.targets)
656
+ for (const asset of ctx.assets)
657
+ if (await skillState(tree, target, bindSkillAgent(asset, ctx.agentName)) !== "installed")
658
+ throw skillConflict();
659
+ }
660
+ catch (error) {
661
+ if (!recoveryJournal)
662
+ throw error;
663
+ let restored = true;
664
+ for (const entry of attempted.reverse()) {
665
+ try {
666
+ const current = await tree.read(entry.path, 1048576, entry.mode);
667
+ if (!current?.equals(entry.before)) {
668
+ if (!entry.identity) {
669
+ restored = false;
670
+ continue;
671
+ }
672
+ await tree.replaceManaged(entry, true);
673
+ }
674
+ if (!(await tree.read(entry.path, 1048576, entry.mode))?.equals(entry.before)
675
+ || entry.identity && !same(await lstat(entry.path), entry.identity))
676
+ restored = false;
677
+ }
678
+ catch {
679
+ restored = false;
680
+ }
442
681
  }
443
- if (await tree.writeNew(value.command, value.shim, 0o700))
444
- changed.push("command");
682
+ const recovery = { upgrade_status: restored ? "rolled_back" : "unknown", recovery_journal: recoveryJournal };
683
+ if (restored && error instanceof CliError && error.code === "installation_permission_denied") {
684
+ Object.assign(error.details, recovery);
685
+ throw error;
686
+ }
687
+ throw new CliError(restored ? "installation_upgrade_failed" : "installation_upgrade_unknown", restored ? "本地升级未完成,已核对并恢复本轮改动的原文件。" : "无法确认本地升级结果,部分受管文件可能尚未恢复。", "保留旧版本与恢复原件,请本人核对 recovery_journal;不要强制覆盖、删除、重新授权或重复业务写入。", 7, recovery);
688
+ }
689
+ return { changed, bundleSha: value.sha, bundleDirectory: value.directory, newlyInstalledPlatforms: originalPlan.newlyInstalledPlatforms, upgradedPlatforms: originalPlan.upgradedPlatforms };
445
690
  }
446
691
  finally {
447
692
  release();
448
693
  }
449
- return { changed, bundleSha: value.sha, bundleDirectory: value.directory, newlyInstalledPlatforms };
450
694
  }
451
695
  catch (error) {
452
696
  const safe = error instanceof CliError ? error
@@ -483,9 +727,54 @@ export async function inspectLocalInstallation(ctx) {
483
727
  }
484
728
  skills.push({ name: asset.name, status });
485
729
  if (target.detected && status !== "installed")
486
- issue(status === "missing" ? "skill_missing" : status === "modified" ? "skill_modified" : "skill_unsafe", "本地技能缺失、已修改或不满足安全要求。", status === "missing" ? `运行 setup --platform ${target.id} 安装缺失技能。` : "保留现有技能并人工核对,不自动覆盖或修复。");
730
+ issue(status === "missing" ? "skill_missing" : status === "modified" ? "skill_modified" : "skill_unsafe", "本地技能缺失、已修改或不满足安全要求。", status === "missing" ? (target.id === "generic" ? "保留本次 --skills-dir 的明确目录再运行 setup;不会猜测其他技能位置。" : `运行 setup --platform ${target.id} 安装缺失技能。`) : "保留现有技能并人工核对,不自动覆盖或修复。");
487
731
  }
488
732
  platforms.push({ id: target.id, detected: target.detected, skill_directory: target.skillDirectory, skills });
489
733
  }
490
734
  return { command: { installed, on_path, version: value.version, ...(installed ? { executable_path: value.command } : {}) }, platforms, issues };
491
735
  }
736
+ // Read-only evidence for known platform paths, never an installation/overwrite
737
+ // permit. Only the current name's exact public templates count as its skills.
738
+ export async function inspectManagedOrigins(ctx) {
739
+ if (ctx.agentName === undefined)
740
+ throw new CliError("agent_name_required", "请用你自己的名字再运行 setup --agent <你的名字>。", "之后每条命令都带同一个 --agent <你的名字>;不会借用其他 AI 的连接。");
741
+ const tree = new OwnedTree(ctx.homeDir);
742
+ validateTargets(ctx, tree);
743
+ const value = await bundle(ctx, tree), assets = ctx.assets.map(asset => bindSkillAgent(asset, ctx.agentName));
744
+ let legacy = [];
745
+ try {
746
+ legacy = await legacyBundles(ctx, tree, value);
747
+ }
748
+ catch { /* Unverified old files provide no evidence; never promote them. */ }
749
+ const platforms = [];
750
+ for (const target of ctx.targets) {
751
+ const skills = [];
752
+ for (const asset of assets) {
753
+ let status;
754
+ try {
755
+ status = await skillState(tree, target, asset);
756
+ if (status === "modified") {
757
+ const dir = join(target.skillDirectory, asset.name), entries = await tree.list(dir);
758
+ if (entries?.join(",") === "SKILL.md") {
759
+ const bytes = await tree.read(join(dir, "SKILL.md"), 65536, 0o600);
760
+ if (bytes && legacy.some(source => source.assets.some(old => old.name === asset.name && Buffer.from(bindSkillAgent(old, ctx.agentName).content).equals(bytes))))
761
+ status = "outdated";
762
+ }
763
+ }
764
+ }
765
+ catch {
766
+ status = "unsafe";
767
+ }
768
+ skills.push({ name: asset.name, status });
769
+ }
770
+ platforms.push({ id: target.id, skill_directory: target.skillDirectory, ownKnownCount: skills.filter(skill => skill.status === "installed" || skill.status === "outdated").length, skills });
771
+ }
772
+ let command = { legacyManaged: false, on_path: false };
773
+ try {
774
+ const bytes = await tree.read(value.command, 1048576, 0o700), old = legacy.find(source => bytes?.equals(source.bundle.shim));
775
+ if (old)
776
+ command = { legacyManaged: true, on_path: await onPath(value.command, ctx.env), version: old.bundle.version, executable_path: value.command };
777
+ }
778
+ catch { /* An unsafe command must not be called a verified old command. */ }
779
+ return { platforms, command };
780
+ }