@jackss119/shelf 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.
package/bin/shelf.mjs CHANGED
@@ -1,78 +1,90 @@
1
- #!/usr/bin/env node
2
- import {
3
- cmdShelfBrowse,
4
- cmdShelfPull,
5
- cmdShelfPush,
6
- cmdShelfCreate,
7
- cmdShelfSync,
8
- cmdShelfInit,
9
- cmdShelfHome,
10
- } from "../lib/commands/shelf.mjs";
11
-
12
- function usage() {
13
- console.log(`shelf — 个人内容货架 CLI(别名 atk)
14
-
15
- Usage:
16
- shelf 交互浏览货架(数字进目录,p 1,3-5 拉取,a 全部)
17
- shelf pull <路径...> 按路径拉取,如 shelf pull skills/common/intj
18
- [--dest <目录>]
19
- shelf create <本地路径> 上架新货:全架查重名,交互选位(m <名> 建目录,
20
- d 放下)[--to <货架目录>] [--yes]
21
- shelf push <本地路径> 更新已有货:按记账/名字自动定位
22
- [--yes] [--force] [--force-secret]
23
- shelf sync [--dry-run] 按账本对账:库上新版自动更新本地;本地有改动
24
- 则显示 diff 由你决定推上库还是覆盖本地
25
- shelf init 初始化工作区(.shelf.json + shelf-ops 手册)
26
- shelf home [--update] 查看货架在哪、什么模式;--update 拉取最新
27
-
28
- 货架定位:SHELF_HOME 环境变量 > 本 clone > ~/.shelfrc > 托管档口 ~/.shelf/home
29
- 免 clone 直跑:npx -y -p github:Jackzz119/my-workspace shelf <命令>`);
30
- }
31
-
32
- const argv = process.argv.slice(2);
33
- const sub = argv[0];
34
- const rest = argv.slice(1);
35
-
36
- try {
37
- switch (sub) {
38
- case undefined:
39
- case "browse":
40
- await cmdShelfBrowse();
41
- break;
42
- case "pull":
43
- await cmdShelfPull(rest);
44
- break;
45
- case "create":
46
- await cmdShelfCreate(rest);
47
- break;
48
- case "push":
49
- await cmdShelfPush(rest);
50
- break;
51
- case "sync":
52
- await cmdShelfSync(rest);
53
- break;
54
- case "init":
55
- await cmdShelfInit(rest);
56
- break;
57
- case "home":
58
- await cmdShelfHome(rest);
59
- break;
60
- case "-h":
61
- case "--help":
62
- case "help":
63
- usage();
64
- break;
65
- case "skills":
66
- case "list":
67
- console.error(`'shelf ${sub}' 已随老版技能命令清退:浏览用 shelf,按路径拉取用 shelf pull <路径> --dest .claude/skills,保持最新用 shelf sync`);
68
- process.exit(1);
69
- break;
70
- default:
71
- console.error(`Unknown command: ${sub}\n`);
72
- usage();
73
- process.exit(1);
74
- }
75
- } catch (err) {
76
- console.error(err.message || err);
77
- process.exit(1);
78
- }
1
+ #!/usr/bin/env node
2
+ import {
3
+ cmdShelfBrowse,
4
+ cmdShelfPull,
5
+ cmdShelfPush,
6
+ cmdShelfCreate,
7
+ cmdShelfSync,
8
+ cmdShelfInit,
9
+ cmdShelfAgents,
10
+ cmdShelfAdopt,
11
+ cmdShelfHome,
12
+ } from "../lib/commands/shelf.mjs";
13
+
14
+ function usage() {
15
+ console.log(`shelf — 个人内容货架 CLI(别名 atk)
16
+
17
+ Usage:
18
+ shelf 交互浏览货架(数字进目录,p 1,3-5 拉取,a 全部)
19
+ shelf pull <路径...> 按路径拉取,如 shelf pull skills/common/intj
20
+ [--dest <目录>]
21
+ shelf create <本地路径> 上架新货:全架查重名,交互选位(m <名> 建目录,
22
+ d 放下)[--to <货架目录>] [--yes]
23
+ shelf push <本地路径> 更新已有货:按记账/名字自动定位
24
+ [--yes] [--force] [--force-secret]
25
+ shelf sync [--dry-run] 按账本对账:库上新版自动更新本地;本地有改动
26
+ 则显示 diff 由你决定推上库还是覆盖本地
27
+ shelf init 项目接入(幂等):交互多选 agent 目标,或
28
+ --agents claude,codex,kimi / all
29
+ shelf agents [add <名>] 查看 / 追加 agent 目标(claude / codex / kimi)
30
+ shelf adopt 收编外来技能:实体目录迁入正本重链 + 未记账技能
31
+ 登记进账本 local 段(不上货架)
32
+ shelf home [--update] 查看货架在哪、什么模式;--update 拉取最新
33
+
34
+ 货架定位:SHELF_HOME 环境变量 > 本 clone > ~/.shelfrc > 托管档口 ~/.shelf/home
35
+ 免 clone 直跑:npx -y -p github:Jackzz119/my-workspace shelf <命令>`);
36
+ }
37
+
38
+ const argv = process.argv.slice(2);
39
+ const sub = argv[0];
40
+ const rest = argv.slice(1);
41
+
42
+ try {
43
+ switch (sub) {
44
+ case undefined:
45
+ case "browse":
46
+ await cmdShelfBrowse();
47
+ break;
48
+ case "pull":
49
+ await cmdShelfPull(rest);
50
+ break;
51
+ case "create":
52
+ await cmdShelfCreate(rest);
53
+ break;
54
+ case "push":
55
+ await cmdShelfPush(rest);
56
+ break;
57
+ case "sync":
58
+ await cmdShelfSync(rest);
59
+ break;
60
+ case "init":
61
+ await cmdShelfInit(rest);
62
+ break;
63
+ case "agents":
64
+ await cmdShelfAgents(rest);
65
+ break;
66
+ case "adopt":
67
+ await cmdShelfAdopt(rest);
68
+ break;
69
+ case "home":
70
+ await cmdShelfHome(rest);
71
+ break;
72
+ case "-h":
73
+ case "--help":
74
+ case "help":
75
+ usage();
76
+ break;
77
+ case "skills":
78
+ case "list":
79
+ console.error(`'shelf ${sub}' 已随老版技能命令清退:浏览用 shelf,按路径拉取用 shelf pull <路径> --dest .claude/skills,保持最新用 shelf sync`);
80
+ process.exit(1);
81
+ break;
82
+ default:
83
+ console.error(`Unknown command: ${sub}\n`);
84
+ usage();
85
+ process.exit(1);
86
+ }
87
+ } catch (err) {
88
+ console.error(err.message || err);
89
+ process.exit(1);
90
+ }
@@ -732,33 +732,365 @@ export async function cmdShelfCreate(argv) {
732
732
  }
