@yijiancaotang/cli 1.0.0 → 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.
@@ -1,4 +1,4 @@
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";
@@ -7,6 +7,8 @@ import { CliError, asRecord } from "./errors.js";
7
7
  import { PLATFORM_IDS, isPlatformId, 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();
@@ -316,7 +358,7 @@ async function bundle(ctx, tree) {
316
358
  for (const file of files)
317
359
  digest.update(`${file.path}\0${file.mode}\0${file.bytes.length}\0`).update(file.bytes);
318
360
  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`);
361
+ const shim = managedShim(directory);
320
362
  return { files, sha, directory, command, shim, version: info.version };
321
363
  }
322
364
  function validateTargets(ctx, tree) {
@@ -378,25 +420,160 @@ async function onPath(command, env) {
378
420
  }
379
421
  return false;
380
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
+ }
381
567
  export async function installLocal(ctx) {
382
568
  const tree = new OwnedTree(ctx.homeDir), changed = [];
383
569
  let changesPossible = false;
384
- const newlyInstalledPlatforms = [];
385
570
  try {
386
571
  validateTargets(ctx, tree);
387
572
  if (!ctx.targets.length && !ctx.agentName)
388
573
  throw new CliError("no_supported_platform", "未识别到支持的AI客户端。", "核对Claude Code、Codex、OpenClaw,或macOS上的Kimi Code、Kimi工作模式、WorkBuddy后再接入;不会自动安装客户端。");
389
574
  const value = await bundle(ctx, tree);
390
575
  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();
576
+ await installPlan(ctx, tree, value);
400
577
  if (await bundleState(tree, value) === "modified")
401
578
  throw conflict();
402
579
  const lockPath = join(tree.home, ".caotang", ".setup.lock");
@@ -411,13 +588,7 @@ export async function installLocal(ctx) {
411
588
  const state = await bundleState(tree, value);
412
589
  if (state === "modified")
413
590
  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
- }
591
+ const originalPlan = await installPlan(ctx, tree, value);
421
592
  if (state === "missing") {
422
593
  const created = await tree.createDirectory(value.directory);
423
594
  if (!created)
@@ -428,25 +599,79 @@ export async function installLocal(ctx) {
428
599
  if (await bundleState(tree, value) !== "installed")
429
600
  throw conflict();
430
601
  }
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();
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;
442
667
  }
443
- if (await tree.writeNew(value.command, value.shim, 0o700))
444
- changed.push("command");
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 };
445
671
  }
446
672
  finally {
447
673
  release();
448
674
  }
449
- return { changed, bundleSha: value.sha, bundleDirectory: value.directory, newlyInstalledPlatforms };
450
675
  }
451
676
  catch (error) {
452
677
  const safe = error instanceof CliError ? error
@@ -489,3 +714,48 @@ export async function inspectLocalInstallation(ctx) {
489
714
  }
490
715
  return { command: { installed, on_path, version: value.version, ...(installed ? { executable_path: value.command } : {}) }, platforms, issues };
491
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
+ }
@@ -6,12 +6,13 @@ import { fileURLToPath } from "node:url";
6
6
  import { authLogin } from "./auth.js";
7
7
  import { doctor } from "./doctor.js";
8
8
  import { CliError, asRecord } from "./errors.js";
9
- import { inspectLocalInstallation, installLocal } from "./installation.js";
9
+ import { inspectLocalInstallation, inspectManagedOrigins, installLocal } from "./installation.js";
10
10
  import { isPlatformId, platformSupported } from "./local-types.js";
11
- import { detectPlatforms } from "./platforms.js";
11
+ import { detectPlatforms, knownSkillTargets } from "./platforms.js";
12
12
  import { loadSkillAssets } from "./skill-assets.js";
13
+ import { LocalRecords } from "./local-records.js";
13
14
  import { defaultConnectionNames, inspectAccountState, onboardingMessage } from "./onboarding.js";
14
- import { setupFailureMessage } from "./connection-copy.js";
15
+ import { connectionCopy, setupFailureMessage } from "./connection-copy.js";
15
16
  const root = () => fileURLToPath(new URL("../", import.meta.url));
16
17
  const invalid = () => new CliError("invalid_arguments", "命令或参数无效。", "运行 --help;平台支持claude-code、codex、openclaw,以及仅macOS支持的kimi-code、kimi-work和workbuddy。");
17
18
  export function selectedPlatforms(value, operatingSystem = process.platform) {
@@ -53,33 +54,97 @@ export async function localVersion(packageRoot = root()) {
53
54
  export function manualUpdateInstructions() {
54
55
  return { status: 200, body: { operation: "update", update_policy: "manual", next_action: "运行 npx @yijiancaotang/cli@latest setup --agent <你的名字> 更新到最新版。" } };
55
56
  }
56
- async function targetsFor(ctx, ids, setup) {
57
+ function connectionTimeInBeijing(value) {
58
+ if (!value)
59
+ return undefined;
60
+ const timestamp = Date.parse(value);
61
+ if (!Number.isFinite(timestamp))
62
+ return undefined;
63
+ const parts = new Intl.DateTimeFormat("en-GB", {
64
+ timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit",
65
+ hour: "2-digit", minute: "2-digit", hourCycle: "h23",
66
+ }).formatToParts(timestamp);
67
+ const fields = Object.fromEntries(parts.map(part => [part.type, part.value]));
68
+ return `${fields.year}-${fields.month}-${fields.day} ${fields.hour}:${fields.minute}`;
69
+ }
70
+ const recordsFor = (ctx) => ctx.agent ? new LocalRecords(ctx.homeDir, ctx.agent, ctx.auth.environment ?? "production", new URL(ctx.auth.api.baseUrl).origin) : undefined;
71
+ async function targetsFor(ctx, ids, setup, assets, recorded) {
72
+ const inspect = (targets) => inspectManagedOrigins({ ...ctx, agentName: ctx.agent?.key, assets, targets });
57
73
  if (ctx.agent) {
58
74
  if (ids && ids.length !== 1)
59
75
  throw new CliError("invalid_arguments", "每次接入只给一个 AI 平台安装技能。", "请带自己的 --agent 名字,一次只选一个 --platform;其它 AI 分别接入。");
60
- ids = ids ?? (ctx.agent.platform ? [ctx.agent.platform] : []);
61
- if (!ids.length)
62
- return [];
76
+ if (ids === undefined && recorded.length === 0) {
77
+ const candidates = await knownSkillTargets({ ...ctx, skipInvalidCandidates: true }), origins = await inspect(candidates);
78
+ const own = candidates.filter(target => origins.platforms.some(platform => platform.id === target.id && platform.skill_directory === target.skillDirectory && platform.ownKnownCount > 0))
79
+ .map(target => ({ ...target, detected: true, evidence: "skills" }));
80
+ if (own.length > 0 || !ctx.agent.platform)
81
+ return { targets: own, origins };
82
+ }
83
+ ids = ids ?? (recorded.length ? recorded.map(target => target.id) : ctx.agent.platform ? [ctx.agent.platform] : []);
84
+ }
85
+ const redirected = (id) => id === "claude-code" ? ctx.env.CLAUDE_CONFIG_DIR !== undefined
86
+ : id === "openclaw" ? ctx.env.OPENCLAW_STATE_DIR !== undefined || ctx.env.OPENCLAW_HOME !== undefined
87
+ : id === "kimi-code" && ctx.env.KIMI_CODE_HOME !== undefined;
88
+ const preferred = recorded.filter(target => (!ids || ids.includes(target.id)) && !redirected(target.id));
89
+ if (preferred.some(target => !platformSupported(target.id, ctx.operatingSystem)))
90
+ throw new CliError("platform_unsupported", "已记录的技能平台不支持当前系统。", "请在原支持的系统上检查该平台;不会猜测新目录或搬动技能。", 2);
91
+ const discoveryIds = ids?.filter(id => !preferred.some(target => target.id === id));
92
+ // A recorded custom target must not cause inspection of an unrelated default
93
+ // directory just to rediscover the same platform.
94
+ const discovered = discoveryIds?.length === 0 ? [] : await detectPlatforms({ homeDir: ctx.homeDir, env: ctx.env, applicationRoots: ctx.applicationRoots, operatingSystem: ctx.operatingSystem, platformIds: discoveryIds });
95
+ const all = [...preferred.map(target => ({ ...target })), ...discovered];
96
+ const origins = await inspect(all);
97
+ for (const target of all) {
98
+ if (ctx.agent && !target.detected && origins.platforms.some(platform => platform.id === target.id && platform.skill_directory === target.skillDirectory && platform.ownKnownCount > 0)) {
99
+ target.detected = true;
100
+ target.evidence = "skills";
101
+ }
63
102
  }
64
- const all = await detectPlatforms({ homeDir: ctx.homeDir, env: ctx.env, applicationRoots: ctx.applicationRoots, operatingSystem: ctx.operatingSystem, platformIds: ids });
65
- return ids ? all.filter(target => ids.includes(target.id)) : setup ? all.filter(target => target.detected) : all;
103
+ return { targets: ids ? all.filter(target => ids.includes(target.id)) : setup ? all.filter(target => target.detected) : all, origins };
66
104
  }
67
105
  export async function localDoctor(ctx, flags, authFailure) {
68
106
  return doctor({
69
107
  auth: ctx.auth, authFailure, agent: ctx.agent,
70
108
  inspectLocal: async () => {
71
109
  const assets = await loadSkillAssets(ctx.packageRoot ?? root());
72
- return inspectLocalInstallation({ ...ctx, agentName: ctx.agent?.key, assets, targets: await targetsFor(ctx, selectedPlatforms(flags.platform, ctx.operatingSystem), false) });
110
+ const recorded = await recordsFor(ctx)?.targets() ?? [];
111
+ const selection = await targetsFor(ctx, selectedPlatforms(flags.platform, ctx.operatingSystem), false, assets, recorded);
112
+ const inspected = await inspectLocalInstallation({ ...ctx, agentName: ctx.agent?.key, assets, targets: selection.targets });
113
+ const platforms = inspected.platforms.map(platform => {
114
+ const origin = selection.origins.platforms.find(origin => origin.id === platform.id && origin.skill_directory === platform.skill_directory);
115
+ return origin ? { ...platform, skills: origin.skills } : platform;
116
+ });
117
+ const issues = inspected.issues.filter(issue => !["skill_missing", "skill_modified", "skill_unsafe"].includes(issue.code));
118
+ let command = inspected.command;
119
+ if (selection.origins.command.legacyManaged) {
120
+ command = { ...command, installed: true, ...selection.origins.command };
121
+ for (let index = issues.length - 1; index >= 0; index--)
122
+ if (issues[index].code === "command_modified")
123
+ issues.splice(index, 1);
124
+ issues.push({ code: "command_outdated", message: "已核对为旧版受管命令行,需要更新。", next_action: "沿用原来的名字运行 npx @yijiancaotang/cli@latest setup --agent <你的名字> 更新,不需要重新批准连接。" });
125
+ }
126
+ const skillPlatforms = selection.targets.filter(target => recorded.some(record => record.id === target.id)
127
+ || selection.origins.platforms.some(origin => origin.id === target.id && origin.skill_directory === target.skillDirectory && origin.ownKnownCount > 0)).map(target => target.id);
128
+ return { ...inspected, command, platforms, issues, skill_platforms: skillPlatforms };
73
129
  },
74
130
  });
75
131
  }
76
132
  export async function localSetup(ctx, flags) {
77
- const assets = await loadSkillAssets(ctx.packageRoot ?? root()), targets = await targetsFor(ctx, selectedPlatforms(flags.platform, ctx.operatingSystem), true);
133
+ const records = recordsFor(ctx), recorded = await records?.targets() ?? [];
134
+ const assets = await loadSkillAssets(ctx.packageRoot ?? root()), { targets } = await targetsFor(ctx, selectedPlatforms(flags.platform, ctx.operatingSystem), true, assets, recorded);
78
135
  const local = { ...ctx, agentName: ctx.agent?.key, assets, targets };
79
136
  const installed = await installLocal(local);
137
+ await records?.rememberInstallation(targets, assets, installed.bundleSha);
138
+ for (const target of targets) {
139
+ if (!target.detected) {
140
+ target.detected = true;
141
+ target.evidence = "skills";
142
+ }
143
+ }
80
144
  const newEntries = installed.newlyInstalledPlatforms;
81
145
  let authFailure;
82
146
  let authorizationAttempted = false;
147
+ const currentSecrets = new Set(ctx.auth.tokenOverride ? [ctx.auth.tokenOverride] : []);
83
148
  // Observe the actual start request after authLogin's credential/abort checks.
84
149
  // Validation and doctor requests do not constitute a new authorization attempt.
85
150
  const observedAuth = {
@@ -89,6 +154,8 @@ export async function localSetup(ctx, flags) {
89
154
  request(path, options) {
90
155
  if (path === "/api/v1/agent/device-auth/start" && options?.method === "POST")
91
156
  authorizationAttempted = true;
157
+ if (path === "/api/v1/agent/mcp-token/validate" && typeof options?.token === "string")
158
+ currentSecrets.add(options.token);
92
159
  return ctx.auth.api.request(path, options);
93
160
  },
94
161
  upload: (url, headers, bytes) => ctx.auth.api.upload(url, headers, bytes),
@@ -97,10 +164,12 @@ export async function localSetup(ctx, flags) {
97
164
  };
98
165
  let newAuthorizationOwner;
99
166
  let loginIdentity;
167
+ let loginResult = null;
100
168
  const names = defaultConnectionNames(targets.map(target => target.id), ctx.computerName, ctx.agent?.name);
101
169
  try {
102
170
  const login = await authLogin(observedAuth, { device_name: flags["device-name"] ?? names.deviceName, client_name: flags["client-name"] ?? names.clientName, noOpen: flags["no-open"] === true });
103
171
  const who = asRecord(login.body);
172
+ loginResult = who;
104
173
  if (typeof who?.user_id === "string" && typeof who.token_id === "string" && (who.kind === "manual" || who.kind === "device") && (who.scope === "read_only" || who.scope === "read_write")) {
105
174
  loginIdentity = { userId: who.user_id, tokenId: who.token_id, kind: who.kind, scope: who.scope };
106
175
  }
@@ -111,7 +180,7 @@ export async function localSetup(ctx, flags) {
111
180
  authFailure = error instanceof CliError ? error : new CliError("authorization_delivery_unknown", "无法确认授权操作的结果。", "先在网页核对并吊销未保存的设备,不要自动重新签发。", 6);
112
181
  }
113
182
  let checkedIdentity;
114
- const report = await doctor({ auth: ctx.auth, authFailure, agent: ctx.agent, inspectLocal: () => inspectLocalInstallation(local), onIdentity: who => { checkedIdentity = who; } });
183
+ const report = await doctor({ auth: ctx.auth, authFailure, agent: ctx.agent, inspectLocal: async () => ({ ...await inspectLocalInstallation(local), skill_platforms: targets.map(target => target.id) }), onIdentity: who => { checkedIdentity = who; } });
115
184
  const body = asRecord(report.result.body);
116
185
  if (!body)
117
186
  throw new CliError("diagnostic_unavailable", "本地安装已执行,但无法确认最终诊断。", "先运行doctor只读核对,不要直接重新授权。", 7);
@@ -129,8 +198,32 @@ export async function localSetup(ctx, flags) {
129
198
  const accountState = newEntries.length && body.ready === true && checkedIdentity ? await inspectAccountState(ctx.auth, checkedIdentity.userId) : "unknown";
130
199
  const greeting = body.ready === true && checkedIdentity ? onboardingMessage(newEntries, accountState, checkedIdentity.scope)
131
200
  : { account_state: "unknown", onboarding: { action: "none", newly_connected_platforms: [] }, user_message: setupFailureMessage(identityChanged ? "authorization_identity_changed" : authFailure?.code) };
201
+ const reused = Boolean(sameConnection && loginResult?.reused_existing === true);
202
+ let connection;
203
+ if (sameConnection && ctx.agent && checkedIdentity) {
204
+ if (reused) {
205
+ connection = ctx.auth.tokenOverride !== undefined ? { connection_name: ctx.agent.key, connection_name_source: "environment_override" }
206
+ : await records?.connection(checkedIdentity.tokenId, [...currentSecrets]) ?? { connection_name: ctx.agent.key, connection_name_source: "local_agent_key" };
207
+ }
208
+ else if (body.ready === true && loginResult?.new_authorization_confirmed === true && typeof loginResult.connection_name === "string" && typeof loginResult.connected_at === "string") {
209
+ connection = { connection_name: loginResult.connection_name, connection_name_source: "local_authorization_record", connected_at: loginResult.connected_at, connection_time_source: "device_authorization" };
210
+ await records?.rememberConnection(checkedIdentity.tokenId, connection, [...currentSecrets]);
211
+ }
212
+ }
213
+ const onboardingCopy = reused && checkedIdentity && (connection?.connection_name_source !== "environment_override"
214
+ ? greeting.onboarding.action !== "none" || checkedIdentity.scope === "read_only"
215
+ : greeting.onboarding.action === "none" && checkedIdentity.scope === "read_only")
216
+ ? { onboarding_message: greeting.onboarding.action !== "none" ? greeting.user_message : connectionCopy.read_only } : {};
217
+ const connectionDisplayTime = connectionTimeInBeijing(connection?.connected_at);
218
+ const reuseCopy = reused && connection && connection.connection_name_source !== "environment_override" ? {
219
+ user_message: "这个名字已经接过了,沿用原来的连接,不用再批准。",
220
+ next_action: `已沿用名为 ${connection.connection_name} 的连接${connectionDisplayTime ? `(${connectionDisplayTime} 接入)` : ""}。如果你不是它,请用你自己的名字重新运行 setup --agent <你的名字>,不要继续用这条连接。`,
221
+ ...(connection.connection_name_source === "local_agent_key" ? { connection_name_note: "本机仅记有规范化名字,原连接名称和接入时间未记录。" } : {}),
222
+ } : reused && connection?.connection_name_source === "environment_override" ? {
223
+ reuse_notice: "本次沿用环境中提供的授权,不是本机保存的同名连接。若要独立接入,请由本人先处理环境授权覆盖,再用自己的名字运行 setup --agent <你的名字>。",
224
+ } : {};
132
225
  return { exitCode: report.exitCode, result: { status: 200, body: { ...body, operation: "setup", local_installation: { changed: installed.changed, bundle_sha: installed.bundleSha }, authorization_attempted: authorizationAttempted,
133
- new_authorization_confirmed: confirmed, ...(confirmed ? { new_authorization_owner_id: newAuthorizationOwner } : {}), ...greeting,
134
- ...(ctx.agent && !targets.length ? { generic_usage: { message: "这个 AI 没有专用技能安装位置;请按下列命令使用自己的连接。", commands: ["doctor", "context --start", "projects", "knowledge", "search", "auth status"].map(command => `caotang --agent ${shellWord(ctx.agent.name)} ${command}`) } } : {}),
226
+ new_authorization_confirmed: confirmed, ...(confirmed ? { new_authorization_owner_id: newAuthorizationOwner } : {}), reused_existing: reused, ...connection, ...greeting, ...onboardingCopy, ...reuseCopy,
227
+ ...(ctx.agent && !targets.length ? { generic_usage: { message: "暂未定位到这个名字的专用技能;若曾使用自定义目录,请保留原目录参数再检查。仍可按下列命令使用自己的连接。", commands: ["doctor", "context --start", "projects", "knowledge", "search", "auth status"].map(command => `caotang --agent ${shellWord(ctx.agent.name)} ${command}`) } } : {}),
135
228
  ...(body.ready === true ? { next_conversation: "请开新对话说‘开工了’。" } : {}) } } };
136
229
  }