lshed 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-09-03
4
+
5
+ Hand-configured MCP servers travel with the profile. Secret values do not.
6
+
7
+ - New category `mcp` for Claude Code: each user-scope server in `~/.claude.json` becomes `mcp/<name>.json` in the shed. `restore` writes only `mcpServers.<name>` and leaves the rest of the file (machine ID, session state, servers you added by hand) untouched. The file is replaced atomically.
8
+ - `init` masks values under `env` and `headers` whose key looks like a secret with `${VAR}` (`EXA_API_KEY` → `${EXA_API_KEY}`, `Authorization: Bearer …` → `Bearer ${NOTION_AUTHORIZATION}`). Claude Code expands `${VAR}` from the environment in every scope, so `restore` places the placeholder verbatim and no secret passes through lshed. Verified against the real binary.
9
+ - `restore` and `status` list the variables a profile needs that are not set in the current shell.
10
+ - `diff` shows entry changes by key path; `save` keeps placeholders that still match and masks newly added secret-looking keys.
11
+ - Profile switches remove only the servers lshed placed, backing each up as JSON.
12
+ - Adapter interface: `entries()` returns categories that live as JSON entries instead of files, with `secretKeys` and `expandsEnv` policy. The default root honours `CLAUDE_CONFIG_DIR`.
13
+ - Managed-set paths for entries are written as `mcp:<name>`; a colon cannot appear in a path segment, so they never collide with files.
14
+
3
15
  ## 0.3.0 — 2026-09-02
4
16
 
5
17
  Claude Code plugins are packages now. On the machine this was built against, the five installed plugins were the only thing a fresh `restore` still left out, and two of them carry MCP servers.
package/README.md CHANGED
@@ -64,12 +64,15 @@ components:
64
64
  instructions:
65
65
  - id: base # file:./instructions/base.md
66
66
  - id: research-style
67
+ mcp:
68
+ - id: exa # file:./mcp/exa.json — secrets replaced by ${VAR}
67
69
 
68
70
  profiles:
69
71
  research:
70
72
  skills: [paper-review]
71
73
  agents: [reviewer]
72
74
  instructions: [base, research-style] # order matters
75
+ mcp: [exa]
73
76
  teaching:
74
77
  skills: [grading-helper]
75
78
  commands: [summarize]
@@ -77,10 +80,25 @@ profiles:
77
80
  ```
78
81
 
79
82
  - Component `source` accepts `file:<path relative to the shed>`. Package `source` accepts `github:owner/repo@ref` or `git:<url>#ref`.
80
- - Category names come from the adapter. For Claude Code: `skills`, `agents`, `commands`, `instructions`.
83
+ - Category names come from the adapter. For Claude Code: `skills`, `agents`, `commands`, `instructions`, `mcp`.
81
84
  - `ignore:` at the top level adds to the built-in list of things never copied: `node_modules`, `.git`, `__pycache__`, `.venv`, cache directories, `*.log`. Build output like `dist/` is not ignored by default, since some skills ship it. Add it yourself if your parts rebuild from source.
82
85
  - Instructions are not merged. `restore` writes a `CLAUDE.md` that `@`-imports each fragment in order, so a fragment edit shows up without re-running anything. Your original `CLAUDE.md` is backed up the first time.
83
86
 
87
+ ## MCP servers and secrets
88
+
89
+ User-scope MCP servers live in `~/.claude.json`, next to machine IDs and session state. lshed treats each server as a component of category `mcp`: the shed holds `mcp/<name>.json`, and `restore` edits only the `mcpServers.<name>` key of `~/.claude.json`, leaving everything else in that file alone.
90
+
91
+ **No secret value enters the shed.** `init` replaces values under `env` and `headers` whose key looks like a secret (`key`, `token`, `secret`, `pass`, `auth`, `credential`, `cookie`, `session`) with a `${VAR}` placeholder:
92
+
93
+ ```json
94
+ { "type": "stdio", "command": "npx", "args": ["-y", "exa-mcp-server"],
95
+ "env": { "EXA_API_KEY": "${EXA_API_KEY}" } }
96
+ { "type": "http", "url": "https://mcp.notion.com/mcp",
97
+ "headers": { "Authorization": "Bearer ${NOTION_AUTHORIZATION}" } }
98
+ ```
99
+
100
+ `restore` writes the placeholder as is. Claude Code expands `${VAR}` from the environment when it starts the server, so the value only ever lives in your shell (`export EXA_API_KEY=...` in `~/.zshrc`, or however you manage secrets). `restore` and `status` list the variables the profile needs that are not set. The heuristic is a suggestion: edit the JSON in the shed to add or remove placeholders, and `init` warns when something in `args` or `url` looks like a token. `save` keeps existing placeholders and masks new secret-looking keys, so a rotated key never leaks into the shed by accident.
101
+
84
102
  ## Three kinds of things
85
103
 
86
104
  A real `~/.claude` mixes three kinds of content, and they need different handling:
@@ -168,22 +186,24 @@ The shed is the source of truth for authored parts: `save` copies local edits ba
168
186
  <shed>/
169
187
  lshed.yaml
170
188
  skills/<id>/ agents/<id>.md commands/<id>.md instructions/<id>.md
189
+ mcp/<id>.json ← secrets as ${VAR}
171
190
 
172
191
  ~/.claude/
173
192
  skills/ agents/ commands/ CLAUDE.md ← placed by restore
193
+ ~/.claude.json mcpServers.<id> ← one key per mcp component; the rest of the file is untouched
174
194
  lshed/state.json ← which profile, which paths are managed
175
195
  lshed/instructions/<id>.md ← fragments imported by CLAUDE.md
176
196
  lshed/backups/<timestamp>/ ← whatever restore replaced
177
197
  ```
178
198
 
179
- `state.json` is per machine and is not part of the shed.
199
+ `state.json` is per machine and is not part of the shed. If `CLAUDE_CONFIG_DIR` is set, lshed uses it as the root and expects `.claude.json` inside it, as Claude Code does.
180
200
 
181
201
  ## Not in scope (yet)
182
202
 
183
- - MCP servers and secrets. Planned: the manifest names the keys, values are injected locally, nothing secret enters the shed.
203
+ - Secrets beyond "name the variable". Encrypted values, `op://` references and OS keychains are possible later; today lshed is deliberately no better than dotfiles here.
204
+ - Project-scope MCP servers (`.mcp.json`, `~/.claude.json` `projects.*`). They belong to the project.
184
205
  - `settings.json` merging (hooks, permissions).
185
206
  - `sync` (a git pull/push wrapper). Use git in the shed directly for now.
186
- - MCP servers configured by hand in `~/.claude.json`. Plugin-bundled ones are covered.
187
207
  - Windows and macOS have not been tested. The code avoids platform-specific paths, but treat 0.1 as Linux/WSL.
188
208
 
189
209
  ## Troubleshooting
package/dist/cli.js CHANGED
@@ -4,11 +4,11 @@
4
4
  import { Command } from "commander";
5
5
  import { createRequire } from "module";
6
6
  import os2 from "os";
7
- import path15 from "path";
7
+ import path17 from "path";
8
8
 
9
9
  // src/adapters/claude-code.ts
10
- import { promises as fs2 } from "fs";
11
- import path2 from "path";
10
+ import { promises as fs4 } from "fs";
11
+ import path4 from "path";
12
12
  import os from "os";
13
13
 
14
14
  // src/installers/claude-plugin.ts
@@ -166,79 +166,14 @@ var pluginInstaller = {
166
166
  }
167
167
  };
168
168
 
