@jackss119/shelf 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.
package/bin/shelf.mjs CHANGED
@@ -1,78 +1,84 @@
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
+ cmdShelfHome,
11
+ } from "../lib/commands/shelf.mjs";
12
+
13
+ function usage() {
14
+ console.log(`shelf — 个人内容货架 CLI(别名 atk)
15
+
16
+ Usage:
17
+ shelf 交互浏览货架(数字进目录,p 1,3-5 拉取,a 全部)
18
+ shelf pull <路径...> 按路径拉取,如 shelf pull skills/common/intj
19
+ [--dest <目录>]
20
+ shelf create <本地路径> 上架新货:全架查重名,交互选位(m <名> 建目录,
21
+ d 放下)[--to <货架目录>] [--yes]
22
+ shelf push <本地路径> 更新已有货:按记账/名字自动定位
23
+ [--yes] [--force] [--force-secret]
24
+ shelf sync [--dry-run] 按账本对账:库上新版自动更新本地;本地有改动
25
+ 则显示 diff 由你决定推上库还是覆盖本地
26
+ shelf init 项目接入(幂等):交互多选 agent 目标,或
27
+ --agents claude,codex,kimi / all
28
+ shelf agents [add <名>] 查看 / 追加 agent 目标(claude / codex / kimi)
29
+ shelf home [--update] 查看货架在哪、什么模式;--update 拉取最新
30
+
31
+ 货架定位:SHELF_HOME 环境变量 > 本 clone > ~/.shelfrc > 托管档口 ~/.shelf/home
32
+ clone 直跑:npx -y -p github:Jackzz119/my-workspace shelf <命令>`);
33
+ }
34
+
35
+ const argv = process.argv.slice(2);
36
+ const sub = argv[0];
37
+ const rest = argv.slice(1);
38
+
39
+ try {
40
+ switch (sub) {
41
+ case undefined:
42
+ case "browse":
43
+ await cmdShelfBrowse();
44
+ break;
45
+ case "pull":
46
+ await cmdShelfPull(rest);
47
+ break;
48
+ case "create":
49
+ await cmdShelfCreate(rest);
50
+ break;
51
+ case "push":
52
+ await cmdShelfPush(rest);
53
+ break;
54
+ case "sync":
55
+ await cmdShelfSync(rest);
56
+ break;
57
+ case "init":
58
+ await cmdShelfInit(rest);
59
+ break;
60
+ case "agents":
61
+ await cmdShelfAgents(rest);
62
+ break;
63
+ case "home":
64
+ await cmdShelfHome(rest);
65
+ break;
66
+ case "-h":
67
+ case "--help":
68
+ case "help":
69
+ usage();
70
+ break;
71
+ case "skills":
72
+ case "list":
73
+ console.error(`'shelf ${sub}' 已随老版技能命令清退:浏览用 shelf,按路径拉取用 shelf pull <路径> --dest .claude/skills,保持最新用 shelf sync`);
74
+ process.exit(1);
75
+ break;
76
+ default:
77
+ console.error(`Unknown command: ${sub}\n`);
78
+ usage();
79
+ process.exit(1);
80
+ }
81
+ } catch (err) {
82
+ console.error(err.message || err);
83
+ process.exit(1);
84
+ }
@@ -732,28 +732,228 @@ export async function cmdShelfCreate(argv) {
732
732
  }
733
733
  }
734
734
 
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
+ // 镜像跟随正本:缺失或内容不一致就整体重刷;返回是否动了
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
+ }
763
+
735
764
  // ---- 子命令:init ----
736
765
 
