@jackss119/shelf 1.1.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
@@ -7,6 +7,7 @@ import {
7
7
  cmdShelfSync,
8
8
  cmdShelfInit,
9
9
  cmdShelfAgents,
10
+ cmdShelfAdopt,
10
11
  cmdShelfHome,
11
12
  } from "../lib/commands/shelf.mjs";
12
13
 
@@ -26,6 +27,8 @@ Usage:
26
27
  shelf init 项目接入(幂等):交互多选 agent 目标,或
27
28
  --agents claude,codex,kimi / all
28
29
  shelf agents [add <名>] 查看 / 追加 agent 目标(claude / codex / kimi)
30
+ shelf adopt 收编外来技能:实体目录迁入正本重链 + 未记账技能
31
+ 登记进账本 local 段(不上货架)
29
32
  shelf home [--update] 查看货架在哪、什么模式;--update 拉取最新
30
33
 
31
34
  货架定位:SHELF_HOME 环境变量 > 本 clone > ~/.shelfrc > 托管档口 ~/.shelf/home
@@ -60,6 +63,9 @@ try {
60
63
  case "agents":
61
64
  await cmdShelfAgents(rest);
62
65
  break;
66
+ case "adopt":
67
+ await cmdShelfAdopt(rest);
68
+ break;
63
69
  case "home":
64
70
  await cmdShelfHome(rest);
65
71
  break;
@@ -751,19 +751,10 @@ function ensureGitignore(cwd, lines) {
751
751
  return missing;
752
752
  }
753
753
 
754
- // 镜像跟随正本:缺失或内容不一致就整体重刷;返回是否动了
755
- function refreshMirror(primaryAbs, mirrorAbs) {
756
- if (!fs.existsSync(primaryAbs)) return false;
757
- if (fs.existsSync(mirrorAbs) && contentHash(mirrorAbs) === contentHash(primaryAbs)) return false;
758
- fs.rmSync(mirrorAbs, { recursive: true, force: true });
759
- fs.mkdirSync(path.dirname(mirrorAbs), { recursive: true });
760
- copyFiltered(primaryAbs, mirrorAbs);
761
- return true;
762
- }
754
+ // ---- agent 目标与技能正本(SHELF 决策 #24/#26)----
763
755
 
764
- // ---- 子命令:init ----
765
-
766
- // ---- agent 目标(SHELF 决策 #24)----
756
+ // 项目里技能的唯一正本;所有 agent 目录都是指向它的链接
757
+ const CANONICAL_SKILLS_DIR = "ai/jaSkills";
767
758
 
768
759
  const AGENT_TARGETS = {
769
760
  claude: { skillsDir: ".claude/skills", doc: { input: "agents/claude/CLAUDE.md", dest: "CLAUDE.md" }, ignore: [".claude/", "CLAUDE.md"] },
@@ -772,7 +763,7 @@ const AGENT_TARGETS = {
772
763
  };
773
764
  const TARGET_PRIORITY = ["claude", "codex", "kimi"];
774
765
 
775
- // "claude,codex" / "all" → 校验并按优先级排序去重
766
+ // "claude,codex" / "all" → 校验并按固定顺序去重
776
767
  function parseTargetNames(spec) {
777
768
  const names = spec === "all"
778
769
  ? [...TARGET_PRIORITY]
@@ -788,7 +779,7 @@ function parseTargetNames(spec) {
788
779
  async function askTargets() {
789
780
  const rl = makeLineReader();
790
781
  try {
791
- console.log("要接入哪些 agent?(决定植入哪些技能目录与协议文档)");
782
+ console.log("要接入哪些 agent?(各自的技能目录会链接到正本 " + CANONICAL_SKILLS_DIR + "/)");
792
783
  TARGET_PRIORITY.forEach((n, i) => {
793
784
  const t = AGENT_TARGETS[n];
794
785
  console.log(" " + (i + 1) + ". " + n + " (" + t.skillsDir + "/ · " + t.doc.dest + ")");
@@ -807,12 +798,58 @@ async function askTargets() {
807
798
  }
808
799
  }
809
800
 
810
- // 植入引擎(init 与 agents add 共用):协议文档去重植入 + multica/JASKILL + common 技能
811
- // 正本 = 所选目标中优先级最高者,其余目标目录挂 mirrors;gitignore 行随目标生成
812
- async function plantForTargets(ctx, manifest, targets) {
813
- const primaryDir = AGENT_TARGETS[targets[0]].skillsDir;
814
- const mirrorDirs = targets.slice(1).map((n) => AGENT_TARGETS[n].skillsDir);
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
+ }
815
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) {
816
853
  const plan = [];
817
854
  const seenDoc = new Set();
818
855
  for (const n of targets) {
@@ -830,14 +867,12 @@ async function plantForTargets(ctx, manifest, targets) {
830
867
  if (!e.isDirectory() || IGNORE_NAMES.has(e.name)) continue;
831
868
  plan.push({
832
869
  input: common.realRel + "/" + e.name,
833
- dest: primaryDir + "/" + e.name,
834
- mirrors: mirrorDirs.map((d) => d + "/" + e.name),
870
+ dest: CANONICAL_SKILLS_DIR + "/" + e.name,
835
871
  });
836
872
  }
837
873
  }
838
874
 
839
875
  const counters = newCounters();
840
- let mirrored = 0;
841
876
  for (const item of plan) {
842
877
  const hit = resolveShelfPath(ctx.shelfDir, item.input);
843
878
  if (!hit) {
@@ -846,35 +881,30 @@ async function plantForTargets(ctx, manifest, targets) {
846
881
  : "✗ 货架上找不到 " + item.input + "(检查货架是否最新)");
847
882
  continue;
848
883
  }
849
- const destAbs = path.resolve(process.cwd(), item.dest);
850
- await pullEntry(ctx, hit.realRel, manifest, counters, safeAsk, destAbs);
851
- if (item.mirrors) {
852
- const entry = manifest.shelf[hit.realRel];
853
- if (entry) entry.mirrors = item.mirrors.map(toPosix);
854
- for (const m of item.mirrors) {
855
- if (refreshMirror(destAbs, path.resolve(process.cwd(), m))) mirrored++;
856
- }
857
- }
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++;
858
890
  }
859
891
 
860
892
  const ignoreLines = [...new Set(targets.flatMap((n) => AGENT_TARGETS[n].ignore))];
861
893
  const added = ensureGitignore(process.cwd(), ignoreLines);
862
- return { counters, mirrored, added };
894
+ return { counters, linksMade, added };
863
895
  }
864
896
 
865
897
  function printPlantResult(targets, r) {
866
898
  printSummary(r.counters);
867
- if (r.mirrored) console.log("≡ 镜像刷新 " + r.mirrored + " 项");
899
+ if (r.linksMade) console.log("⛓ 建立/修复链接 " + r.linksMade + " 个");
868
900
  if (r.added.length) console.log("✓ .gitignore 补行: " + r.added.join(", "));
869
901
  console.log("");
870
902
  console.log("工作区已接入货架(agent 目标: " + targets.join(" + ") + "):");
871
903
  const docs = [...new Set(targets.map((n) => AGENT_TARGETS[n].doc.dest))].join(" / ");
872
904
  console.log(" " + docs + " 工作协议(真源在货架,shelf sync 保持最新)");
873
905
  console.log(" ai/JASKILL.md 基础技能名册");
874
- const mirrorText = targets.length > 1
875
- ? ";镜像: " + targets.slice(1).map((n) => AGENT_TARGETS[n].skillsDir + "/").join(" ")
876
- : "";
877
- console.log(" " + AGENT_TARGETS[targets[0]].skillsDir + "/ common 技能正本" + mirrorText);
906
+ console.log(" " + CANONICAL_SKILLS_DIR + "/ 技能唯一正本(改技能、拉技能都在这)");
907
+ console.log(" " + targets.map((n) => AGENT_TARGETS[n].skillsDir).join(" · ") + " → 指向正本的链接");
878
908
  console.log(" .shelf.json 记账本(进项目 git,队友 clone 后 shelf init 即还原)");
879
909
  }
880
910
 
@@ -897,7 +927,7 @@ export async function cmdShelfInit(argv = []) {
897
927
  targets = parseTargetNames(agentsFlag);
898
928
  } else if (manifest.agents?.length) {
899
929
  targets = TARGET_PRIORITY.filter((n) => manifest.agents.includes(n));
900
- console.log("(沿用已配置的 agent 目标: " + targets.join(", ") + ";调整用 shelf agents add 或 --agents)");
930
+ console.log("(沿用已配置的 agent 目标: " + targets.join(", ") + ";调整用 shelf agents add 或 --agents)");
901
931
  } else if (INTERACTIVE) {
902
932
  targets = await askTargets();
903
933
  if (!targets || !targets.length) {
@@ -911,8 +941,27 @@ export async function cmdShelfInit(argv = []) {
911
941
 
912
942
  const result = await plantForTargets(ctx, manifest, targets);
913
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);
914
962
  saveManifest(manifest);
915
963
  printPlantResult(targets, result);
964
+ if (restored) console.log("↺ 按账本补还原 " + restored + " 项(标准清单之外)");
916
965
  } finally {
917
966
  ctx.cleanup();
918
967
  }
@@ -926,10 +975,11 @@ export async function cmdShelfAgents(argv) {
926
975
  const sub = argv[0];
927
976
 
928
977
  if (sub === undefined) {
929
- console.log("已配置: " + (current.length ? current.join(", ") : "(无——先跑 shelf init)"));
978
+ console.log("技能正本: " + CANONICAL_SKILLS_DIR + "/(改技能、拉技能都在这)");
979
+ console.log("已配置: " + (current.length ? current.join(", ") : "(无——先跑 shelf init)"));
930
980
  for (const n of TARGET_PRIORITY) {
931
981
  const t = AGENT_TARGETS[n];
932
- console.log(" " + (current.includes(n) ? "●" : "○") + " " + n + " " + t.skillsDir + "/ · " + t.doc.dest);
982
+ console.log(" " + (current.includes(n) ? "●" : "○") + " " + n + " " + t.skillsDir + "/ → 链接 · " + t.doc.dest);
933
983
  }
934
984
  console.log("添加: shelf agents add <名>");
935
985
  return;
@@ -959,6 +1009,88 @@ export async function cmdShelfAgents(argv) {
959
1009
  }
960
1010
  }
961
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
+
962
1094
  // ---- 子命令:home(查看/更新档口)----
963
1095
 
964
1096
  export async function cmdShelfHome(argv) {
@@ -1137,23 +1269,26 @@ export async function cmdShelfSync(argv) {
1137
1269
  c.pushed++;
1138
1270
  }
1139
1271
 
1140
- // 镜像校对(决策 #22):正本处理完后统一核对,哈希不符即从正本重刷
1141
- let mirrorFixed = 0;
1142
- if (!dryRun) {
1143
- for (const [, rec] of Object.entries(manifest.shelf ?? {})) {
1144
- if (!rec.mirrors?.length || !rec.localPath) continue;
1145
- const primary = path.resolve(process.cwd(), rec.localPath);
1146
- for (const m of rec.mirrors) {
1147
- if (refreshMirror(primary, path.resolve(process.cwd(), m))) mirrorFixed++;
1148
- }
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++;
1149
1278
  }
1150
1279
  }
1151
1280
 
1281
+ let localStat = null;
1282
+ if (!dryRun) localStat = await reconcileLocalSkills(manifest);
1283
+
1152
1284
  if (!dryRun) saveManifest(manifest);
1153
1285
  console.log("");
1154
1286
  console.log(
1155
1287
  `Sync${dryRun ? "(dry-run)" : ""}: ${c.same} 一致, ${c.updated} 更新本地, ${c.pushed} 推上库, ` +
1156
- `${c.cleaned} 清账, ${c.skipped} 跳过, ${mirrorFixed} 镜像刷新, ${pending.length} 待决`,
1288
+ `${c.cleaned} 清账, ${c.skipped} 跳过, ${linksFixed} 链接修复, ${pending.length} 待决` +
1289
+ (localStat && (localStat.registered || localStat.refreshed || localStat.missing)
1290
+ ? `;本地技能: ${localStat.registered} 新登记 / ${localStat.refreshed} 指纹更新 / ${localStat.missing} 缺失`
1291
+ : ""),
1157
1292
  );
1158
1293
  if (pending.length) {
1159
1294
  for (const x of pending) console.log(` · ${x}`);
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.1.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": {