733
733
  }
734
734
 
735
- // ---- 子命令:init ----
735
+ // ---- init 植入协议(SHELF 决策 #21/#22/#23)----
736
+
737
+ // gitignore 补行:只补缺失的,其余不动;目录行同时认 ".claude" 与 ".claude/"
738
+ function ensureGitignore(cwd, lines) {
739
+ const p = path.join(cwd, ".gitignore");
740
+ const content = fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
741
+ const NL = String.fromCharCode(10);
742
+ const CR = String.fromCharCode(13);
743
+ const have = new Set(content.split(NL).map((l) => l.replaceAll(CR, "").trim()));
744
+ const missing = lines.filter((l) => {
745
+ const bare = l.endsWith("/") ? l.slice(0, -1) : l;
746
+ return !have.has(bare) && !have.has(bare + "/");
747
+ });
748
+ if (missing.length === 0) return [];
749
+ const sep = content === "" || content.endsWith(NL) ? "" : NL;
750
+ fs.writeFileSync(p, content + sep + "# shelf init: agent 资产不入项目库" + NL + missing.join(NL) + NL, "utf8");
751
+ return missing;
752
+ }
753
+
754
+ // ---- agent 目标与技能正本(SHELF 决策 #24/#26)----
755
+
756
+ // 项目里技能的唯一正本;所有 agent 目录都是指向它的链接
757
+ const CANONICAL_SKILLS_DIR = "ai/jaSkills";
758
+
759
+ const AGENT_TARGETS = {
760
+ claude: { skillsDir: ".claude/skills", doc: { input: "agents/claude/CLAUDE.md", dest: "CLAUDE.md" }, ignore: [".claude/", "CLAUDE.md"] },
761
+ codex: { skillsDir: ".codex/skills", doc: { input: "agents/codex/AGENTS.md", dest: "AGENTS.md" }, ignore: [".codex/", "AGENTS.md"] },
762
+ kimi: { skillsDir: ".kimi/skills", doc: { input: "agents/codex/AGENTS.md", dest: "AGENTS.md" }, ignore: [".kimi/", "AGENTS.md"] },
763
+ };
764
+ const TARGET_PRIORITY = ["claude", "codex", "kimi"];
765
+
766
+ // "claude,codex" / "all" → 校验并按固定顺序去重
767
+ function parseTargetNames(spec) {
768
+ const names = spec === "all"
769
+ ? [...TARGET_PRIORITY]
770
+ : String(spec).split(",").map((x) => x.trim()).filter(Boolean);
771
+ const bad = names.filter((n) => !AGENT_TARGETS[n]);
772
+ if (bad.length) {
773
+ console.error("✗ 未知 agent 目标: " + bad.join(", ") + "(可选: " + TARGET_PRIORITY.join(" / ") + " / all)");
774
+ process.exit(1);
775
+ }
776
+ return TARGET_PRIORITY.filter((n) => names.includes(n));
777
+ }
778
+
779
+ async function askTargets() {
780
+ const rl = makeLineReader();
781
+ try {
782
+ console.log("要接入哪些 agent?(各自的技能目录会链接到正本 " + CANONICAL_SKILLS_DIR + "/)");
783
+ TARGET_PRIORITY.forEach((n, i) => {
784
+ const t = AGENT_TARGETS[n];
785
+ console.log(" " + (i + 1) + ". " + n + " (" + t.skillsDir + "/ · " + t.doc.dest + ")");
786
+ });
787
+ while (true) {
788
+ const raw = await rl.question("选择编号,逗号分隔(如 1,2);a=全部 > ");
789
+ if (raw === null) return null;
790
+ const ans = raw.trim().toLowerCase();
791
+ if (ans === "a") return [...TARGET_PRIORITY];
792
+ const idx = parseIndices(ans, TARGET_PRIORITY.length);
793
+ if (idx && idx.length) return idx.map((i) => TARGET_PRIORITY[i]);
794
+ console.log(" 没看懂。例如: 1,2 或 a");
795
+ }
796
+ } finally {
797
+ rl.close();
798
+ }
799
+ }
800
+
801
+ // 把 agent 的技能目录做成指向正本的链接(Windows junction 免管理员 / POSIX 相对 symlink)。
802
+ // 遇到老式实体目录(复制镜像时代)自动迁移:内容并入正本(同名跳过),原地替换为链接。
803
+ // 返回 "ok"(已正确)/ "created" / "fixed"
804
+ function ensureSkillsLink(cwd, targetName) {
805
+ const rel = AGENT_TARGETS[targetName].skillsDir;
806
+ const linkPath = path.join(cwd, ...rel.split("/"));
807
+ const canonical = path.join(cwd, ...CANONICAL_SKILLS_DIR.split("/"));
808
+ fs.mkdirSync(canonical, { recursive: true });
809
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
810
+
811
+ let st = null;
812
+ try { st = fs.lstatSync(linkPath); } catch { st = null; }
813
+
814
+ if (st && st.isSymbolicLink()) {
815
+ try {
816
+ if (fs.realpathSync(linkPath) === fs.realpathSync(canonical)) return "ok";
817
+ } catch { /* 断链 → 重建 */ }
818
+ fs.rmSync(linkPath, { recursive: true, force: true });
819
+ } else if (st && st.isDirectory()) {
820
+ let moved = 0;
821
+ let skipped = 0;
822
+ for (const e of fs.readdirSync(linkPath, { withFileTypes: true })) {
823
+ if (IGNORE_NAMES.has(e.name)) continue;
824
+ const from = path.join(linkPath, e.name);
825
+ const to = path.join(canonical, e.name);
826
+ if (fs.existsSync(to)) {
827
+ skipped++;
828
+ continue;
829
+ }
830
+ fs.cpSync(from, to, { recursive: true });
831
+ moved++;
832
+ }
833
+ fs.rmSync(linkPath, { recursive: true, force: true });
834
+ if (moved || skipped) {
835
+ console.log("↪ " + rel + " 原为实体目录:迁入正本 " + moved + " 项"
836
+ + (skipped ? ",同名跳过 " + skipped + " 项" : "") + ",已替换为链接");
837
+ }
838
+ } else if (st) {
839
+ fs.rmSync(linkPath, { force: true });
840
+ }
841
+
842
+ if (process.platform === "win32") {
843
+ fs.symlinkSync(canonical, linkPath, "junction");
844
+ } else {
845
+ fs.symlinkSync(path.relative(path.dirname(linkPath), canonical), linkPath, "dir");
846
+ }
847
+ return st ? "fixed" : "created";
848
+ }
849
+
850
+ // 植入引擎(init 与 agents add 共用):协议文档去重植入 + multica/JASKILL +
851
+ // common 技能拉进正本 ai/jaSkills + 各目标技能目录链接化 + gitignore
852
+ async function plantForTargets(ctx, manifest, targets) {
853
+ const plan = [];
854
+ const seenDoc = new Set();
855
+ for (const n of targets) {
856
+ const doc = AGENT_TARGETS[n].doc;
857
+ if (seenDoc.has(doc.dest)) continue; // codex/kimi 共用 AGENTS.md,只植一份
858
+ seenDoc.add(doc.dest);
859
+ plan.push({ input: doc.input, dest: doc.dest });
860
+ }
861
+ plan.push({ input: "multica", dest: "multica", optional: true });
862
+ plan.push({ input: "docs/JASKILL.md", dest: "ai/JASKILL.md" });
863
+
864
+ const common = resolveShelfPath(ctx.shelfDir, "skills/common");
865
+ if (common) {
866
+ for (const e of fs.readdirSync(common.abs, { withFileTypes: true })) {
867
+ if (!e.isDirectory() || IGNORE_NAMES.has(e.name)) continue;
868
+ plan.push({
869
+ input: common.realRel + "/" + e.name,
870
+ dest: CANONICAL_SKILLS_DIR + "/" + e.name,
871
+ });
872
+ }
873
+ }
874
+
875
+ const counters = newCounters();
876
+ for (const item of plan) {
877
+ const hit = resolveShelfPath(ctx.shelfDir, item.input);
878
+ if (!hit) {
879
+ console.log(item.optional
880
+ ? "- 货架上暂无 " + item.input + ",跳过"
881
+ : "✗ 货架上找不到 " + item.input + "(检查货架是否最新)");
882
+ continue;
883
+ }
884
+ await pullEntry(ctx, hit.realRel, manifest, counters, safeAsk, path.resolve(process.cwd(), item.dest));
885
+ }
886
+
887
+ let linksMade = 0;
888
+ for (const n of targets) {
889
+ if (ensureSkillsLink(process.cwd(), n) !== "ok") linksMade++;
890
+ }
891
+
892
+ const ignoreLines = [...new Set(targets.flatMap((n) => AGENT_TARGETS[n].ignore))];
893
+ const added = ensureGitignore(process.cwd(), ignoreLines);
894
+ return { counters, linksMade, added };
895
+ }
896
+
897
+ function printPlantResult(targets, r) {
898
+ printSummary(r.counters);
899
+ if (r.linksMade) console.log("⛓ 建立/修复链接 " + r.linksMade + " 个");
900
+ if (r.added.length) console.log("✓ .gitignore 补行: " + r.added.join(", "));
901
+ console.log("");
902
+ console.log("工作区已接入货架(agent 目标: " + targets.join(" + ") + "):");
903
+ const docs = [...new Set(targets.map((n) => AGENT_TARGETS[n].doc.dest))].join(" / ");
904
+ console.log(" " + docs + " 工作协议(真源在货架,shelf sync 保持最新)");
905
+ console.log(" ai/JASKILL.md 基础技能名册");
906
+ console.log(" " + CANONICAL_SKILLS_DIR + "/ 技能唯一正本(改技能、拉技能都在这)");
907
+ console.log(" " + targets.map((n) => AGENT_TARGETS[n].skillsDir).join(" · ") + " → 指向正本的链接");
908
+ console.log(" .shelf.json 记账本(进项目 git,队友 clone 后 shelf init 即还原)");
909
+ }
910
+
911
+ export async function cmdShelfInit(argv = []) {
912
+ let rest = argv;
913
+ let agentsFlag;
914
+ ({ args: rest, value: agentsFlag } = takeFlag(rest, "--agents", true));
736
915
 
737
- export async function cmdShelfInit() {
738
916
  const ctx = resolveShelfContext();
739
917
  try {
918
+ if (path.resolve(process.cwd()) === path.resolve(ctx.root)) {
919
+ console.error("✗ 这里就是货架 home 本体,不能对它 init(防止把忽略规则写进货架仓库)");
920
+ process.exit(1);
921
+ }
740
922
  const manifest = loadManifest();
741
923
  manifest.source ??= remoteUrl(ctx.root) || toPosix(ctx.root);
742
924
 
743
- const hit = resolveShelfPath(ctx.shelfDir, "skills/common/shelf-ops");
744
- if (!hit) {
745
- console.error("✗ 货架上找不到 skills/common/shelf-ops(操作手册 skill),检查 shelf 是否最新");
746
- process.exit(1);
925
+ let targets;
926
+ if (agentsFlag !== undefined) {
927
+ targets = parseTargetNames(agentsFlag);
928
+ } else if (manifest.agents?.length) {
929
+ targets = TARGET_PRIORITY.filter((n) => manifest.agents.includes(n));
930
+ console.log("(沿用已配置的 agent 目标: " + targets.join(", ") + ";调整用 shelf agents add 或 --agents)");
931
+ } else if (INTERACTIVE) {
932
+ targets = await askTargets();
933
+ if (!targets || !targets.length) {
934
+ console.log("已取消。");
935
+ return;
936
+ }
937
+ } else {
938
+ console.error("✗ 非交互环境首次 init 需指定 --agents claude,codex,kimi(或 all)。已中止。");
939
+ process.exit(2);
747
940
  }
748
- const counters = newCounters();
749
- const dest = path.join(process.cwd(), ".claude", "skills", "shelf-ops");
750
- await pullEntry(ctx, hit.realRel, manifest, counters, safeAsk, dest);
941
+
942
+ const result = await plantForTargets(ctx, manifest, targets);
943
+ manifest.agents = targets;
944
+
945
+ // 账本还原(2026-08-30 用户确认):标准植入清单之外、账上有而本地缺失的货架物品一并补拉,
946
+ // 换设备后一条 init 全就位,无需再跑 sync
947
+ let restored = 0;
948
+ for (const [key, rec] of Object.entries(manifest.shelf ?? {})) {
949
+ if (!rec.localPath) continue;
950
+ const destAbs = path.resolve(process.cwd(), rec.localPath);
951
+ if (fs.existsSync(destAbs)) continue;
952
+ const hit = resolveShelfPath(ctx.shelfDir, key);
953
+ if (!hit) {
954
+ console.log("⚠ 账上物品 " + displayPath(key) + " 在货架上已不在原位——跑 shelf sync 处理搬家/下架");
955
+ continue;
956
+ }
957
+ await pullEntry(ctx, hit.realRel, manifest, result.counters, safeAsk, destAbs);
958
+ restored++;
959
+ }
960
+
961
+ await reconcileLocalSkills(manifest);
751
962
  saveManifest(manifest);
963
+ printPlantResult(targets, result);
964
+ if (restored) console.log("↺ 按账本补还原 " + restored + " 项(标准清单之外)");
965
+ } finally {
966
+ ctx.cleanup();
967
+ }
968
+ }
752
969
 
753
- console.log("");
754
- console.log("工作区已就绪:");
755
- console.log(" .shelf.json 版本追踪 manifest");
756
- console.log(" .claude/skills/shelf-ops 货架操作手册(agent 据此执行 pull/push)");
970
+ // ---- 子命令:agents(查看/添加 agent 目标,SHELF 决策 #24)----
971
+
972
+ export async function cmdShelfAgents(argv) {
973
+ const manifest = loadManifest();
974
+ const current = TARGET_PRIORITY.filter((n) => (manifest.agents ?? []).includes(n));
975
+ const sub = argv[0];
976
+
977
+ if (sub === undefined) {
978
+ console.log("技能正本: " + CANONICAL_SKILLS_DIR + "/(改技能、拉技能都在这)");
979
+ console.log("已配置: " + (current.length ? current.join(", ") : "(无——先跑 shelf init)"));
980
+ for (const n of TARGET_PRIORITY) {
981
+ const t = AGENT_TARGETS[n];
982
+ console.log(" " + (current.includes(n) ? "●" : "○") + " " + n + " " + t.skillsDir + "/ → 链接 · " + t.doc.dest);
983
+ }
984
+ console.log("添加: shelf agents add <名>");
985
+ return;
986
+ }
987
+ if (sub !== "add" || argv.length < 2) {
988
+ console.error("用法: shelf agents 查看已配置/可用目标");
989
+ console.error(" shelf agents add <名> 添加目标(claude / codex / kimi / all)");
990
+ process.exit(1);
991
+ }
992
+
993
+ const adding = parseTargetNames(argv.slice(1).join(","));
994
+ const targets = TARGET_PRIORITY.filter((n) => current.includes(n) || adding.includes(n));
995
+ if (targets.join() === current.join()) {
996
+ console.log("= 目标已包含,无需变更(" + current.join(", ") + ")");
997
+ return;
998
+ }
999
+
1000
+ const ctx = resolveShelfContext();
1001
+ try {
1002
+ const result = await plantForTargets(ctx, manifest, targets);
1003
+ manifest.agents = targets;
1004
+ saveManifest(manifest);
1005
+ printPlantResult(targets, result);
1006
+ console.log("✓ agent 目标: " + (current.join(", ") || "(无)") + " → " + targets.join(", "));
757
1007
  } finally {
758
1008
  ctx.cleanup();
759
1009
  }
760
1010
  }
761
1011
 
1012
+ // ---- 本地技能台账(SHELF 决策 #27 · 增量对账)----
1013
+
1014
+ // local 段维护:在场→刷指纹;升格→移除;缺失→告警但保留记录(仅 adopt 交互提供清账);
1015
+ // 新出现的未追踪目录→登记。init / sync / adopt 共用。
1016
+ async function reconcileLocalSkills(manifest, { allowPrune = false } = {}) {
1017
+ const canonical = path.join(process.cwd(), ...CANONICAL_SKILLS_DIR.split("/"));
1018
+ const tracked = new Set(
1019
+ Object.values(manifest.shelf ?? {}).map((e) => toPosix(e.localPath ?? "")),
1020
+ );
1021
+ const prev = manifest.local ?? {};
1022
+ const next = {};
1023
+ const c = { registered: 0, refreshed: 0, missing: 0, graduated: 0 };
1024
+
1025
+ const dirs = fs.existsSync(canonical)
1026
+ ? fs.readdirSync(canonical, { withFileTypes: true })
1027
+ .filter((e) => e.isDirectory() && !IGNORE_NAMES.has(e.name))
1028
+ .map((e) => e.name)
1029
+ : [];
1030
+ const present = new Set(dirs);
1031
+
1032
+ for (const [name, entry] of Object.entries(prev)) {
1033
+ if (tracked.has(CANONICAL_SKILLS_DIR + "/" + name)) {
1034
+ c.graduated++;
1035
+ continue;
1036
+ }
1037
+ if (present.has(name)) {
1038
+ const hash = contentHash(path.join(canonical, name));
1039
+ if (hash !== entry.contentHash) c.refreshed++;
1040
+ next[name] = { ...entry, contentHash: hash };
1041
+ continue;
1042
+ }
1043
+ c.missing++;
1044
+ console.log("⚠ 登记过的本地技能缺失: " + name + "(登记于 " + (entry.addedAt ?? "?")
1045
+ + ",上次指纹 " + String(entry.contentHash ?? "").slice(7, 19) + "…)——重装后跑 shelf sync 即恢复入账");
1046
+ if (allowPrune && INTERACTIVE) {
1047
+ const act = await choose(" [k]保留记录 / [r]清账? ", [{ key: "k" }, { key: "r" }]);
1048
+ if (act === "r") continue;
1049
+ }
1050
+ next[name] = entry;
1051
+ }
1052
+
1053
+ for (const name of dirs) {
1054
+ if (next[name] || tracked.has(CANONICAL_SKILLS_DIR + "/" + name)) continue;
1055
+ next[name] = {
1056
+ contentHash: contentHash(path.join(canonical, name)),
1057
+ addedAt: todayISO(),
1058
+ origin: "adopted",
1059
+ };
1060
+ console.log("✚ 登记本地技能: " + name);
1061
+ c.registered++;
1062
+ }
1063
+
1064
+ manifest.local = next;
1065
+ return c;
1066
+ }
1067
+
1068
+ // ---- 子命令:adopt(收编外来技能,SHELF 决策 #27)----
1069
+
1070
+ export async function cmdShelfAdopt() {
1071
+ const manifest = loadManifest();
1072
+
1073
+ // agent 技能目录若被外部安装器换成了实体目录:内容迁入正本,恢复链接
1074
+ let relinked = 0;
1075
+ for (const n of manifest.agents ?? []) {
1076
+ if (!AGENT_TARGETS[n]) continue;
1077
+ if (ensureSkillsLink(process.cwd(), n) !== "ok") relinked++;
1078
+ }
1079
+
1080
+ const c = await reconcileLocalSkills(manifest, { allowPrune: true });
1081
+ saveManifest(manifest);
1082
+
1083
+ console.log("");
1084
+ console.log(
1085
+ "Adopt: " + c.registered + " 新登记, " + c.refreshed + " 指纹更新, " + c.missing + " 缺失保留, "
1086
+ + c.graduated + " 已升格/移除, " + relinked + " 目录收编重链;"
1087
+ + "local 段共 " + Object.keys(manifest.local).length + " 个本地技能",
1088
+ );
1089
+ if (Object.keys(manifest.local).length) {
1090
+ console.log("(本地技能不参与货架对账;想跨项目复用: shelf create " + CANONICAL_SKILLS_DIR + "/<名> --to skills/<包>)");
1091
+ }
1092
+ }
1093
+
762
1094
  // ---- 子命令:home(查看/更新档口)----
763
1095
 
764
1096
  export async function cmdShelfHome(argv) {
@@ -789,165 +1121,180 @@ export async function cmdShelfHome(argv) {
789
1121
  ctx.cleanup();
790
1122
  }
791
1123
  }
792
-
793
- // ---- 子命令:sync(按账本与货架对账,SHELF 决策 #19)----
794
-
795
- // 逐行内容 diff 直通终端(git 自带着色),方向:库上 → 本地
796
- function showLineDiff(shelfAbs, localAbs, shown) {
797
- console.log(`—— ${shown} diff(库上 → 本地)——`);
798
- spawnSync("git", ["diff", "--no-index", "--color=always", "--", shelfAbs, localAbs], { stdio: "inherit" });
799
- }
800
-
801
- export async function cmdShelfSync(argv) {
802
- let rest = argv;
803
- let dryRun;
804
- ({ args: rest, value: dryRun } = takeFlag(rest, "--dry-run"));
805
-
806
- const ctx = resolveShelfContext({ forWrite: true }); // 对账必须对着真最新:等同写操作,强制刷新
807
- let keepEphemeral = false;
808
- try {
809
- const manifest = loadManifest();
810
- const entries = Object.entries(manifest.shelf ?? {});
811
- if (entries.length === 0) {
812
- console.log("账本为空(.shelf.json 没有 shelf 段记录)——先 shelf pull / create。");
813
- return;
814
- }
815
-
816
- const c = { same: 0, updated: 0, pushed: 0, skipped: 0, cleaned: 0 };
817
- const pending = [];
818
-
819
- for (const [key, rec] of entries) {
820
- let effKey = key;
821
- let target = path.join(ctx.shelfDir, ...effKey.split("/"));
822
- let shown = displayPath(effKey);
823
- const localAbs = path.resolve(process.cwd(), rec.localPath ?? "");
824
- const localExists = !!rec.localPath && fs.existsSync(localAbs);
825
-
826
- // 情形 5:库上路径没了 → 按名字找回(搬家)或报告下架
827
- if (!fs.existsSync(target)) {
828
- const name = path.basename(effKey);
829
- const hits = findByBasename(ctx.shelfDir, name);
830
- if (hits.length === 1) {
831
- if (dryRun) {
832
- console.log(`↪ ${shown} 已被移动到 ${displayPath(hits[0])}(--dry-run,暂不改账)`);
833
- } else {
834
- delete manifest.shelf[effKey];
835
- manifest.shelf[hits[0]] = rec;
836
- console.log(`↪ ${shown} 已被移动到 ${displayPath(hits[0])},记账已更新`);
837
- }
838
- effKey = hits[0];
839
- target = path.join(ctx.shelfDir, ...effKey.split("/"));
840
- shown = displayPath(effKey);
841
- } else {
842
- const label = hits.length === 0 ? "已从货架移除" : `同名多义(${hits.length} 处)`;
843
- if (!INTERACTIVE || dryRun) {
844
- console.log(`⚠ 待决 ${shown}:${label}`);
845
- pending.push(`${shown}(${label})`);
846
- continue;
847
- }
848
- const act = await choose(`⚠ ${shown} ${label}。[r]清记账 / [s]跳过? `, [{ key: "r" }, { key: "s" }]);
849
- if (act === "r") {
850
- delete manifest.shelf[effKey];
851
- console.log(`✓ 已清记账 ${shown}(本地文件未动;想重新上架用 shelf create)`);
852
- c.cleaned++;
853
- } else c.skipped++;
854
- continue;
855
- }
856
- }
857
-
858
- // 情形 6:本地文件没了 → 孤儿记账
859
- if (!localExists) {
860
- if (!INTERACTIVE || dryRun) {
861
- console.log(`⚠ 待决 ${shown}:本地文件不存在(${rec.localPath ?? "无 localPath"})`);
862
- pending.push(`${shown}(本地文件不存在)`);
863
- continue;
864
- }
865
- const act = await choose(
866
- `⚠ ${shown} 的本地文件不存在(${rec.localPath ?? "无 localPath"})。[p]重新拉取 / [r]清记账 / [s]跳过? `,
867
- [{ key: "p" }, { key: "r" }, { key: "s" }],
868
- );
869
- if (act === "p") {
870
- fs.mkdirSync(path.dirname(localAbs), { recursive: true });
871
- copyFiltered(target, localAbs);
872
- manifest.shelf[effKey] = { ...rec, contentHash: contentHash(target), sourceCommit: headCommit(ctx.root), pulledAt: todayISO() };
873
- console.log(`↓ 已重新拉取 ${shown}`);
874
- c.updated++;
875
- } else if (act === "r") {
876
- delete manifest.shelf[effKey];
877
- console.log(`✓ 已清记账 ${shown}`);
878
- c.cleaned++;
879
- } else c.skipped++;
880
- continue;
881
- }
882
-
883
- const S = contentHash(target);
884
- const L = contentHash(localAbs);
885
- const R = rec.contentHash;
886
-
887
- if (S === R && L === R) { c.same++; continue; }
888
-
889
- // 情形 2:库上有新版、本地没动 → 自动覆盖本地(零损失)
890
- if (S !== R && L === R) {
891
- if (dryRun) {
892
- console.log(`↓ 将更新本地:${shown}`);
893
- c.updated++;
894
- continue;
895
- }
896
- fs.rmSync(localAbs, { recursive: true, force: true });
897
- copyFiltered(target, localAbs);
898
- manifest.shelf[effKey] = { ...rec, contentHash: S, sourceCommit: headCommit(ctx.root), pulledAt: todayISO() };
899
- console.log(`↓ 已更新本地:${shown}`);
900
- c.updated++;
901
- continue;
902
- }
903
-
904
- // 情形 3/4:本地有改动(本地领先,或双方都改)
905
- const both = S !== R;
906
- const tag = both ? "双方都改过" : "本地领先";
907
- if (!INTERACTIVE || dryRun) {
908
- const d = diffSummary(target, localAbs);
909
- console.log(`⚠ 待决 ${shown}(${tag}):本地相对库上 新增 ${d.added.length} / 删除 ${d.removed.length} / 修改 ${d.changed.length}`);
910
- pending.push(`${shown}(${tag})`);
911
- continue;
912
- }
913
-
914
- showLineDiff(target, localAbs, shown);
915
- const act = await choose(`${shown}(${tag})。[p]本地推上库 / [o]库覆盖本地 / [s]跳过? `, [{ key: "p" }, { key: "o" }, { key: "s" }]);
916
- if (act === "s") { c.skipped++; continue; }
917
- if (act === "o") {
918
- fs.rmSync(localAbs, { recursive: true, force: true });
919
- copyFiltered(target, localAbs);
920
- manifest.shelf[effKey] = { ...rec, contentHash: S, sourceCommit: headCommit(ctx.root), pulledAt: todayISO() };
921
- console.log(`↓ 已用库上版本覆盖本地:${shown}`);
922
- c.updated++;
923
- continue;
924
- }
925
- // p:本地推上库
926
- if (both) {
927
- const confirm = await choose(`库上的改动将被你的本地版本覆盖,确认? [y]es / [n]o? `, [{ key: "y" }, { key: "n" }]);
928
- if (confirm === "n") { c.skipped++; continue; }
929
- }
930
- const { secrets } = guardScan(localAbs);
931
- if (secrets.length > 0) {
932
- console.log(`✗ ${shown} 含疑似凭据文件(${secrets.map((x) => x.rel).join(", ")}),sync 不代推,已跳过——确要推请单独 shelf push --force-secret`);
933
- c.skipped++;
934
- continue;
935
- }
936
- keepEphemeral = applyAndCommit(ctx, effKey, localAbs, manifest, "update") || keepEphemeral;
937
- c.pushed++;
938
- }
939
-
940
- if (!dryRun) saveManifest(manifest);
941
- console.log("");
942
- console.log(
943
- `Sync${dryRun ? "(dry-run)" : ""}: ${c.same} 一致, ${c.updated} 更新本地, ${c.pushed} 推上库, ` +
944
- `${c.cleaned} 清账, ${c.skipped} 跳过, ${pending.length} 待决`,
945
- );
946
- if (pending.length) {
947
- for (const x of pending) console.log(` · ${x}`);
948
- if (!INTERACTIVE && !dryRun) process.exit(2);
949
- }
950
- } finally {
951
- if (!keepEphemeral) ctx.cleanup();
952
- }
953
- }
1124
+
1125
+ // ---- 子命令:sync(按账本与货架对账,SHELF 决策 #19)----
1126
+
1127
+ // 逐行内容 diff 直通终端(git 自带着色),方向:库上 → 本地
1128
+ function showLineDiff(shelfAbs, localAbs, shown) {
1129
+ console.log(`—— ${shown} diff(库上 → 本地)——`);
1130
+ spawnSync("git", ["diff", "--no-index", "--color=always", "--", shelfAbs, localAbs], { stdio: "inherit" });
1131
+ }
1132
+
1133
+ export async function cmdShelfSync(argv) {
1134
+ let rest = argv;
1135
+ let dryRun;
1136
+ ({ args: rest, value: dryRun } = takeFlag(rest, "--dry-run"));
1137
+
1138
+ const ctx = resolveShelfContext({ forWrite: true }); // 对账必须对着真最新:等同写操作,强制刷新
1139
+ let keepEphemeral = false;
1140
+ try {
1141
+ const manifest = loadManifest();
1142
+ const entries = Object.entries(manifest.shelf ?? {});
1143
+ if (entries.length === 0) {
1144
+ console.log("账本为空(.shelf.json 没有 shelf 段记录)——先 shelf pull / create。");
1145
+ return;
1146
+ }
1147
+
1148
+ const c = { same: 0, updated: 0, pushed: 0, skipped: 0, cleaned: 0 };
1149
+ const pending = [];
1150
+
1151
+ for (const [key, rec] of entries) {
1152
+ let effKey = key;
1153
+ let target = path.join(ctx.shelfDir, ...effKey.split("/"));
1154
+ let shown = displayPath(effKey);
1155
+ const localAbs = path.resolve(process.cwd(), rec.localPath ?? "");
1156
+ const localExists = !!rec.localPath && fs.existsSync(localAbs);
1157
+
1158
+ // 情形 5:库上路径没了 → 按名字找回(搬家)或报告下架
1159
+ if (!fs.existsSync(target)) {
1160
+ const name = path.basename(effKey);
1161
+ const hits = findByBasename(ctx.shelfDir, name);
1162
+ if (hits.length === 1) {
1163
+ if (dryRun) {
1164
+ console.log(`↪ ${shown} 已被移动到 ${displayPath(hits[0])}(--dry-run,暂不改账)`);
1165
+ } else {
1166
+ delete manifest.shelf[effKey];
1167
+ manifest.shelf[hits[0]] = rec;
1168
+ console.log(`↪ ${shown} 已被移动到 ${displayPath(hits[0])},记账已更新`);
1169
+ }
1170
+ effKey = hits[0];
1171
+ target = path.join(ctx.shelfDir, ...effKey.split("/"));
1172
+ shown = displayPath(effKey);
1173
+ } else {
1174
+ const label = hits.length === 0 ? "已从货架移除" : `同名多义(${hits.length} 处)`;
1175
+ if (!INTERACTIVE || dryRun) {
1176
+ console.log(`⚠ 待决 ${shown}:${label}`);
1177
+ pending.push(`${shown}(${label})`);
1178
+ continue;
1179
+ }
1180
+ const act = await choose(`⚠ ${shown} ${label}。[r]清记账 / [s]跳过? `, [{ key: "r" }, { key: "s" }]);
1181
+ if (act === "r") {
1182
+ delete manifest.shelf[effKey];
1183
+ console.log(`✓ 已清记账 ${shown}(本地文件未动;想重新上架用 shelf create)`);
1184
+ c.cleaned++;
1185
+ } else c.skipped++;
1186
+ continue;
1187
+ }
1188
+ }
1189
+
1190
+ // 情形 6:本地文件没了 → 孤儿记账
1191
+ if (!localExists) {
1192
+ if (!INTERACTIVE || dryRun) {
1193
+ console.log(`⚠ 待决 ${shown}:本地文件不存在(${rec.localPath ?? "无 localPath"})`);
1194
+ pending.push(`${shown}(本地文件不存在)`);
1195
+ continue;
1196
+ }
1197
+ const act = await choose(
1198
+ `⚠ ${shown} 的本地文件不存在(${rec.localPath ?? "无 localPath"})。[p]重新拉取 / [r]清记账 / [s]跳过? `,
1199
+ [{ key: "p" }, { key: "r" }, { key: "s" }],
1200
+ );
1201
+ if (act === "p") {
1202
+ fs.mkdirSync(path.dirname(localAbs), { recursive: true });
1203
+ copyFiltered(target, localAbs);
1204
+ manifest.shelf[effKey] = { ...rec, contentHash: contentHash(target), sourceCommit: headCommit(ctx.root), pulledAt: todayISO() };
1205
+ console.log(`↓ 已重新拉取 ${shown}`);
1206
+ c.updated++;
1207
+ } else if (act === "r") {
1208
+ delete manifest.shelf[effKey];
1209
+ console.log(`✓ 已清记账 ${shown}`);
1210
+ c.cleaned++;
1211
+ } else c.skipped++;
1212
+ continue;
1213
+ }
1214
+
1215
+ const S = contentHash(target);
1216
+ const L = contentHash(localAbs);
1217
+ const R = rec.contentHash;
1218
+
1219
+ if (S === R && L === R) { c.same++; continue; }
1220
+
1221
+ // 情形 2:库上有新版、本地没动 → 自动覆盖本地(零损失)
1222
+ if (S !== R && L === R) {
1223
+ if (dryRun) {
1224
+ console.log(`↓ 将更新本地:${shown}`);
1225
+ c.updated++;
1226
+ continue;
1227
+ }
1228
+ fs.rmSync(localAbs, { recursive: true, force: true });
1229
+ copyFiltered(target, localAbs);
1230
+ manifest.shelf[effKey] = { ...rec, contentHash: S, sourceCommit: headCommit(ctx.root), pulledAt: todayISO() };
1231
+ console.log(`↓ 已更新本地:${shown}`);
1232
+ c.updated++;
1233
+ continue;
1234
+ }
1235
+
1236
+ // 情形 3/4:本地有改动(本地领先,或双方都改)
1237
+ const both = S !== R;
1238
+ const tag = both ? "双方都改过" : "本地领先";
1239
+ if (!INTERACTIVE || dryRun) {
1240
+ const d = diffSummary(target, localAbs);
1241
+ console.log(`⚠ 待决 ${shown}(${tag}):本地相对库上 新增 ${d.added.length} / 删除 ${d.removed.length} / 修改 ${d.changed.length}`);
1242
+ pending.push(`${shown}(${tag})`);
1243
+ continue;
1244
+ }
1245
+
1246
+ showLineDiff(target, localAbs, shown);
1247
+ const act = await choose(`${shown}(${tag})。[p]本地推上库 / [o]库覆盖本地 / [s]跳过? `, [{ key: "p" }, { key: "o" }, { key: "s" }]);
1248
+ if (act === "s") { c.skipped++; continue; }
1249
+ if (act === "o") {
1250
+ fs.rmSync(localAbs, { recursive: true, force: true });
1251
+ copyFiltered(target, localAbs);
1252
+ manifest.shelf[effKey] = { ...rec, contentHash: S, sourceCommit: headCommit(ctx.root), pulledAt: todayISO() };
1253
+ console.log(`↓ 已用库上版本覆盖本地:${shown}`);
1254
+ c.updated++;
1255
+ continue;
1256
+ }
1257
+ // p:本地推上库
1258
+ if (both) {
1259
+ const confirm = await choose(`库上的改动将被你的本地版本覆盖,确认? [y]es / [n]o? `, [{ key: "y" }, { key: "n" }]);
1260
+ if (confirm === "n") { c.skipped++; continue; }
1261
+ }
1262
+ const { secrets } = guardScan(localAbs);
1263
+ if (secrets.length > 0) {
1264
+ console.log(`✗ ${shown} 含疑似凭据文件(${secrets.map((x) => x.rel).join(", ")}),sync 不代推,已跳过——确要推请单独 shelf push --force-secret`);
1265
+ c.skipped++;
1266
+ continue;
1267
+ }
1268
+ keepEphemeral = applyAndCommit(ctx, effKey, localAbs, manifest, "update") || keepEphemeral;
1269
+ c.pushed++;
1270
+ }
1271
+
1272
+ // 链接完整性(决策 #26):已配置目标的技能目录必须是指向 ai/jaSkills 的链接
1273
+ let linksFixed = 0;
1274
+ if (!dryRun && manifest.agents?.length) {
1275
+ for (const n of manifest.agents) {
1276
+ if (!AGENT_TARGETS[n]) continue;
1277
+ if (ensureSkillsLink(process.cwd(), n) !== "ok") linksFixed++;
1278
+ }
1279
+ }
1280
+
1281
+ let localStat = null;
1282
+ if (!dryRun) localStat = await reconcileLocalSkills(manifest);
1283
+
1284
+ if (!dryRun) saveManifest(manifest);
1285
+ console.log("");
1286
+ console.log(
1287
+ `Sync${dryRun ? "(dry-run)" : ""}: ${c.same} 一致, ${c.updated} 更新本地, ${c.pushed} 推上库, ` +
1288
+ `${c.cleaned} 清账, ${c.skipped} 跳过, ${linksFixed} 链接修复, ${pending.length} 待决` +
1289
+ (localStat && (localStat.registered || localStat.refreshed || localStat.missing)
1290
+ ? `;本地技能: ${localStat.registered} 新登记 / ${localStat.refreshed} 指纹更新 / ${localStat.missing} 缺失`
1291
+ : ""),
1292
+ );
1293
+ if (pending.length) {
1294
+ for (const x of pending) console.log(` · ${x}`);
1295
+ if (!INTERACTIVE && !dryRun) process.exit(2);
1296
+ }
1297
+ } finally {
1298
+ if (!keepEphemeral) ctx.cleanup();
1299
+ }
1300
+ }
package/lib/manifest.mjs CHANGED
@@ -28,11 +28,13 @@ export function loadManifest(cwd = process.cwd()) {
28
28
  skillsDir: ".claude/skills",
29
29
  skills: {},
30
30
  shelf: {},
31
+ local: {},
31
32
  };
32
33
  }
33
34
  const data = JSON.parse(fs.readFileSync(file, "utf8"));
34
35
  data.skills ??= {};
35
36
  data.shelf ??= {};
37
+ data.local ??= {};
36
38
  return data;
37
39
  }
38
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackss119/shelf",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Personal content shelf CLI: browse, pull and push skills, templates, docs and snippets across every machine you work on.",
5
5
  "type": "module",
6
6
  "bin": {