737
- export async function cmdShelfInit() {
766
+ // ---- agent 目标(SHELF 决策 #24)----
767
+
768
+ const AGENT_TARGETS = {
769
+ claude: { skillsDir: ".claude/skills", doc: { input: "agents/claude/CLAUDE.md", dest: "CLAUDE.md" }, ignore: [".claude/", "CLAUDE.md"] },
770
+ codex: { skillsDir: ".codex/skills", doc: { input: "agents/codex/AGENTS.md", dest: "AGENTS.md" }, ignore: [".codex/", "AGENTS.md"] },
771
+ kimi: { skillsDir: ".kimi/skills", doc: { input: "agents/codex/AGENTS.md", dest: "AGENTS.md" }, ignore: [".kimi/", "AGENTS.md"] },
772
+ };
773
+ const TARGET_PRIORITY = ["claude", "codex", "kimi"];
774
+
775
+ // "claude,codex" / "all" → 校验并按优先级排序去重
776
+ function parseTargetNames(spec) {
777
+ const names = spec === "all"
778
+ ? [...TARGET_PRIORITY]
779
+ : String(spec).split(",").map((x) => x.trim()).filter(Boolean);
780
+ const bad = names.filter((n) => !AGENT_TARGETS[n]);
781
+ if (bad.length) {
782
+ console.error("✗ 未知 agent 目标: " + bad.join(", ") + "(可选: " + TARGET_PRIORITY.join(" / ") + " / all)");
783
+ process.exit(1);
784
+ }
785
+ return TARGET_PRIORITY.filter((n) => names.includes(n));
786
+ }
787
+
788
+ async function askTargets() {
789
+ const rl = makeLineReader();
790
+ try {
791
+ console.log("要接入哪些 agent?(决定植入哪些技能目录与协议文档)");
792
+ TARGET_PRIORITY.forEach((n, i) => {
793
+ const t = AGENT_TARGETS[n];
794
+ console.log(" " + (i + 1) + ". " + n + " (" + t.skillsDir + "/ · " + t.doc.dest + ")");
795
+ });
796
+ while (true) {
797
+ const raw = await rl.question("选择编号,逗号分隔(如 1,2);a=全部 > ");
798
+ if (raw === null) return null;
799
+ const ans = raw.trim().toLowerCase();
800
+ if (ans === "a") return [...TARGET_PRIORITY];
801
+ const idx = parseIndices(ans, TARGET_PRIORITY.length);
802
+ if (idx && idx.length) return idx.map((i) => TARGET_PRIORITY[i]);
803
+ console.log(" 没看懂。例如: 1,2 或 a");
804
+ }
805
+ } finally {
806
+ rl.close();
807
+ }
808
+ }
809
+
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);
815
+
816
+ const plan = [];
817
+ const seenDoc = new Set();
818
+ for (const n of targets) {
819
+ const doc = AGENT_TARGETS[n].doc;
820
+ if (seenDoc.has(doc.dest)) continue; // codex/kimi 共用 AGENTS.md,只植一份
821
+ seenDoc.add(doc.dest);
822
+ plan.push({ input: doc.input, dest: doc.dest });
823
+ }
824
+ plan.push({ input: "multica", dest: "multica", optional: true });
825
+ plan.push({ input: "docs/JASKILL.md", dest: "ai/JASKILL.md" });
826
+
827
+ const common = resolveShelfPath(ctx.shelfDir, "skills/common");
828
+ if (common) {
829
+ for (const e of fs.readdirSync(common.abs, { withFileTypes: true })) {
830
+ if (!e.isDirectory() || IGNORE_NAMES.has(e.name)) continue;
831
+ plan.push({
832
+ input: common.realRel + "/" + e.name,
833
+ dest: primaryDir + "/" + e.name,
834
+ mirrors: mirrorDirs.map((d) => d + "/" + e.name),
835
+ });
836
+ }
837
+ }
838
+
839
+ const counters = newCounters();
840
+ let mirrored = 0;
841
+ for (const item of plan) {
842
+ const hit = resolveShelfPath(ctx.shelfDir, item.input);
843
+ if (!hit) {
844
+ console.log(item.optional
845
+ ? "- 货架上暂无 " + item.input + ",跳过"
846
+ : "✗ 货架上找不到 " + item.input + "(检查货架是否最新)");
847
+ continue;
848
+ }
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
+ }
858
+ }
859
+
860
+ const ignoreLines = [...new Set(targets.flatMap((n) => AGENT_TARGETS[n].ignore))];
861
+ const added = ensureGitignore(process.cwd(), ignoreLines);
862
+ return { counters, mirrored, added };
863
+ }
864
+
865
+ function printPlantResult(targets, r) {
866
+ printSummary(r.counters);
867
+ if (r.mirrored) console.log("≡ 镜像刷新 " + r.mirrored + " 项");
868
+ if (r.added.length) console.log("✓ .gitignore 补行: " + r.added.join(", "));
869
+ console.log("");
870
+ console.log("工作区已接入货架(agent 目标: " + targets.join(" + ") + "):");
871
+ const docs = [...new Set(targets.map((n) => AGENT_TARGETS[n].doc.dest))].join(" / ");
872
+ console.log(" " + docs + " 工作协议(真源在货架,shelf sync 保持最新)");
873
+ 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);
878
+ console.log(" .shelf.json 记账本(进项目 git,队友 clone 后 shelf init 即还原)");
879
+ }
880
+
881
+ export async function cmdShelfInit(argv = []) {
882
+ let rest = argv;
883
+ let agentsFlag;
884
+ ({ args: rest, value: agentsFlag } = takeFlag(rest, "--agents", true));
885
+
738
886
  const ctx = resolveShelfContext();
739
887
  try {
888
+ if (path.resolve(process.cwd()) === path.resolve(ctx.root)) {
889
+ console.error("✗ 这里就是货架 home 本体,不能对它 init(防止把忽略规则写进货架仓库)");
890
+ process.exit(1);
891
+ }
740
892
  const manifest = loadManifest();
741
893
  manifest.source ??= remoteUrl(ctx.root) || toPosix(ctx.root);
742
894
 
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);
895
+ let targets;
896
+ if (agentsFlag !== undefined) {
897
+ targets = parseTargetNames(agentsFlag);
898
+ } else if (manifest.agents?.length) {
899
+ targets = TARGET_PRIORITY.filter((n) => manifest.agents.includes(n));
900
+ console.log("(沿用已配置的 agent 目标: " + targets.join(", ") + ";调整用 shelf agents add 或 --agents)");
901
+ } else if (INTERACTIVE) {
902
+ targets = await askTargets();
903
+ if (!targets || !targets.length) {
904
+ console.log("已取消。");
905
+ return;
906
+ }
907
+ } else {
908
+ console.error("✗ 非交互环境首次 init 需指定 --agents claude,codex,kimi(或 all)。已中止。");
909
+ process.exit(2);
747
910
  }
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);
911
+
912
+ const result = await plantForTargets(ctx, manifest, targets);
913
+ manifest.agents = targets;
751
914
  saveManifest(manifest);
