@jackss119/shelf 1.0.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.
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const MANIFEST_NAME = ".shelf.json";
5
+ // 历史名:读取时自动兼容,saveManifest 落新名并删旧文件
6
+ const LEGACY_MANIFEST_NAMES = [".atk.json", ".agent-toolkit.json"];
7
+
8
+ export function manifestPath(cwd = process.cwd()) {
9
+ return path.join(cwd, MANIFEST_NAME);
10
+ }
11
+
12
+ function existingLegacyPath(cwd = process.cwd()) {
13
+ for (const name of LEGACY_MANIFEST_NAMES) {
14
+ const p = path.join(cwd, name);
15
+ if (fs.existsSync(p)) return p;
16
+ }
17
+ return null;
18
+ }
19
+
20
+ export function loadManifest(cwd = process.cwd()) {
21
+ let file = manifestPath(cwd);
22
+ if (!fs.existsSync(file)) {
23
+ file = existingLegacyPath(cwd) ?? file;
24
+ }
25
+ if (!fs.existsSync(file)) {
26
+ return {
27
+ source: null,
28
+ skillsDir: ".claude/skills",
29
+ skills: {},
30
+ shelf: {},
31
+ };
32
+ }
33
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
34
+ data.skills ??= {};
35
+ data.shelf ??= {};
36
+ return data;
37
+ }
38
+
39
+ export function saveManifest(manifest, cwd = process.cwd()) {
40
+ const file = manifestPath(cwd);
41
+ fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n", "utf8");
42
+ for (let legacy = existingLegacyPath(cwd); legacy; legacy = existingLegacyPath(cwd)) {
43
+ fs.rmSync(legacy);
44
+ console.log(`(已迁移 ${path.basename(legacy)} → ${MANIFEST_NAME})`);
45
+ }
46
+ }
47
+
48
+ // shelf 段以 shelf 相对路径(真实名)为键,见 SHELF 决策 #6
49
+ export function setShelfEntry(manifest, shelfPath, entry) {
50
+ manifest.shelf ??= {};
51
+ manifest.shelf[shelfPath] = entry;
52
+ }
53
+
54
+ export function findShelfEntryByLocalPath(manifest, localPath) {
55
+ const normalized = localPath.replaceAll("\\", "/");
56
+ for (const [shelfPath, entry] of Object.entries(manifest.shelf ?? {})) {
57
+ if ((entry.localPath ?? "").replaceAll("\\", "/") === normalized) {
58
+ return { shelfPath, entry };
59
+ }
60
+ }
61
+ return null;
62
+ }
package/lib/paths.mjs ADDED
@@ -0,0 +1,22 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const here = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ // 从 lib/ 向上找带 shelf/skills 的目录:
8
+ // - monorepo 开发态(packages/shelf/lib → 仓库根)命中
9
+ // - npx 快照(包内含 shelf/)命中
10
+ // - 全局安装的独立 CLI 包内没有内容,返回 null,由 transport 开档口
11
+ function findLocalRoot(start) {
12
+ let dir = start;
13
+ for (let i = 0; i < 6; i++) {
14
+ if (fs.existsSync(path.join(dir, "shelf", "skills"))) return dir;
15
+ const parent = path.dirname(dir);
16
+ if (parent === dir) break;
17
+ dir = parent;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ export const repoRoot = findLocalRoot(here);
package/lib/prompt.mjs ADDED
@@ -0,0 +1,17 @@
1
+ import readline from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+
4
+ export async function choose(question, choices) {
5
+ const keys = choices.map((c) => c.key.toLowerCase());
6
+ const rl = readline.createInterface({ input: stdin, output: stdout });
7
+ try {
8
+ while (true) {
9
+ const ans = (await rl.question(question)).trim().toLowerCase();
10
+ const hit = choices.find((c) => c.key.toLowerCase() === ans);
11
+ if (hit) return hit.key.toLowerCase();
12
+ stdout.write(` please type one of: ${keys.join(", ")}\n`);
13
+ }
14
+ } finally {
15
+ rl.close();
16
+ }
17
+ }
@@ -0,0 +1,69 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ // `_` 前缀只是文件系统里的置顶手段(SHELF 决策 #11):
5
+ // 展示时隐藏,输入时两种写法都接受,落盘与 manifest 永远用真实名。
6
+
7
+ const SKIP_NAMES = new Set(["node_modules", ".git", ".DS_Store"]);
8
+
9
+ export function displayName(realName) {
10
+ return realName.replace(/^_+/, "");
11
+ }
12
+
13
+ export function displayPath(realRelPath) {
14
+ return realRelPath.split("/").map(displayName).join("/");
15
+ }
16
+
17
+ // 在 parentDir 下把一个展示名/真实名解析回真实目录项;不存在返回 null
18
+ export function resolveSegment(parentDir, segment) {
19
+ if (fs.existsSync(path.join(parentDir, segment))) return segment;
20
+ for (const prefix of ["_", "__"]) {
21
+ const candidate = prefix + segment;
22
+ if (fs.existsSync(path.join(parentDir, candidate))) return candidate;
23
+ }
24
+ return null;
25
+ }
26
+
27
+ // 把用户输入的 shelf 相对路径(展示名或真实名混用皆可)解析为真实相对路径。
28
+ // allowCreate 供 push 的目标路径使用:从第一个不存在的段起,按字面新建(created 标记返回)。
29
+ export function resolveShelfPath(shelfDir, inputPath, { allowCreate = false } = {}) {
30
+ const segments = inputPath.replaceAll("\\", "/").split("/").filter(Boolean);
31
+ if (segments.length === 0) return null;
32
+
33
+ const realSegments = [];
34
+ let created = false;
35
+ let current = shelfDir;
36
+ for (let i = 0; i < segments.length; i++) {
37
+ const real = resolveSegment(current, segments[i]);
38
+ if (real === null) {
39
+ if (!allowCreate) return null;
40
+ realSegments.push(...segments.slice(i));
41
+ created = true;
42
+ break;
43
+ }
44
+ realSegments.push(real);
45
+ current = path.join(current, real);
46
+ }
47
+ const realRel = realSegments.join("/");
48
+ return { realRel, abs: path.join(shelfDir, ...realSegments), created };
49
+ }
50
+
51
+ // 全架按名字找条目(名字即 ID,SHELF 决策 #14/#15):
52
+ // 目录或文件的真实名/展示名匹配即命中;命中的目录不再深入其内部(内部文件是货物的组成部分,不是货物)。
53
+ export function findByBasename(shelfDir, name) {
54
+ const hits = [];
55
+ const walk = (dir, rel) => {
56
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
57
+ if (SKIP_NAMES.has(entry.name)) continue;
58
+ const entryRel = rel ? `${rel}/${entry.name}` : entry.name;
59
+ const matched = entry.name === name || displayName(entry.name) === name;
60
+ if (matched) {
61
+ hits.push(entryRel);
62
+ continue;
63
+ }
64
+ if (entry.isDirectory()) walk(path.join(dir, entry.name), entryRel);
65
+ }
66
+ };
67
+ walk(shelfDir, "");
68
+ return hits;
69
+ }
@@ -0,0 +1,268 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+ import { repoRoot } from "./paths.mjs";
6
+
7
+ // 内容货架的默认来源(CLI 发布为公开包,这里只暴露仓库地址,不含任何访问权)
8
+ export const DEFAULT_REMOTE = "https://github.com/Jackzz119/my-workspace.git";
9
+
10
+ // 托管档口:全局安装的 CLI 自己维护的一份 clone,用户不需要手动 clone
11
+ export const managedHomeDir = path.join(os.homedir(), ".shelf", "home");
12
+ const refreshStamp = path.join(os.homedir(), ".shelf", ".last-refresh");
13
+ const REFRESH_INTERVAL_MS = 60 * 1000; // 只为去重连环命令,不是保鲜策略:每次操作都会刷新
14
+ const NL = String.fromCharCode(10);
15
+
16
+ // shelf 传输层(SHELF 决策 #4 / #16 / #17)解析顺序:
17
+ // 1. SHELF_HOME / ATK_HOME 环境变量指定的 clone
18
+ // 2. CLI 自身所在的 clone(monorepo 内直跑 / npm link 都命中)
19
+ // 3. ~/.shelfrc 的 home
20
+ // 4. 托管档口 ~/.shelf/home(存在即用;每次操作都刷新,写操作强制先拉)
21
+ // 5. npx 快照(包内带 shelf/ 但无 .git):读操作直接用
22
+ // 6. 都没有:自动 clone 出托管档口;快照场景或显式 ephemeral 时走一次性 clone
23
+
24
+ function git(args, cwd, opts = {}) {
25
+ return execFileSync("git", args, {
26
+ cwd,
27
+ encoding: "utf8",
28
+ stdio: ["ignore", "pipe", "pipe"],
29
+ ...opts,
30
+ }).trim();
31
+ }
32
+
33
+ function firstLines(err, n = 3) {
34
+ return (err.stderr || err.message || "").toString().trim().split(NL).slice(0, n).join(" / ");
35
+ }
36
+
37
+ function hasShelf(root) {
38
+ return !!root && fs.existsSync(path.join(root, "shelf"));
39
+ }
40
+
41
+ function isGitRepo(root) {
42
+ return !!root && fs.existsSync(path.join(root, ".git"));
43
+ }
44
+
45
+ function pkgField(root, field) {
46
+ try {
47
+ return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"))[field] ?? null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function repoUrlFromPkg(root) {
54
+ const repo = pkgField(root, "repository");
55
+ const url = typeof repo === "string" ? repo : repo?.url;
56
+ if (!url) return null;
57
+ return url.startsWith("git+") ? url.slice(4) : url;
58
+ }
59
+
60
+ function readRc() {
61
+ for (const name of [".shelfrc", ".atkrc"]) {
62
+ const rcPath = path.join(os.homedir(), name);
63
+ if (!fs.existsSync(rcPath)) continue;
64
+ try {
65
+ return JSON.parse(fs.readFileSync(rcPath, "utf8"));
66
+ } catch {
67
+ console.warn("! ~/" + name + " 不是合法 JSON,已忽略");
68
+ }
69
+ }
70
+ return {};
71
+ }
72
+
73
+ function resolveRemote(rc, snapshot) {
74
+ return process.env.SHELF_REMOTE
75
+ || process.env.ATK_REMOTE
76
+ || rc.remote
77
+ || (snapshot ? repoUrlFromPkg(snapshot) : null)
78
+ || DEFAULT_REMOTE;
79
+ }
80
+
81
+ // 刷新托管档口:默认带 60 秒去重(防连环命令重复联网),force 无视去重;离线或失败都不阻断命令
82
+ export function refreshManagedHome({ force = false } = {}) {
83
+ if (!isGitRepo(managedHomeDir)) return false;
84
+ if (!force) {
85
+ try {
86
+ const age = Date.now() - fs.statSync(refreshStamp).mtimeMs;
87
+ if (age < REFRESH_INTERVAL_MS) return false;
88
+ } catch { /* 没有戳记就拉一次 */ }
89
+ }
90
+ try {
91
+ git(["pull", "--ff-only", "--quiet"], managedHomeDir);
92
+ fs.mkdirSync(path.dirname(refreshStamp), { recursive: true });
93
+ fs.writeFileSync(refreshStamp, new Date().toISOString(), "utf8");
94
+ return true;
95
+ } catch (err) {
96
+ console.warn("! 托管档口更新失败(继续用本地副本): " + firstLines(err, 1));
97
+ return false;
98
+ }
99
+ }
100
+
101
+ function createManagedHome(remote) {
102
+ fs.mkdirSync(path.dirname(managedHomeDir), { recursive: true });
103
+ console.log("⇣ 首次使用:正在把货架 clone 到 " + managedHomeDir + " …");
104
+ // -c core.autocrlf=false 必须在 clone 当时生效:我们按字节复制文件,
105
+ // 若 checkout 时做了换行符转换,工作区会永远显示"已修改",进而卡住自动更新
106
+ git(["clone", "-c", "core.autocrlf=false", "--quiet", remote, managedHomeDir], os.homedir());
107
+ fs.writeFileSync(refreshStamp, new Date().toISOString(), "utf8");
108
+ console.log("✓ 档口就绪,以后所有命令都在本地跑(更新用 shelf home --update)");
109
+ }
110
+
111
+ function ephemeralClone(remote) {
112
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "shelf-"));
113
+ git(["clone", "--depth=1", "--filter=blob:none", "--sparse", "--quiet", remote, tmp], os.tmpdir());
114
+ git(["sparse-checkout", "set", "shelf"], tmp);
115
+ return tmp;
116
+ }
117
+
118
+ function homeContext(root, mode = "home") {
119
+ return { mode, root, shelfDir: path.join(root, "shelf"), cleanup() {} };
120
+ }
121
+
122
+ export function resolveShelfContext({ forWrite = false } = {}) {
123
+ const rc = readRc();
124
+
125
+ const explicit = [process.env.SHELF_HOME, process.env.ATK_HOME, rc.home]
126
+ .find((r) => hasShelf(r) && isGitRepo(r));
127
+ if (explicit) return homeContext(explicit);
128
+
129
+ if (hasShelf(repoRoot) && isGitRepo(repoRoot)) return homeContext(repoRoot);
130
+
131
+ if (hasShelf(managedHomeDir) && isGitRepo(managedHomeDir)) {
132
+ // 货架前期高频更新:写操作无条件先拉最新(避免基于旧内容提交被远端拒收),
133
+ // 读操作也每次刷新,仅用 60 秒戳记去重连环命令
134
+ refreshManagedHome({ force: forWrite });
135
+ return homeContext(managedHomeDir, "managed");
136
+ }
137
+
138
+ // npm/npx 安装的快照:有 shelf/ 没 .git
139
+ const snapshot = hasShelf(repoRoot) && !isGitRepo(repoRoot) ? repoRoot : null;
140
+ if (snapshot && !forWrite) {
141
+ return { mode: "snapshot", root: snapshot, shelfDir: path.join(snapshot, "shelf"), cleanup() {} };
142
+ }
143
+
144
+ const remote = resolveRemote(rc, snapshot);
145
+ const ephemeralOnly = process.env.SHELF_EPHEMERAL === "1" || rc.ephemeral === true || !!snapshot;
146
+
147
+ if (!ephemeralOnly) {
148
+ createManagedHome(remote);
149
+ return homeContext(managedHomeDir, "managed");
150
+ }
151
+
152
+ const tmp = ephemeralClone(remote);
153
+ let disposed = false;
154
+ const cleanup = () => {
155
+ if (disposed) return;
156
+ disposed = true;
157
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* 尽力而为 */ }
158
+ };
159
+ // process.exit 会跳过 finally,这里兜底,保证任何退出路径都不留临时目录
160
+ process.on("exit", cleanup);
161
+ return {
162
+ mode: "ephemeral",
163
+ root: tmp,
164
+ shelfDir: path.join(tmp, "shelf"),
165
+ cleanup,
166
+ keep() { disposed = true; }, // push 失败要保留现场时解除自动清理
167
+ };
168
+ }
169
+
170
+ export function headCommit(root) {
171
+ try {
172
+ return git(["rev-parse", "HEAD"], root) || null;
173
+ } catch {
174
+ // npx 快照没有 .git;npm 打包 git 依赖时可能注入 package.json 的 gitHead
175
+ return pkgField(root, "gitHead");
176
+ }
177
+ }
178
+
179
+ export function remoteUrl(root) {
180
+ try {
181
+ return git(["config", "--get", "remote.origin.url"], root) || null;
182
+ } catch {
183
+ return repoUrlFromPkg(root);
184
+ }
185
+ }
186
+
187
+ function hasGitIdentity(root) {
188
+ try {
189
+ return !!git(["config", "user.email"], root);
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
194
+
195
+ // 全新机器可能还没配 git 身份,会导致档口提交失败。只给我们自己建的档口兜底,
196
+ // 绝不改用户自己的 clone。
197
+ function isToolOwnedClone(root) {
198
+ const r = path.resolve(root);
199
+ return r === path.resolve(managedHomeDir) || r.startsWith(path.resolve(os.tmpdir()));
200
+ }
201
+
202
+ function ensureManagedIdentity(root) {
203
+ if (!isToolOwnedClone(root)) return;
204
+ if (hasGitIdentity(root)) return;
205
+ const email = "shelf@" + os.hostname();
206
+ git(["config", "user.name", "shelf"], root);
207
+ git(["config", "user.email", email], root);
208
+ console.log("(本机未配置 git 身份,已给本次 clone 设 shelf <" + email + ">;想换成自己的:git -C " + root + " config user.name 你的名字)");
209
+ }
210
+
211
+ // 提交失败时把货架工作区恢复原状,避免半改状态被下次 push 误判成「异机修改」
212
+ function restoreWorktree(root, relPaths) {
213
+ for (const args of [
214
+ ["reset", "--quiet", "--", ...relPaths],
215
+ ["checkout", "--", ...relPaths],
216
+ ["clean", "-qfd", "--", ...relPaths],
217
+ ]) {
218
+ try { git(args, root); } catch { /* 尽力而为 */ }
219
+ }
220
+ }
221
+
222
+ // 提交 shelf 下的指定路径;有 remote 则尝试 push。
223
+ // 返回 { committed, sha, pushed, pushError, failed }
224
+ export function commitAndPush(root, relPaths, message) {
225
+ let sha;
226
+ try {
227
+ ensureManagedIdentity(root);
228
+ git(["add", "--", ...relPaths], root);
229
+
230
+ let staged = true;
231
+ try {
232
+ git(["diff", "--cached", "--quiet", "--", ...relPaths], root);
233
+ staged = false; // exit 0 = 无差异
234
+ } catch {
235
+ staged = true;
236
+ }
237
+ if (!staged) return { committed: false, sha: null, pushed: false, pushError: null };
238
+
239
+ git(["commit", "--quiet", "-m", message], root);
240
+ sha = headCommit(root);
241
+ // 消除换行符转换残留,保持工作区干净,避免下次 git pull 被本地改动挡住
242
+ try { git(["checkout", "--", ...relPaths], root); } catch { /* 尽力而为 */ }
243
+ } catch (err) {
244
+ restoreWorktree(root, relPaths);
245
+ return { committed: false, sha: null, pushed: false, pushError: firstLines(err), failed: true };
246
+ }
247
+
248
+ if (!remoteUrl(root)) {
249
+ return { committed: true, sha, pushed: false, pushError: "no-remote" };
250
+ }
251
+ try {
252
+ git(["push", "--quiet"], root);
253
+ return { committed: true, sha, pushed: true, pushError: null };
254
+ } catch (err) {
255
+ const msg = firstLines(err, 2);
256
+ // 远端比我们新(non-fast-forward):把本地提交变基到最新远端上,重推一次
257
+ if (/fetch first|non-fast-forward|rejected/i.test(msg)) {
258
+ try {
259
+ git(["pull", "--rebase", "--autostash", "--quiet"], root);
260
+ git(["push", "--quiet"], root);
261
+ return { committed: true, sha: headCommit(root), pushed: true, pushError: null, rebased: true };
262
+ } catch (err2) {
263
+ return { committed: true, sha, pushed: false, pushError: firstLines(err2, 1) };
264
+ }
265
+ }
266
+ return { committed: true, sha, pushed: false, pushError: firstLines(err, 1) };
267
+ }
268
+ }
@@ -0,0 +1,33 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+
5
+ function walkFiles(dir, base = dir) {
6
+ const out = [];
7
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
8
+ const full = path.join(dir, entry.name);
9
+ if (entry.isDirectory()) {
10
+ out.push(...walkFiles(full, base));
11
+ } else if (entry.isFile()) {
12
+ out.push(path.relative(base, full).split(path.sep).join("/"));
13
+ }
14
+ }
15
+ return out;
16
+ }
17
+
18
+ export function contentHash(target) {
19
+ const hash = crypto.createHash("sha256");
20
+ if (fs.statSync(target).isFile()) {
21
+ hash.update("file\0");
22
+ hash.update(fs.readFileSync(target));
23
+ return `sha256:${hash.digest("hex")}`;
24
+ }
25
+ const files = walkFiles(target).sort();
26
+ for (const rel of files) {
27
+ hash.update(rel);
28
+ hash.update("\0");
29
+ hash.update(fs.readFileSync(path.join(target, rel)));
30
+ hash.update("\0");
31
+ }
32
+ return `sha256:${hash.digest("hex")}`;
33
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@jackss119/shelf",
3
+ "version": "1.0.0",
4
+ "description": "Personal content shelf CLI: browse, pull and push skills, templates, docs and snippets across every machine you work on.",
5
+ "type": "module",
6
+ "bin": {
7
+ "shelf": "bin/shelf.mjs",
8
+ "atk": "bin/shelf.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/Jackzz119/my-workspace.git",
17
+ "directory": "packages/shelf"
18
+ },
19
+ "homepage": "https://github.com/Jackzz119/my-workspace/tree/main/packages/shelf#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/Jackzz119/my-workspace/issues"
22
+ },
23
+ "keywords": [
24
+ "cli",
25
+ "skills",
26
+ "claude-code",
27
+ "codex",
28
+ "agent",
29
+ "dotfiles",
30
+ "sync",
31
+ "templates"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "license": "MIT"
40
+ }