169
- // src/adapters/claude-code.ts
170
- var CATEGORIES = [
171
- { name: "skills", root: "skills", kind: "dir" },
172
- { name: "agents", root: "agents", kind: "file" },
173
- { name: "commands", root: "commands", kind: "file" }
174
- // instructions 는 단일 파일(CLAUDE.md)이라 스캔 대상이 아니라 생성 대상이다 (§3.3)
175
- ];
176
- var ClaudeCodeAdapter = class {
177
- name = "claude-code";
178
- root;
179
- constructor(root) {
180
- this.root = root ?? path2.join(os.homedir(), ".claude");
181
- }
182
- categories() {
183
- return CATEGORIES;
184
- }
185
- instructionsStrategy() {
186
- return "import";
187
- }
188
- instructionsFileName() {
189
- return "CLAUDE.md";
190
- }
191
- installers() {
192
- return [marketplaceInstaller, pluginInstaller];
193
- }
194
- async scan() {
195
- const out = [];
196
- for (const cat of CATEGORIES) {
197
- const dir = path2.join(this.root, cat.root);
198
- let entries;
199
- try {
200
- entries = await fs2.readdir(dir, { withFileTypes: true });
201
- } catch {
202
- continue;
203
- }
204
- for (const e of entries) {
205
- if (e.name.startsWith(".")) continue;
206
- const full = path2.join(dir, e.name);
207
- let st;
208
- try {
209
- st = await fs2.stat(full);
210
- } catch {
211
- continue;
212
- }
213
- if (cat.kind === "dir" && st.isDirectory()) {
214
- out.push({ category: cat.name, id: e.name, path: full });
215
- } else if (cat.kind === "file" && st.isFile() && e.name.endsWith(".md")) {
216
- out.push({ category: cat.name, id: e.name.slice(0, -3), path: full });
217
- }
218
- }
219
- }
220
- return out;
221
- }
222
- };
223
-
224
- // src/core/context.ts
225
- import { promises as fs6 } from "fs";
226
- import path8 from "path";
227
- import { spawn as spawn2 } from "child_process";
228
-
229
- // src/installers/git.ts
230
- import { promises as fs4 } from "fs";
231
- import path5 from "path";
232
-
233
- // src/git.ts
234
- import { execFile, spawn } from "child_process";
235
- import { promisify } from "util";
236
- import path4 from "path";
169
+ // src/adapters/claude-mcp.ts
170
+ import { promises as fs3 } from "fs";
171
+ import path3 from "path";
237
172
 
238
173
  // src/fsutil.ts
239
- import { promises as fs3 } from "fs";
174
+ import { promises as fs2 } from "fs";
240
175
  import { createHash } from "crypto";
241
- import path3 from "path";
176
+ import path2 from "path";
242
177
 
243
178
  // src/ignore.ts