915
+ printPlantResult(targets, result);
916
+ } finally {
917
+ ctx.cleanup();
918
+ }
919
+ }
752
920
 
753
- console.log("");
754
- console.log("工作区已就绪:");
755
- console.log(" .shelf.json 版本追踪 manifest");
756
- console.log(" .claude/skills/shelf-ops 货架操作手册(agent 据此执行 pull/push)");
921
+ // ---- 子命令:agents(查看/添加 agent 目标,SHELF 决策 #24)----
922
+
923
+ export async function cmdShelfAgents(argv) {
924
+ const manifest = loadManifest();
925
+ const current = TARGET_PRIORITY.filter((n) => (manifest.agents ?? []).includes(n));
926
+ const sub = argv[0];
927
+
928
+ if (sub === undefined) {
929
+ console.log("已配置: " + (current.length ? current.join(", ") : "(无——先跑 shelf init)"));
930
+ for (const n of TARGET_PRIORITY) {
931
+ const t = AGENT_TARGETS[n];
932
+ console.log(" " + (current.includes(n) ? "●" : "○") + " " + n + " " + t.skillsDir + "/ · " + t.doc.dest);
933
+ }
934
+ console.log("添加: shelf agents add <名>");
935
+ return;
936
+ }
937
+ if (sub !== "add" || argv.length < 2) {
938
+ console.error("用法: shelf agents 查看已配置/可用目标");
939
+ console.error(" shelf agents add <名> 添加目标(claude / codex / kimi / all)");
940
+ process.exit(1);
941
+ }
942
+
943
+ const adding = parseTargetNames(argv.slice(1).join(","));
944
+ const targets = TARGET_PRIORITY.filter((n) => current.includes(n) || adding.includes(n));
945
+ if (targets.join() === current.join()) {
946
+ console.log("= 目标已包含,无需变更(" + current.join(", ") + ")");
947
+ return;
948
+ }
949
+
950
+ const ctx = resolveShelfContext();
951
+ try {
952
+ const result = await plantForTargets(ctx, manifest, targets);
953
+ manifest.agents = targets;
954
+ saveManifest(manifest);
955
+ printPlantResult(targets, result);
956
+ console.log("✓ agent 目标: " + (current.join(", ") || "(无)") + " → " + targets.join(", "));
757
957
  } finally {
758
958
  ctx.cleanup();
759
959
  }
@@ -789,165 +989,177 @@ export async function cmdShelfHome(argv) {
789
989
  ctx.cleanup();
790
990
  }
791
991
  }
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
- }
992
+
993
+ // ---- 子命令:sync(按账本与货架对账,SHELF 决策 #19)----
994
+
995
+ // 逐行内容 diff 直通终端(git 自带着色),方向:库上 → 本地
996
+ function showLineDiff(shelfAbs, localAbs, shown) {
997
+ console.log(`—— ${shown} diff(库上 → 本地)——`);
998
+ spawnSync("git", ["diff", "--no-index", "--color=always", "--", shelfAbs, localAbs], { stdio: "inherit" });
999
+ }
1000
+
1001
+ export async function cmdShelfSync(argv) {
1002
+ let rest = argv;
1003
+ let dryRun;
1004
+ ({ args: rest, value: dryRun } = takeFlag(rest, "--dry-run"));
1005
+
1006
+ const ctx = resolveShelfContext({ forWrite: true }); // 对账必须对着真最新:等同写操作,强制刷新
1007
+ let keepEphemeral = false;
1008
+ try {
1009
+ const manifest = loadManifest();
1010
+ const entries = Object.entries(manifest.shelf ?? {});
1011
+ if (entries.length === 0) {
1012
+ console.log("账本为空(.shelf.json 没有 shelf 段记录)——先 shelf pull / create。");
1013
+ return;
1014
+ }
1015
+
1016
+ const c = { same: 0, updated: 0, pushed: 0, skipped: 0, cleaned: 0 };
1017
+ const pending = [];
1018
+
1019
+ for (const [key, rec] of entries) {
1020
+ let effKey = key;
1021
+ let target = path.join(ctx.shelfDir, ...effKey.split("/"));
1022
+ let shown = displayPath(effKey);
1023
+ const localAbs = path.resolve(process.cwd(), rec.localPath ?? "");
1024
+ const localExists = !!rec.localPath && fs.existsSync(localAbs);
1025
+
1026
+ // 情形 5:库上路径没了 → 按名字找回(搬家)或报告下架
1027
+ if (!fs.existsSync(target)) {
1028
+ const name = path.basename(effKey);
1029
+ const hits = findByBasename(ctx.shelfDir, name);
1030
+ if (hits.length === 1) {
1031
+ if (dryRun) {
1032
+ console.log(`↪ ${shown} 已被移动到 ${displayPath(hits[0])}(--dry-run,暂不改账)`);
1033
+ } else {
1034
+ delete manifest.shelf[effKey];
1035
+ manifest.shelf[hits[0]] = rec;
1036
+ console.log(`↪ ${shown} 已被移动到 ${displayPath(hits[0])},记账已更新`);
1037
+ }
1038
+ effKey = hits[0];
1039
+ target = path.join(ctx.shelfDir, ...effKey.split("/"));
1040
+ shown = displayPath(effKey);
1041
+ } else {
1042
+ const label = hits.length === 0 ? "已从货架移除" : `同名多义(${hits.length} 处)`;
1043
+ if (!INTERACTIVE || dryRun) {
1044
+ console.log(`⚠ 待决 ${shown}:${label}`);
1045
+ pending.push(`${shown}(${label})`);
1046
+ continue;
1047
+ }
1048
+ const act = await choose(`⚠ ${shown} ${label}。[r]清记账 / [s]跳过? `, [{ key: "r" }, { key: "s" }]);
1049
+ if (act === "r") {
1050
+ delete manifest.shelf[effKey];
1051
+ console.log(`✓ 已清记账 ${shown}(本地文件未动;想重新上架用 shelf create)`);
1052
+ c.cleaned++;
1053
+ } else c.skipped++;
1054
+ continue;
1055
+ }
1056
+ }
1057
+
1058
+ // 情形 6:本地文件没了 → 孤儿记账
1059
+ if (!localExists) {
1060
+ if (!INTERACTIVE || dryRun) {
1061
+ console.log(`⚠ 待决 ${shown}:本地文件不存在(${rec.localPath ?? "无 localPath"})`);
1062
+ pending.push(`${shown}(本地文件不存在)`);
1063
+ continue;
1064
+ }
1065
+ const act = await choose(
1066
+ `⚠ ${shown} 的本地文件不存在(${rec.localPath ?? "无 localPath"})。[p]重新拉取 / [r]清记账 / [s]跳过? `,
1067
+ [{ key: "p" }, { key: "r" }, { key: "s" }],
1068
+ );
1069
+ if (act === "p") {
1070
+ fs.mkdirSync(path.dirname(localAbs), { recursive: true });
1071
+ copyFiltered(target, localAbs);
1072
+ manifest.shelf[effKey] = { ...rec, contentHash: contentHash(target), sourceCommit: headCommit(ctx.root), pulledAt: todayISO() };
1073
+ console.log(`↓ 已重新拉取 ${shown}`);
1074
+ c.updated++;
1075
+ } else if (act === "r") {
1076
+ delete manifest.shelf[effKey];
1077
+ console.log(`✓ 已清记账 ${shown}`);
1078
+ c.cleaned++;
1079
+ } else c.skipped++;
1080
+ continue;
1081
+ }
1082
+
1083
+ const S = contentHash(target);
1084
+ const L = contentHash(localAbs);
1085
+ const R = rec.contentHash;
1086
+
1087
+ if (S === R && L === R) { c.same++; continue; }
1088
+
1089
+ // 情形 2:库上有新版、本地没动 → 自动覆盖本地(零损失)
1090
+ if (S !== R && L === R) {
1091
+ if (dryRun) {
1092
+ console.log(`↓ 将更新本地:${shown}`);
1093
+ c.updated++;
1094
+ continue;
1095
+ }
1096
+ fs.rmSync(localAbs, { recursive: true, force: true });
1097
+ copyFiltered(target, localAbs);
1098
+ manifest.shelf[effKey] = { ...rec, contentHash: S, sourceCommit: headCommit(ctx.root), pulledAt: todayISO() };
1099
+ console.log(`↓ 已更新本地:${shown}`);
1100
+ c.updated++;
1101
+ continue;
1102
+ }
1103
+
1104
+ // 情形 3/4:本地有改动(本地领先,或双方都改)
1105
+ const both = S !== R;
1106
+ const tag = both ? "双方都改过" : "本地领先";
1107
+ if (!INTERACTIVE || dryRun) {
1108
+ const d = diffSummary(target, localAbs);
1109
+ console.log(`⚠ 待决 ${shown}(${tag}):本地相对库上 新增 ${d.added.length} / 删除 ${d.removed.length} / 修改 ${d.changed.length}`);
1110
+ pending.push(`${shown}(${tag})`);
1111
+ continue;
1112
+ }
1113
+
1114
+ showLineDiff(target, localAbs, shown);
1115
+ const act = await choose(`${shown}(${tag})。[p]本地推上库 / [o]库覆盖本地 / [s]跳过? `, [{ key: "p" }, { key: "o" }, { key: "s" }]);
1116
+ if (act === "s") { c.skipped++; continue; }
1117
+ if (act === "o") {
1118
+ fs.rmSync(localAbs, { recursive: true, force: true });
1119
+ copyFiltered(target, localAbs);
1120
+ manifest.shelf[effKey] = { ...rec, contentHash: S, sourceCommit: headCommit(ctx.root), pulledAt: todayISO() };
1121
+ console.log(`↓ 已用库上版本覆盖本地:${shown}`);
1122
+ c.updated++;
1123
+ continue;
1124
+ }
1125
+ // p:本地推上库
1126
+ if (both) {
1127
+ const confirm = await choose(`库上的改动将被你的本地版本覆盖,确认? [y]es / [n]o? `, [{ key: "y" }, { key: "n" }]);
1128
+ if (confirm === "n") { c.skipped++; continue; }
1129
+ }
1130
+ const { secrets } = guardScan(localAbs);
1131
+ if (secrets.length > 0) {
1132
+ console.log(`✗ ${shown} 含疑似凭据文件(${secrets.map((x) => x.rel).join(", ")}),sync 不代推,已跳过——确要推请单独 shelf push --force-secret`);
1133
+ c.skipped++;
1134
+ continue;
1135
+ }
1136
+ keepEphemeral = applyAndCommit(ctx, effKey, localAbs, manifest, "update") || keepEphemeral;
1137
+ c.pushed++;
1138
+ }
1139
+
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
+ }
1149
+ }
1150
+ }
1151
+
1152
+ if (!dryRun) saveManifest(manifest);
1153
+ console.log("");
1154
+ console.log(
1155
+ `Sync${dryRun ? "(dry-run)" : ""}: ${c.same} 一致, ${c.updated} 更新本地, ${c.pushed} 推上库, ` +
1156
+ `${c.cleaned} 清账, ${c.skipped} 跳过, ${mirrorFixed} 镜像刷新, ${pending.length} 待决`,
1157
+ );
1158
+ if (pending.length) {
1159
+ for (const x of pending) console.log(` · ${x}`);
1160
+ if (!INTERACTIVE && !dryRun) process.exit(2);
1161
+ }
1162
+ } finally {
1163
+ if (!keepEphemeral) ctx.cleanup();
1164
+ }
1165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackss119/shelf",
3
- "version": "1.0.0",
3
+ "version": "1.1.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": {