244
179
  var DEFAULT_IGNORE = [
@@ -263,7 +198,7 @@ function isIgnored(rel, patterns) {
263
198
  // src/fsutil.ts
264
199
  async function exists(p) {
265
200
  try {
266
- await fs3.access(p);
201
+ await fs2.access(p);
267
202
  return true;
268
203
  } catch {
269
204
  return false;
@@ -271,7 +206,7 @@ async function exists(p) {
271
206
  }
272
207
  async function isDir(p) {
273
208
  try {
274
- return (await fs3.stat(p)).isDirectory();
209
+ return (await fs2.stat(p)).isDirectory();
275
210
  } catch {
276
211
  return false;
277
212
  }
@@ -280,48 +215,48 @@ async function listFiles(root, ignore = DEFAULT_IGNORE) {
280
215
  if (!await exists(root)) return [];
281
216
  if (!await isDir(root)) return [""];
282
217
  const out = [];
283
- async function walk(dir, rel) {
284
- const entries = await fs3.readdir(dir, { withFileTypes: true });
218
+ async function walk2(dir, rel) {
219
+ const entries = await fs2.readdir(dir, { withFileTypes: true });
285
220
  for (const e of entries) {
286
221
  const r = rel ? `${rel}/${e.name}` : e.name;
287
222
  if (isIgnored(r, ignore)) continue;
288
- const full = path3.join(dir, e.name);
223
+ const full = path2.join(dir, e.name);
289
224
  let st;
290
225
  try {
291
- st = await fs3.stat(full);
226
+ st = await fs2.stat(full);
292
227
  } catch {
293
228
  continue;
294
229
  }
295
- if (st.isDirectory()) await walk(full, r);
230
+ if (st.isDirectory()) await walk2(full, r);
296
231
  else if (st.isFile()) out.push(r);
297
232
  }
298
233
  }
299
- await walk(root, "");
234
+ await walk2(root, "");
300
235
  return out.sort();
301
236
  }
302
237
  async function hashFile(p) {
303
- return createHash("sha256").update(await fs3.readFile(p)).digest("hex");
238
+ return createHash("sha256").update(await fs2.readFile(p)).digest("hex");
304
239
  }
305
240
  async function hashTree(root, ignore = DEFAULT_IGNORE) {
306
241
  if (!await exists(root)) return null;
307
242
  const h = createHash("sha256");
308
243
  for (const rel of await listFiles(root, ignore)) {
309
- h.update(rel).update("\0").update(await fs3.readFile(rel ? path3.join(root, rel) : root)).update("\0");
244
+ h.update(rel).update("\0").update(await fs2.readFile(rel ? path2.join(root, rel) : root)).update("\0");
310
245
  }
311
246
  return h.digest("hex");
312
247
  }
313
248
  async function copyTree(src, dst, ignore = DEFAULT_IGNORE) {
314
- await fs3.rm(dst, { recursive: true, force: true });
315
- await fs3.mkdir(path3.dirname(dst), { recursive: true });
316
- const srcRoot = path3.resolve(src);
317
- await fs3.cp(src, dst, {
249
+ await fs2.rm(dst, { recursive: true, force: true });
250
+ await fs2.mkdir(path2.dirname(dst), { recursive: true });
251
+ const srcRoot = path2.resolve(src);
252
+ await fs2.cp(src, dst, {
318
253
  recursive: true,
319
254
  dereference: true,
320
255
  filter: async (from) => {
321
- const rel = path3.relative(srcRoot, path3.resolve(from)).split(path3.sep).join("/");
256
+ const rel = path2.relative(srcRoot, path2.resolve(from)).split(path2.sep).join("/");
322
257
  if (isIgnored(rel, ignore)) return false;
323
258
  try {
324
- if ((await fs3.lstat(from)).isSymbolicLink()) await fs3.stat(from);
259
+ if ((await fs2.lstat(from)).isSymbolicLink()) await fs2.stat(from);
325
260
  } catch {
326
261
  return false;
327
262
  }
@@ -330,15 +265,15 @@ async function copyTree(src, dst, ignore = DEFAULT_IGNORE) {
330
265
  });
331
266
  }
332
267
  async function removeTree(p) {
333
- await fs3.rm(p, { recursive: true, force: true });
268
+ await fs2.rm(p, { recursive: true, force: true });
334
269
  }
335
270
  async function diffTrees(local, shed, ignore = DEFAULT_IGNORE) {
336
271
  const l = new Set(await listFiles(local, ignore));
337
272
  const s = new Set(await listFiles(shed, ignore));
338
273
  const out = [];
339
274
  for (const f of [.../* @__PURE__ */ new Set([...l, ...s])].sort()) {
340
- const lp = f ? path3.join(local, f) : local;
341
- const sp = f ? path3.join(shed, f) : shed;
275
+ const lp = f ? path2.join(local, f) : local;
276
+ const sp = f ? path2.join(shed, f) : shed;
342
277
  if (l.has(f) && !s.has(f)) out.push({ status: "A", file: f });
343
278
  else if (!l.has(f) && s.has(f)) out.push({ status: "D", file: f });
344
279
  else if (await hashFile(lp) !== await hashFile(sp)) out.push({ status: "M", file: f });
@@ -346,13 +281,127 @@ async function diffTrees(local, shed, ignore = DEFAULT_IGNORE) {
346
281
  return out;
347
282
  }
348
283
 
284
+ // src/adapters/claude-mcp.ts
285
+ var ClaudeMcpEntries = class {
286
+ constructor(root) {
287
+ this.root = root;
288
+ }
289
+ root;
290
+ name = "mcp";
291
+ kind = "entry";
292
+ secretKeys = ["env", "headers"];
293
+ expandsEnv = true;
294
+ /** ~/.claude 의 형제 ~/.claude.json. CLAUDE_CONFIG_DIR 처럼 루트 안에 있으면 그것을 쓴다. */
295
+ async file() {
296
+ const inside = path3.join(this.root, ".claude.json");
297
+ return await exists(inside) ? inside : `${this.root}.json`;
298
+ }
299
+ async load() {
300
+ const p = await this.file();
301
+ try {
302
+ return JSON.parse(await fs3.readFile(p, "utf8"));
303
+ } catch (e) {
304
+ if (e.code === "ENOENT") return {};
305
+ throw new Error(`${p} \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${e.message}`);
306
+ }
307
+ }
308
+ async read() {
309
+ const servers = (await this.load()).mcpServers;
310
+ return servers && typeof servers === "object" ? { ...servers } : {};
311
+ }
312
+ async write(id, value) {
313
+ const p = await this.file();
314
+ const all = await this.load();
315
+ const servers = { ...all.mcpServers ?? {} };
316
+ if (value === null) delete servers[id];
317
+ else servers[id] = value;
318
+ all.mcpServers = servers;
319
+ await fs3.mkdir(path3.dirname(p), { recursive: true });
320
+ const tmp = `${p}.lshed-${process.pid}.tmp`;
321
+ await fs3.writeFile(tmp, JSON.stringify(all, null, 2) + "\n");
322
+ await fs3.rename(tmp, p);
323
+ }
324
+ };
325
+
326
+ // src/adapters/claude-code.ts
327
+ var CATEGORIES = [
328
+ { name: "skills", root: "skills", kind: "dir" },
329
+ { name: "agents", root: "agents", kind: "file" },
330
+ { name: "commands", root: "commands", kind: "file" }
331
+ // instructions 는 단일 파일(CLAUDE.md)이라 스캔 대상이 아니라 생성 대상이다 (§3.3)
332
+ ];
333
+ var ClaudeCodeAdapter = class {
334
+ name = "claude-code";
335
+ root;
336
+ mcp;
337
+ constructor(root) {
338
+ this.root = root ?? process.env.CLAUDE_CONFIG_DIR ?? path4.join(os.homedir(), ".claude");
339
+ this.mcp = new ClaudeMcpEntries(this.root);
340
+ }
341
+ entries() {
342
+ return [this.mcp];
343
+ }
344
+ categories() {
345
+ return CATEGORIES;
346
+ }
347
+ instructionsStrategy() {
348
+ return "import";
349
+ }
350
+ instructionsFileName() {
351
+ return "CLAUDE.md";
352
+ }
353
+ installers() {
354
+ return [marketplaceInstaller, pluginInstaller];
355
+ }
356
+ async scan() {
357
+ const out = [];
358
+ for (const cat of CATEGORIES) {
359
+ const dir = path4.join(this.root, cat.root);
360
+ let entries;
361
+ try {
362
+ entries = await fs4.readdir(dir, { withFileTypes: true });
363
+ } catch {
364
+ continue;
365
+ }
366
+ for (const e of entries) {
367
+ if (e.name.startsWith(".")) continue;
368
+ const full = path4.join(dir, e.name);
369
+ let st;
370
+ try {
371
+ st = await fs4.stat(full);
372
+ } catch {
373
+ continue;
374
+ }
375
+ if (cat.kind === "dir" && st.isDirectory()) {
376
+ out.push({ category: cat.name, id: e.name, path: full });
377
+ } else if (cat.kind === "file" && st.isFile() && e.name.endsWith(".md")) {
378
+ out.push({ category: cat.name, id: e.name.slice(0, -3), path: full });
379
+ }
380
+ }
381
+ }
382
+ return out;
383
+ }
384
+ };
385
+
386
+ // src/core/context.ts
387
+ import { promises as fs7 } from "fs";
388
+ import path9 from "path";
389
+ import { spawn as spawn2 } from "child_process";
390
+
391
+ // src/installers/git.ts
392
+ import { promises as fs5 } from "fs";
393
+ import path6 from "path";
394
+
349
395
  // src/git.ts
396
+ import { execFile, spawn } from "child_process";
397
+ import { promisify } from "util";
398
+ import path5 from "path";
350
399
  var x = promisify(execFile);
351
400
  async function git(args, cwd) {
352
401
  const { stdout } = await x("git", args, { cwd, maxBuffer: 1 << 24 });
353
402
  return stdout.trim();
354
403
  }
355
- var isRepo = (dir) => exists(path4.join(dir, ".git"));
404
+ var isRepo = (dir) => exists(path5.join(dir, ".git"));
356
405
  var remoteUrl = (dir) => git(["remote", "get-url", "origin"], dir).catch(() => null);
357
406
  var head = (dir) => git(["rev-parse", "HEAD"], dir);
358
407
  var branch = async (dir) => {
@@ -373,7 +422,7 @@ function runShell(cmd, cwd) {
373
422
  // src/installers/git.ts
374
423
  function dirOf(ctx, pkg) {
375
424
  if (!pkg.into) throw new Error(`package ${pkg.id}: git \uACC4\uC5F4 \uD328\uD0A4\uC9C0\uB294 into \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4`);
376
- return path5.join(ctx.adapter.root, ...pkg.into.split("/"));
425
+ return path6.join(ctx.adapter.root, ...pkg.into.split("/"));
377
426
  }
378
427
  var gitInstaller = {
379
428
  name: "git",
@@ -385,7 +434,7 @@ var gitInstaller = {
385
434
  if (!await isRepo(f.path)) continue;
386
435
  const url = await remoteUrl(f.path);
387
436
  if (!url) continue;
388
- const into = path5.relative(ctx.adapter.root, f.path).split(path5.sep).join("/");
437
+ const into = path6.relative(ctx.adapter.root, f.path).split(path6.sep).join("/");
389
438
  out.push({ id: f.id, into, source: sourceFromRemote(url, await branch(f.path)), rev: await head(f.path), path: f.path });
390
439
  }
391
440
  return out;
@@ -399,7 +448,7 @@ var gitInstaller = {
399
448
  const dir = dirOf(ctx, pkg);
400
449
  if (await exists(dir)) throw new Error(`package ${pkg.id}: ${dir} \uAC00 \uC788\uC9C0\uB9CC git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2D9\uB2C8\uB2E4. \uCE58\uC6B0\uAC70\uB098 into \uB97C \uBC14\uAFB8\uC138\uC694.`);
401
450
  const { url, ref } = cloneTarget(parseSource(pkg.source));
402
- await fs4.mkdir(path5.dirname(dir), { recursive: true });
451
+ await fs5.mkdir(path6.dirname(dir), { recursive: true });
403
452
  await clone(url, dir, ref);
404
453
  if (locked) {
405
454
  await resetHard(dir, locked).catch(() => {
@@ -506,7 +555,8 @@ ${problems.map((p) => " " + p).join("\n")}`);
506
555
  return m;
507
556
  }
508
557
  function effectiveSource(category, c, kind = "dir") {
509
- return c.source ?? `file:./${category}/${c.id}${kind === "file" ? ".md" : ""}`;
558
+ const ext = kind === "file" ? ".md" : kind === "entry" ? ".json" : "";
559
+ return c.source ?? `file:./${category}/${c.id}${ext}`;
510
560
  }
511
561
  function stringifyManifest(m) {
512
562
  const out = { ...m };
@@ -520,10 +570,10 @@ function packagesOf(m, profile) {
520
570
  }
521
571
 
522
572
  // src/resolvers/file.ts
523
- import path6 from "path";
573
+ import path7 from "path";
524
574
  function resolveSource(shed, raw) {
525
575
  const s = parseSource(raw);
526
- if (s.scheme === "file") return path6.resolve(shed, s.path);
576
+ if (s.scheme === "file") return path7.resolve(shed, s.path);
527
577
  throw new Error(`"${raw}": ${s.scheme}: \uCD9C\uCC98\uB294 v0.2\uC5D0\uC11C \uC9C0\uC6D0\uB429\uB2C8\uB2E4. \uC9C0\uAE08\uC740 file: \uB9CC \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.`);
528
578
  }
529
579
  function isSaveable(raw) {
@@ -531,8 +581,8 @@ function isSaveable(raw) {
531
581
  }
532
582
 
533
583
  // src/state.ts
534
- import { promises as fs5 } from "fs";
535
- import path7 from "path";
584
+ import { promises as fs6 } from "fs";
585
+ import path8 from "path";
536
586
  import { z as z2 } from "zod";
537
587
  var StateSchema = z2.object({
538
588
  profile: z2.string(),
@@ -543,11 +593,11 @@ var StateSchema = z2.object({
543
593
  });
544
594
  var LSHED_DIR = "lshed";
545
595
  function statePath(adapter) {
546
- return path7.join(adapter.root, LSHED_DIR, "state.json");
596
+ return path8.join(adapter.root, LSHED_DIR, "state.json");
547
597
  }
548
598
  async function readState(adapter) {
549
599
  try {
550
- const raw = JSON.parse(await fs5.readFile(statePath(adapter), "utf8"));
600
+ const raw = JSON.parse(await fs6.readFile(statePath(adapter), "utf8"));
551
601
  return StateSchema.parse(raw);
552
602
  } catch (e) {
553
603
  if (e.code === "ENOENT") return null;
@@ -556,8 +606,8 @@ async function readState(adapter) {
556
606
  }
557
607
  async function writeState(adapter, state) {
558
608
  const p = statePath(adapter);
559
- await fs5.mkdir(path7.dirname(p), { recursive: true });
560
- await fs5.writeFile(p, JSON.stringify(state, null, 2) + "\n");
609
+ await fs6.mkdir(path8.dirname(p), { recursive: true });
610
+ await fs6.writeFile(p, JSON.stringify(state, null, 2) + "\n");
561
611
  }
562
612
 
563
613
  // src/core/context.ts
@@ -582,15 +632,15 @@ var MANIFEST_FILE = "lshed.yaml";
582
632
  var INSTRUCTIONS = "instructions";
583
633
  var FRAGMENTS_DIR = `${LSHED_DIR}/instructions`;
584
634
  function manifestPath(ctx) {
585
- return path8.join(ctx.shed, MANIFEST_FILE);
635
+ return path9.join(ctx.shed, MANIFEST_FILE);
586
636
  }
587
637
  function knownCategories(adapter) {
588
- return [...adapter.categories().map((c) => c.name), INSTRUCTIONS];
638
+ return [...adapter.categories().map((c) => c.name), ...adapter.entries().map((e) => e.name), INSTRUCTIONS];
589
639
  }
590
640
  async function loadManifest(ctx) {
591
641
  let text;
592
642
  try {
593
- text = await fs6.readFile(manifestPath(ctx), "utf8");
643
+ text = await fs7.readFile(manifestPath(ctx), "utf8");
594
644
  } catch {
595
645
  throw new Error(`\uCC3D\uACE0\uC5D0 ${MANIFEST_FILE} \uC774 \uC5C6\uC2B5\uB2C8\uB2E4: ${ctx.shed}
596
646
  \uBA3C\uC800 'lshed init --shed ${ctx.shed}' \uB97C \uC2E4\uD589\uD558\uC138\uC694.`);
@@ -604,12 +654,22 @@ function ignoreOf(ctx) {
604
654
  }
605
655
  function targetRel(cat, id) {
606
656
  if (cat === INSTRUCTIONS) return `${FRAGMENTS_DIR}/${id}.md`;
657
+ if (cat.kind === "entry") return `${cat.name}:${id}`;
607
658
  return cat.kind === "dir" ? `${cat.root}/${id}` : `${cat.root}/${id}.md`;
608
659
  }
660
+ function entryOf(ctx, rel) {
661
+ const i = rel.indexOf(":");
662
+ if (i <= 0) return void 0;
663
+ const cat = ctx.adapter.entries().find((e) => e.name === rel.slice(0, i));
664
+ return cat ? { cat, id: rel.slice(i + 1) } : void 0;
665
+ }
666
+ function kindOf(ctx, category) {
667
+ if (category === INSTRUCTIONS) return "file";
668
+ if (ctx.adapter.entries().some((e) => e.name === category)) return "entry";
669
+ return ctx.adapter.categories().find((k) => k.name === category)?.kind ?? "dir";
670
+ }
609
671
  function sourcePath(ctx, category, c) {
610
- const cat = ctx.adapter.categories().find((k) => k.name === category);
611
- const kind = category === INSTRUCTIONS ? "file" : cat?.kind ?? "dir";
612
- return resolveSource(ctx.shed, effectiveSource(category, c, kind));
672
+ return resolveSource(ctx.shed, effectiveSource(category, c, kindOf(ctx, category)));
613
673
  }
614
674
  function findComponent(m, category, id) {
615
675
  const c = (m.components[category] ?? []).find((x2) => x2.id === id);
@@ -625,28 +685,29 @@ function planProfile(ctx, m, profile) {
625
685
  const items = [];
626
686
  for (const [category, ids] of Object.entries(p)) {
627
687
  if (category === PACKAGES) continue;
628
- const cat = category === INSTRUCTIONS ? INSTRUCTIONS : ctx.adapter.categories().find((k) => k.name === category);
688
+ const cat = category === INSTRUCTIONS ? INSTRUCTIONS : ctx.adapter.categories().find((k) => k.name === category) ?? ctx.adapter.entries().find((e) => e.name === category);
629
689
  if (!cat) throw new Error(`\uD504\uB85C\uD544 "${profile}": \uC5B4\uB311\uD130 ${ctx.adapter.name} \uC740 \uCE74\uD14C\uACE0\uB9AC "${category}" \uB97C \uBAA8\uB985\uB2C8\uB2E4`);
690
+ const entry = cat !== INSTRUCTIONS && cat.kind === "entry" ? cat : void 0;
630
691
  for (const id of ids) {
631
692
  const component = findComponent(m, category, id);
632
- items.push({ category, id, rel: targetRel(cat, id), src: sourcePath(ctx, category, component), component });
693
+ items.push({ category, id, rel: targetRel(cat, id), src: sourcePath(ctx, category, component), component, entry });
633
694
  }
634
695
  }
635
696
  return items;
636
697
  }
637
698
  function abs(ctx, rel) {
638
- return path8.join(ctx.adapter.root, ...rel.split("/"));
699
+ return path9.join(ctx.adapter.root, ...rel.split("/"));
639
700
  }
640
701
 
641
702
  // src/core/init.ts
642
- import { promises as fs9 } from "fs";
643
- import path12 from "path";
703
+ import { promises as fs11 } from "fs";
704
+ import path14 from "path";
644
705
 
645
706
  // src/core/instructions.ts
646
- import path9 from "path";
707
+ import path10 from "path";
647
708
  var MARKER = "<!-- generated by lshed";
648
709
  function instructionsFile(ctx) {
649
- return path9.join(ctx.adapter.root, ctx.adapter.instructionsFileName());
710
+ return path10.join(ctx.adapter.root, ctx.adapter.instructionsFileName());
650
711
  }
651
712
  function isGenerated(text) {
652
713
  return text.trimStart().startsWith(MARKER);
@@ -665,12 +726,12 @@ ${f.content.trimEnd()}
665
726
  }
666
727
 
667
728
  // src/core/packages.ts
668
- import { promises as fs8 } from "fs";
669
- import path11 from "path";
729
+ import { promises as fs9 } from "fs";
730
+ import path12 from "path";
670
731
 
671
732
  // src/lock.ts
672
- import { promises as fs7 } from "fs";
673
- import path10 from "path";
733
+ import { promises as fs8 } from "fs";
734
+ import path11 from "path";
674
735
  import YAML2 from "yaml";
675
736
  import { z as z3 } from "zod";
676
737
  var EntrySchema = z3.object({ source: z3.string(), rev: z3.string().optional(), commit: z3.string().optional() }).transform((e) => ({ source: e.source, rev: e.rev ?? e.commit ?? "" }));
@@ -681,7 +742,7 @@ var LockSchema = z3.object({
681
742
  var LOCK_FILE = "lshed.lock";
682
743
  async function readLock(shed) {
683
744
  try {
684
- return LockSchema.parse(YAML2.parse(await fs7.readFile(path10.join(shed, LOCK_FILE), "utf8")));
745
+ return LockSchema.parse(YAML2.parse(await fs8.readFile(path11.join(shed, LOCK_FILE), "utf8")));
685
746
  } catch (e) {
686
747
  if (e.code === "ENOENT") return { version: 1, packages: {} };
687
748
  throw new Error(`${LOCK_FILE} \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${e.message}`);
@@ -689,7 +750,7 @@ async function readLock(shed) {
689
750
  }
690
751
  async function writeLock(shed, lock) {
691
752
  const sorted = { version: 1, packages: Object.fromEntries(Object.entries(lock.packages).sort()) };
692
- await fs7.writeFile(path10.join(shed, LOCK_FILE), "# generated by lshed \u2014 do not edit; 'lshed update' refreshes it\n" + YAML2.stringify(sorted));
753
+ await fs8.writeFile(path11.join(shed, LOCK_FILE), "# generated by lshed \u2014 do not edit; 'lshed update' refreshes it\n" + YAML2.stringify(sorted));
693
754
  }
694
755
 
695
756
  // src/core/packages.ts
@@ -702,26 +763,26 @@ async function detectGenerated(found, pkgs) {
702
763
  const out = /* @__PURE__ */ new Map();
703
764
  const located = pkgs.filter((p) => p.path);
704
765
  if (!located.length) return out;
705
- const roots = await Promise.all(located.map(async (p) => ({ id: p.id, real: await fs8.realpath(p.path) })));
766
+ const roots = await Promise.all(located.map(async (p) => ({ id: p.id, real: await fs9.realpath(p.path) })));
706
767
  for (const f of found) {
707
768
  if (located.some((p) => p.path === f.path)) continue;
708
769
  let entries;
709
770
  try {
710
- if (!(await fs8.stat(f.path)).isDirectory()) continue;
711
- entries = await fs8.readdir(f.path);
771
+ if (!(await fs9.stat(f.path)).isDirectory()) continue;
772
+ entries = await fs9.readdir(f.path);
712
773
  } catch {
713
774
  continue;
714
775
  }
715
776
  for (const name of entries) {
716
- const p = path11.join(f.path, name);
777
+ const p = path12.join(f.path, name);
717
778
  let target;
718
779
  try {
719
- if (!(await fs8.lstat(p)).isSymbolicLink()) continue;
720
- target = await fs8.realpath(p).catch(async () => path11.resolve(f.path, await fs8.readlink(p)));
780
+ if (!(await fs9.lstat(p)).isSymbolicLink()) continue;
781
+ target = await fs9.realpath(p).catch(async () => path12.resolve(f.path, await fs9.readlink(p)));
721
782
  } catch {
722
783
  continue;
723
784
  }
724
- const owner = roots.find((r) => target === r.real || target.startsWith(r.real + path11.sep));
785
+ const owner = roots.find((r) => target === r.real || target.startsWith(r.real + path12.sep));
725
786
  if (owner) {
726
787
  out.set(`${f.category}/${f.id}`, owner.id);
727
788
  break;
@@ -766,7 +827,7 @@ async function ensurePackages(ctx, pkgs, opts = {}) {
766
827
  async function maybeInstall(ctx, pkg, dir, opts, res) {
767
828
  if (!pkg.install) return;
768
829
  if (opts.yes) {
769
- ctx.log(` $ (${path11.relative(ctx.adapter.root, dir) || "."}) ${pkg.install}`);
830
+ ctx.log(` $ (${path12.relative(ctx.adapter.root, dir) || "."}) ${pkg.install}`);
770
831
  await runShell(pkg.install, dir);
771
832
  } else {
772
833
  res.pendingInstalls.push({ id: pkg.id, dir, cmd: pkg.install });
@@ -807,6 +868,132 @@ async function updatePackages(ctx, pkgs, opts = {}) {
807
868
  return res;
808
869
  }
809
870
 
871
+ // src/core/entries.ts
872
+ import { promises as fs10 } from "fs";
873
+ import path13 from "path";
874
+ var SECRET_KEY_RE = /key|token|secret|pass|auth|credential|cookie|session/i;
875
+ var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
876
+ function placeholdersIn(v) {
877
+ const out = /* @__PURE__ */ new Set();
878
+ walk(v, (s) => {
879
+ for (const m of s.matchAll(PLACEHOLDER_RE)) out.add(m[1]);
880
+ return s;
881
+ });
882
+ return [...out];
883
+ }
884
+ function walk(v, onString, keyPath = []) {
885
+ if (typeof v === "string") return onString(v, keyPath);
886
+ if (Array.isArray(v)) return v.map((x2, i) => walk(x2, onString, [...keyPath, String(i)]));
887
+ if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x2]) => [k, walk(x2, onString, [...keyPath, k])]));
888
+ return v;
889
+ }
890
+ var envName = (...parts) => parts.join("_").replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase();
891
+ function mask(id, entry, cat) {
892
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry;
893
+ const out = { ...entry };
894
+ for (const sk of cat.secretKeys) {
895
+ const sect = out[sk];
896
+ if (!sect || typeof sect !== "object" || Array.isArray(sect)) continue;
897
+ const masked = {};
898
+ for (const [k, v] of Object.entries(sect)) {
899
+ if (typeof v !== "string" || !SECRET_KEY_RE.test(k) || PLACEHOLDER_RE.test(v)) {
900
+ masked[k] = v;
901
+ PLACEHOLDER_RE.lastIndex = 0;
902
+ continue;
903
+ }
904
+ const name = sk === "env" ? envName(k) : envName(id, k);
905
+ const scheme = /^(\w+) \S+$/.exec(v);
906
+ masked[k] = scheme ? `${scheme[1]} \${${name}}` : `\${${name}}`;
907
+ }
908
+ out[sk] = masked;
909
+ }
910
+ return out;
911
+ }
912
+ function suspiciousStrings(entry) {
913
+ const out = [];
914
+ walk(entry, (s, kp) => {
915
+ if (/^(sk-|ghp_|github_pat_|xox[abp]-|AKIA|glpat-|ntn_|secret_)[A-Za-z0-9_-]{8,}/.test(s)) out.push(kp.join("."));
916
+ return s;
917
+ });
918
+ return out;
919
+ }
920
+ function expand(entry, env = process.env) {
921
+ const missing = /* @__PURE__ */ new Set();
922
+ const value = walk(
923
+ entry,
924
+ (s) => s.replace(PLACEHOLDER_RE, (whole, name, def) => {
925
+ if (env[name] !== void 0) return env[name];
926
+ if (def !== void 0) return def;
927
+ missing.add(name);
928
+ return whole;
929
+ })
930
+ );
931
+ return { value, missing: [...missing] };
932
+ }
933
+ function stringMatches(shed, local) {
934
+ if (shed === local) return true;
935
+ if (!PLACEHOLDER_RE.test(shed)) {
936
+ PLACEHOLDER_RE.lastIndex = 0;
937
+ return false;
938
+ }
939
+ PLACEHOLDER_RE.lastIndex = 0;
940
+ const re = "^" + shed.split(PLACEHOLDER_RE).map((part, i) => i % 3 === 0 ? part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : i % 3 === 1 ? ".+" : "").join("") + "$";
941
+ return new RegExp(re).test(local);
942
+ }
943
+ function matches2(shed, local) {
944
+ if (typeof shed === "string" && typeof local === "string") return stringMatches(shed, local);
945
+ if (Array.isArray(shed) && Array.isArray(local)) return shed.length === local.length && shed.every((x2, i) => matches2(x2, local[i]));
946
+ if (shed && local && typeof shed === "object" && typeof local === "object" && !Array.isArray(shed) && !Array.isArray(local)) {
947
+ const a = Object.keys(shed).sort(), b = Object.keys(local).sort();
948
+ return a.length === b.length && a.every((k, i) => k === b[i] && matches2(shed[k], local[k]));
949
+ }
950
+ return shed === local;
951
+ }
952
+ function remask(id, local, shed, cat) {
953
+ const keep = (l, s) => {
954
+ if (typeof l === "string" && typeof s === "string" && stringMatches(s, l)) return s;
955
+ if (Array.isArray(l) && Array.isArray(s)) return l.map((x2, i) => keep(x2, s[i]));
956
+ if (l && s && typeof l === "object" && typeof s === "object" && !Array.isArray(l) && !Array.isArray(s)) {
957
+ return Object.fromEntries(Object.entries(l).map(([k, x2]) => [k, keep(x2, s[k])]));
958
+ }
959
+ return l;
960
+ };
961
+ return mask(id, shed === null ? local : keep(local, shed), cat);
962
+ }
963
+ function diffEntry(shed, local) {
964
+ const out = [];
965
+ const go = (s, l, kp) => {
966
+ if (s === void 0) {
967
+ out.push({ status: "A", file: kp || "(entry)" });
968
+ return;
969
+ }
970
+ if (l === void 0) {
971
+ out.push({ status: "D", file: kp || "(entry)" });
972
+ return;
973
+ }
974
+ const objs = s && l && typeof s === "object" && typeof l === "object" && !Array.isArray(s) && !Array.isArray(l);
975
+ if (objs) {
976
+ for (const k of (/* @__PURE__ */ new Set([...Object.keys(s), ...Object.keys(l)])).values()) go(s[k], l[k], kp ? `${kp}.${k}` : k);
977
+ return;
978
+ }
979
+ if (!matches2(s, l)) out.push({ status: "M", file: kp || "(entry)" });
980
+ };
981
+ go(shed, local, "");
982
+ return out.sort((a, b) => a.file.localeCompare(b.file));
983
+ }
984
+ async function readEntryFile(p) {
985
+ try {
986
+ return JSON.parse(await fs10.readFile(p, "utf8"));
987
+ } catch (e) {
988
+ if (e.code === "ENOENT") return null;
989
+ throw new Error(`${p}: JSON \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${e.message}`);
990
+ }
991
+ }
992
+ async function writeEntryFile(p, v) {
993
+ await fs10.mkdir(path13.dirname(p), { recursive: true });
994
+ await fs10.writeFile(p, JSON.stringify(v, null, 2) + "\n");
995
+ }
996
+
810
997
  // src/core/init.ts
811
998
  async function init(ctx, opts = {}) {
812
999
  const profileName = opts.profile ?? "default";
@@ -841,7 +1028,7 @@ async function init(ctx, opts = {}) {
841
1028
  m.components[cat.name] = [];
842
1029
  m.profiles[profileName][cat.name] = [];
843
1030
  for (const f of mine) {
844
- const dst = path12.join(ctx.shed, cat.root, cat.kind === "dir" ? f.id : `${f.id}.md`);
1031
+ const dst = path14.join(ctx.shed, cat.root, cat.kind === "dir" ? f.id : `${f.id}.md`);
845
1032
  await copyTree(f.path, dst, ignoreOf(ctx));
846
1033
  m.components[cat.name].push({ id: f.id });
847
1034
  m.profiles[profileName][cat.name].push(f.id);
@@ -850,23 +1037,50 @@ async function init(ctx, opts = {}) {
850
1037
  ctx.log(` + ${cat.name}/${f.id}`);
851
1038
  }
852
1039
  }
1040
+ for (const cat of ctx.adapter.entries()) {
1041
+ const all2 = await cat.read();
1042
+ const ids = Object.keys(all2).sort();
1043
+ for (const id of ids.filter((id2) => isExcluded(cat.name, id2))) {
1044
+ skipped.push(`${cat.name}/${id}`);
1045
+ ctx.log(` - ${cat.name}/${id} (--exclude)`);
1046
+ }
1047
+ const mine = ids.filter((id) => !isExcluded(cat.name, id));
1048
+ if (!mine.length) continue;
1049
+ m.components[cat.name] = [];
1050
+ m.profiles[profileName][cat.name] = [];
1051
+ for (const id of mine) {
1052
+ if (!/^[\w.-]+$/.test(id)) {
1053
+ ctx.log(` ! ${cat.name}/${id}: \uC774\uB984\uC5D0 \uC4F8 \uC218 \uC5C6\uB294 \uBB38\uC790\uAC00 \uC788\uC5B4 \uAC74\uB108\uB700`);
1054
+ continue;
1055
+ }
1056
+ const masked = mask(id, all2[id], cat);
1057
+ await writeEntryFile(path14.join(ctx.shed, cat.name, `${id}.json`), masked);
1058
+ m.components[cat.name].push({ id });
1059
+ m.profiles[profileName][cat.name].push(id);
1060
+ managed.push(targetRel(cat, id));
1061
+ copied++;
1062
+ const vars = placeholdersIn(masked);
1063
+ ctx.log(` + ${cat.name}/${id}${vars.length ? ` (\uC2DC\uD06C\uB9BF \u2192 ${vars.map((v) => "${" + v + "}").join(", ")})` : ""}`);
1064
+ for (const where of suspiciousStrings(masked)) ctx.log(` ! ${where} \uAC00 \uC2DC\uD06C\uB9BF\uCC98\uB7FC \uBCF4\uC785\uB2C8\uB2E4. \uCC3D\uACE0\uC758 ${cat.name}/${id}.json \uC5D0\uC11C \${VAR} \uB85C \uBC14\uAFB8\uC138\uC694`);
1065
+ }
1066
+ }
853
1067
  const instr = instructionsFile(ctx);
854
1068
  if (await exists(instr)) {
855
- const text = await fs9.readFile(instr, "utf8");
1069
+ const text = await fs11.readFile(instr, "utf8");
856
1070
  if (!isGenerated(text)) {
857
- const dst = path12.join(ctx.shed, INSTRUCTIONS, "main.md");
858
- await fs9.mkdir(path12.dirname(dst), { recursive: true });
859
- await fs9.writeFile(dst, text);
1071
+ const dst = path14.join(ctx.shed, INSTRUCTIONS, "main.md");
1072
+ await fs11.mkdir(path14.dirname(dst), { recursive: true });
1073
+ await fs11.writeFile(dst, text);
860
1074
  const fragRel = targetRel(INSTRUCTIONS, "main");
861
1075
  await copyTree(dst, abs(ctx, fragRel), ignoreOf(ctx));
862
1076
  managed.push(fragRel);
863
1077
  m.components[INSTRUCTIONS] = [{ id: "main" }];
864
1078
  m.profiles[profileName][INSTRUCTIONS] = ["main"];
865
1079
  copied++;
866
- ctx.log(` + ${INSTRUCTIONS}/main (${path12.basename(instr)})`);
1080
+ ctx.log(` + ${INSTRUCTIONS}/main (${path14.basename(instr)})`);
867
1081
  }
868
1082
  }
869
- await fs9.mkdir(ctx.shed, { recursive: true });
1083
+ await fs11.mkdir(ctx.shed, { recursive: true });
870
1084
  let yamlText = stringifyManifest(m);
871
1085
  for (const p of pkgs) {
872
1086
  if (!p.into) continue;
@@ -875,7 +1089,7 @@ async function init(ctx, opts = {}) {
875
1089
  # install: ./setup # \u2190 \uBCF5\uC6D0 \uD6C4 \uC2E4\uD589\uD560 \uBA85\uB839\uC774 \uC788\uC73C\uBA74 \uCC44\uC6B0\uC138\uC694 (--yes \uB85C \uC2E4\uD589)
876
1090
  `);
877
1091
  }
878
- await fs9.writeFile(manifestPath(ctx), `# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed
1092
+ await fs11.writeFile(manifestPath(ctx), `# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed
879
1093
  ` + yamlText);
880
1094
  if (pkgs.length) {
881
1095
  await writeLock(ctx.shed, { version: 1, packages: Object.fromEntries(pkgs.map((p) => [p.id, { source: p.source, rev: p.rev }])) });
@@ -892,8 +1106,8 @@ ${MANIFEST_FILE} \uC0DD\uC131: ${manifestPath(ctx)} (${parts.join(", ")}, \uD50
892
1106
  }
893
1107
 
894
1108
  // src/core/restore.ts
895
- import { promises as fs10 } from "fs";
896
- import path13 from "path";
1109
+ import { promises as fs12 } from "fs";
1110
+ import path15 from "path";
897
1111
  async function restore(ctx, profileArg, opts = {}) {
898
1112
  const backup = opts.backup ?? true;
899
1113
  const state = await readState(ctx.adapter);
@@ -912,22 +1126,59 @@ async function restore(ctx, profileArg, opts = {}) {
912
1126
  const oldManaged = new Set(state?.managed ?? []);
913
1127
  const toRemove = [...oldManaged].filter((r) => !newManaged.has(r)).sort();
914
1128
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
915
- const backupDir = path13.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
1129
+ const backupDir = path15.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
916
1130
  const backedUp = [];
917
1131
  const placed = [];
1132
+ const missingEnv = [];
1133
+ const localEntries = /* @__PURE__ */ new Map();
1134
+ const entriesOf = async (cat) => {
1135
+ if (!localEntries.has(cat.name)) localEntries.set(cat.name, await cat.read());
1136
+ return localEntries.get(cat.name);
1137
+ };
918
1138
  async function backUp(rel) {
1139
+ const en = entryOf(ctx, rel);
1140
+ if (en) {
1141
+ const cur = (await entriesOf(en.cat))[en.id];
1142
+ if (cur === void 0) return;
1143
+ backedUp.push(rel);
1144
+ if (opts.dryRun || !backup) return;
1145
+ await writeEntryFile(path15.join(backupDir, en.cat.name, `${en.id}.json`), cur);
1146
+ return;
1147
+ }
919
1148
  const from = abs(ctx, rel);
920
1149
  if (!await exists(from)) return;
921
1150
  backedUp.push(rel);
922
1151
  if (opts.dryRun || !backup) return;
923
- await copyTree(from, path13.join(backupDir, ...rel.split("/")), ignoreOf(ctx));
1152
+ await copyTree(from, path15.join(backupDir, ...rel.split("/")), ignoreOf(ctx));
924
1153
  }
925
1154
  for (const rel of toRemove) {
926
1155
  ctx.log(` - ${rel}`);
927
1156
  await backUp(rel);
928
- if (!opts.dryRun) await removeTree(abs(ctx, rel));
1157
+ if (opts.dryRun) continue;
1158
+ const en = entryOf(ctx, rel);
1159
+ if (en) await en.cat.write(en.id, null);
1160
+ else await removeTree(abs(ctx, rel));
929
1161
  }
930
1162
  for (const it of plan) {
1163
+ if (it.entry) {
1164
+ const shed = await readEntryFile(it.src);
1165
+ const local = (await entriesOf(it.entry))[it.id];
1166
+ const vars = placeholdersIn(shed);
1167
+ const ex = expand(shed);
1168
+ if (ex.missing.length) missingEnv.push({ rel: it.rel, vars: ex.missing });
1169
+ const value = it.entry.expandsEnv ? shed : ex.value;
1170
+ const same2 = local !== void 0 && matches2(shed, local);
1171
+ const mark2 = same2 ? "=" : local !== void 0 ? "~" : "+";
1172
+ ctx.log(` ${mark2} ${it.rel}${vars.length ? ` (${vars.map((v) => "${" + v + "}").join(", ")})` : ""}`);
1173
+ if (same2) {
1174
+ placed.push(it.rel);
1175
+ continue;
1176
+ }
1177
+ if (local !== void 0) await backUp(it.rel);
1178
+ if (!opts.dryRun) await it.entry.write(it.id, value);
1179
+ placed.push(it.rel);
1180
+ continue;
1181
+ }
931
1182
  const target = abs(ctx, it.rel);
932
1183
  const same = await hashTree(target, ignoreOf(ctx)) === await hashTree(it.src, ignoreOf(ctx));
933
1184
  const mark = same ? "=" : await exists(target) ? "~" : "+";
@@ -943,14 +1194,14 @@ async function restore(ctx, profileArg, opts = {}) {
943
1194
  const instrPath = instructionsFile(ctx);
944
1195
  if (fragments.length) {
945
1196
  const contents = [];
946
- for (const f of fragments) contents.push({ id: f.id, content: await fs10.readFile(f.src, "utf8") });
1197
+ for (const f of fragments) contents.push({ id: f.id, content: await fs12.readFile(f.src, "utf8") });
947
1198
  const rendered = renderInstructions(ctx, profile, contents);
948
- const existing = await exists(instrPath) ? await fs10.readFile(instrPath, "utf8") : null;
1199
+ const existing = await exists(instrPath) ? await fs12.readFile(instrPath, "utf8") : null;
949
1200
  if (existing !== rendered) {
950
1201
  const mark = existing === null ? "+" : "~";
951
1202
  ctx.log(` ${mark} ${instrRel}${existing !== null && !isGenerated(existing) ? " (\uAE30\uC874 \uD30C\uC77C\uC740 lshed \uC0DD\uC131\uBB3C\uC774 \uC544\uB2D8 \u2192 \uBC31\uC5C5)" : ""}`);
952
1203
  if (existing !== null) await backUp(instrRel);
953
- if (!opts.dryRun) await fs10.writeFile(instrPath, rendered);
1204
+ if (!opts.dryRun) await fs12.writeFile(instrPath, rendered);
954
1205
  } else {
955
1206
  ctx.log(` = ${instrRel}`);
956
1207
  }
@@ -960,14 +1211,22 @@ async function restore(ctx, profileArg, opts = {}) {
960
1211
  ctx.log(`
961
1212
  (dry-run) \uBCC0\uACBD \uC5C6\uC74C. \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}, \uBC31\uC5C5 \uC608\uC815 ${backedUp.length}`);
962
1213
  reportPending(ctx, pkgRes);
963
- return { profile, placed, removed: toRemove, backedUp, backupDir: null };
1214
+ reportMissingEnv(ctx, missingEnv);
1215
+ return { profile, placed, removed: toRemove, backedUp, backupDir: null, missingEnv };
964
1216
  }
965
1217
  await writeState(ctx.adapter, { profile, shed: ctx.shed, managed: [...newManaged].sort(), appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
966
1218
  const bdir = backup && backedUp.length ? backupDir : null;
967
1219
  ctx.log(`
968
1220
  \uD504\uB85C\uD544 "${profile}" \uC801\uC6A9: \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}${pkgRes.installed.length ? `, \uD328\uD0A4\uC9C0 \uC124\uCE58 ${pkgRes.installed.length}` : ""}${bdir ? `, \uBC31\uC5C5 ${backedUp.length} \u2192 ${bdir}` : ""}`);
969
1221
  reportPending(ctx, pkgRes);
970
- return { profile, placed, removed: toRemove, backedUp, backupDir: bdir };
1222
+ reportMissingEnv(ctx, missingEnv);
1223
+ return { profile, placed, removed: toRemove, backedUp, backupDir: bdir, missingEnv };
1224
+ }
1225
+ function reportMissingEnv(ctx, missing) {
1226
+ if (!missing.length) return;
1227
+ ctx.log(`
1228
+ \uD658\uACBD\uBCC0\uC218\uAC00 \uC5C6\uB294 \uD56D\uBAA9\uC774 \uC788\uC2B5\uB2C8\uB2E4. \uC2DC\uD06C\uB9BF \uAC12\uC740 \uCC3D\uACE0\uC5D0 \uB2F4\uC9C0 \uC54A\uC73C\uBBC0\uB85C \uC774 \uAE30\uAE30\uC758 \uC178 \uD658\uACBD\uC5D0 \uB123\uC73C\uC138\uC694 (\uC608: ~/.zshrc \uC758 export):`);
1229
+ for (const m of missing) ctx.log(` ${m.rel}: ${m.vars.join(", ")}`);
971
1230
  }
972
1231
 
973
1232
  // src/core/diff.ts
@@ -976,8 +1235,16 @@ async function diff(ctx) {
976
1235
  if (!state) throw new Error("\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 'lshed restore <profile>' \uC744 \uC2E4\uD589\uD558\uC138\uC694.");
977
1236
  const m = await loadManifest(ctx);
978
1237
  const out = [];
1238
+ const localEntries = /* @__PURE__ */ new Map();
979
1239
  for (const item of planProfile(ctx, m, state.profile)) {
980
- const changes = await diffTrees(abs(ctx, item.rel), item.src, ignoreOf(ctx));
1240
+ let changes;
1241
+ if (item.entry) {
1242
+ if (!localEntries.has(item.category)) localEntries.set(item.category, await item.entry.read());
1243
+ const shed = await readEntryFile(item.src);
1244
+ changes = shed === null ? [{ status: "A", file: "(entry)" }] : diffEntry(shed, localEntries.get(item.category)[item.id]);
1245
+ } else {
1246
+ changes = await diffTrees(abs(ctx, item.rel), item.src, ignoreOf(ctx));
1247
+ }
981
1248
  if (changes.length) out.push({ item, changes });
982
1249
  }
983
1250
  return out;
@@ -996,12 +1263,18 @@ function formatDiff(diffs) {
996
1263
  // src/core/status.ts
997
1264
  async function status(ctx) {
998
1265
  const state = await readState(ctx.adapter);
999
- if (!state) return { state: null, drifted: [], packages: [] };
1266
+ if (!state) return { state: null, drifted: [], packages: [], missingEnv: [] };
1000
1267
  const d = await diff(ctx);
1001
1268
  const m = await loadManifest(ctx);
1002
1269
  const lock = await readLock(ctx.shed);
1003
1270
  const packages = await Promise.all(packagesOf(m, state.profile).map((p) => packageStatus(ctx, p, lock)));
1004
- return { state, drifted: d.map((x2) => `${x2.item.category}/${x2.item.id}`), packages };
1271
+ const missingEnv = [];
1272
+ for (const it of planProfile(ctx, m, state.profile).filter((p) => p.entry)) {
1273
+ const shed = await readEntryFile(it.src);
1274
+ const missing = shed === null ? [] : expand(shed).missing;
1275
+ if (missing.length) missingEnv.push({ rel: it.rel, vars: missing });
1276
+ }
1277
+ return { state, drifted: d.map((x2) => `${x2.item.category}/${x2.item.id}`), packages, missingEnv };
1005
1278
  }
1006
1279
  function formatStatus(s, adapterRoot) {
1007
1280
  if (!s.state) return `\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4 (${adapterRoot}).
@@ -1018,6 +1291,7 @@ function formatStatus(s, adapterRoot) {
1018
1291
  const where = !p.present ? "\uC124\uCE58 \uC548 \uB428 \u2192 lshed restore" : !p.locked ? `${short2(p.rev)} (\uB77D \uC5C6\uC74C)` : p.rev === p.locked ? `${short2(p.rev)} = lock` : `${short2(p.rev)} \u2260 lock ${short2(p.locked)} \u2192 lshed update`;
1019
1292
  lines.push(`\uD328\uD0A4\uC9C0 ${p.pkg.id} ${where}`);
1020
1293
  }
1294
+ for (const m of s.missingEnv) lines.push(`\uD658\uACBD\uBCC0\uC218 ${m.rel}: ${m.vars.join(", ")} \uC5C6\uC74C \u2192 \uC178\uC5D0\uC11C export \uD558\uC138\uC694`);
1021
1295
  return lines.join("\n");
1022
1296
  }
1023
1297
 
@@ -1037,13 +1311,27 @@ async function save(ctx, ids = []) {
1037
1311
  });
1038
1312
  }
1039
1313
  const saved = [];
1314
+ const localEntries = /* @__PURE__ */ new Map();
1040
1315
  for (const it of plan) {
1041
- const kind = it.category === INSTRUCTIONS ? "file" : ctx.adapter.categories().find((c) => c.name === it.category).kind;
1042
- const src = effectiveSource(it.category, it.component, kind);
1316
+ const src = effectiveSource(it.category, it.component, kindOf(ctx, it.category));
1043
1317
  if (!isSaveable(src)) {
1044
1318
  ctx.log(` ! ${it.category}/${it.id}: \uC6D0\uACA9 \uCD9C\uCC98(${src})\uB294 save \uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4`);
1045
1319
  continue;
1046
1320
  }
1321
+ if (it.entry) {
1322
+ if (!localEntries.has(it.category)) localEntries.set(it.category, await it.entry.read());
1323
+ const local2 = localEntries.get(it.category)[it.id];
1324
+ if (local2 === void 0) {
1325
+ ctx.log(` ! ${it.category}/${it.id}: \uB85C\uCEEC\uC5D0 \uC5C6\uC74C (\uAC74\uB108\uB700)`);
1326
+ continue;
1327
+ }
1328
+ const shed = await readEntryFile(it.src);
1329
+ if (shed !== null && matches2(shed, local2)) continue;
1330
+ await writeEntryFile(it.src, remask(it.id, local2, shed, it.entry));
1331
+ saved.push(`${it.category}/${it.id}`);
1332
+ ctx.log(` \u2713 ${it.category}/${it.id} \u2192 \uCC3D\uACE0 (\uC2DC\uD06C\uB9BF\uC740 \uC790\uB9AC\uD45C\uC2DC\uC790\uB85C)`);
1333
+ continue;
1334
+ }
1047
1335
  const local = abs(ctx, it.rel);
1048
1336
  if (!await exists(local)) {
1049
1337
  ctx.log(` ! ${it.category}/${it.id}: \uB85C\uCEEC\uC5D0 \uC5C6\uC74C (\uAC74\uB108\uB700)`);
@@ -1083,8 +1371,8 @@ function formatRows(rows, m) {
1083
1371
  }
1084
1372
 
1085
1373
  // src/core/remove.ts
1086
- import { promises as fs11 } from "fs";
1087
- import path14 from "path";
1374
+ import { promises as fs13 } from "fs";
1375
+ import path16 from "path";
1088
1376
  import YAML3, { isSeq, isMap } from "yaml";
1089
1377
  function resolveKey(m, raw) {
1090
1378
  const rows = listRows(m);
@@ -1099,7 +1387,7 @@ async function remove(ctx, raw) {
1099
1387
  const { category, id } = resolveKey(m, raw);
1100
1388
  const users = listRows(m).find((r) => r.category === category && r.id === id).usedBy;
1101
1389
  if (users.length) throw new Error(`${category}/${id} \uB294 \uD504\uB85C\uD544 ${users.join(", ")} \uC774 \uC4F0\uACE0 \uC788\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 \uD504\uB85C\uD544\uC5D0\uC11C \uBE7C\uC138\uC694.`);
1102
- const text = await fs11.readFile(manifestPath(ctx), "utf8");
1390
+ const text = await fs13.readFile(manifestPath(ctx), "utf8");
1103
1391
  const doc = YAML3.parseDocument(text);
1104
1392
  let deleted;
1105
1393
  if (category === PACKAGES) {
@@ -1121,14 +1409,14 @@ async function remove(ctx, raw) {
1121
1409
  seq.delete(idx);
1122
1410
  if (!seq.items.length) doc.deleteIn(["components", category]);
1123
1411
  const src = sourcePath(ctx, category, findComponent(m, category, id));
1124
- const inside = !path14.relative(ctx.shed, src).startsWith("..");
1412
+ const inside = !path16.relative(ctx.shed, src).startsWith("..");
1125
1413
  if (inside && await exists(src)) {
1126
1414
  await removeTree(src);
1127
1415
  deleted = src;
1128
1416
  }
1129
1417
  ctx.log(` - ${category}/${id}${deleted ? "" : " (\uCC3D\uACE0 \uBC16 \uACBD\uB85C\uB77C \uD30C\uC77C\uC740 \uB450\uC5C8\uC74C)"}`);
1130
1418
  }
1131
- await fs11.writeFile(manifestPath(ctx), doc.toString());
1419
+ await fs13.writeFile(manifestPath(ctx), doc.toString());
1132
1420
  return { category, id, deleted };
1133
1421
  }
1134
1422
  async function prune(ctx, opts = {}) {
@@ -1158,16 +1446,16 @@ var { version } = createRequire(import.meta.url)("../package.json");
1158
1446
  var program = new Command().name("lshed").description("Keep your coding-agent harness (skills, agents, commands, instructions) in a shed and restore it anywhere by profile.").version(version).option("--shed <dir>", "shed directory (default: $LSHED_HOME, then the shed recorded by the last restore)").option("--root <dir>", "agent config root (default: ~/.claude)");
1159
1447
  function adapterFromOpts() {
1160
1448
  const { root } = program.opts();
1161
- return new ClaudeCodeAdapter(root ? path15.resolve(root) : void 0);
1449
+ return new ClaudeCodeAdapter(root ? path17.resolve(root) : void 0);
1162
1450
  }
1163
1451
  async function ctxFor(cmd) {
1164
1452
  const adapter = adapterFromOpts();
1165
1453
  const { shed: flag } = program.opts();
1166
1454
  let shed = flag ?? process.env.LSHED_HOME;
1167
1455
  if (!shed && cmd === "other") shed = (await readState(adapter))?.shed;
1168
- if (!shed && cmd === "init") shed = path15.join(os2.homedir(), "lshed");
1456
+ if (!shed && cmd === "init") shed = path17.join(os2.homedir(), "lshed");
1169
1457
  if (!shed) throw new Error("\uCC3D\uACE0 \uC704\uCE58\uB97C \uBAA8\uB985\uB2C8\uB2E4. --shed <dir> \uB610\uB294 LSHED_HOME \uC744 \uC9C0\uC815\uD558\uC138\uC694.");
1170
- return { adapter, shed: path15.resolve(shed), log: (l) => console.log(l), exec: spawnExec };
1458
+ return { adapter, shed: path17.resolve(shed), log: (l) => console.log(l), exec: spawnExec };
1171
1459
  }
1172
1460
  async function run(fn) {
1173
1461
  try {
@@ -1212,7 +1500,7 @@ program.command("status").description("show the applied profile, managed paths a
1212
1500
  const adapter = adapterFromOpts();
1213
1501
  const state = await readState(adapter);
1214
1502
  if (!state) {
1215
- console.log(formatStatus({ state: null, drifted: [], packages: [] }, adapter.root));
1503
+ console.log(formatStatus({ state: null, drifted: [], packages: [], missingEnv: [] }, adapter.root));
1216
1504
  return;
1217
1505
  }
1218
1506
  const ctx = await ctxFor("other");
@@ -1244,6 +1532,11 @@ program.command("scan").description("(debug) list components found in the agent
1244
1532
  const adapter = adapterFromOpts();
1245
1533
  const found = await adapter.scan();
1246
1534
  for (const c of found) console.log(`${c.category}/${c.id} ${c.path}`);
1247
- console.error(`${found.length}\uAC1C \uBC1C\uACAC (root: ${adapter.root})`);
1535
+ let n = found.length;
1536
+ for (const e of adapter.entries()) for (const id of Object.keys(await e.read())) {
1537
+ console.log(`${e.name}/${id} (entry)`);
1538
+ n++;
1539
+ }
1540
+ console.error(`${n}\uAC1C \uBC1C\uACAC (root: ${adapter.root})`);
1248
1541
  }));
1249
1542
  program.parseAsync();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lshed",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Portable harness environment manager for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "leesongheon <leesongheon1209@gmail.com>",