lshed 0.6.0 → 0.7.2

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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.2 — 2026-09-03
4
+
5
+ Joining a machine that already has a harness, found while preparing the Windows check.
6
+
7
+ - `restore` warns before removing parts that a **different** shed had claimed. Running `init` against a scratch shed to look around marks that machine's own parts as managed, so a later restore from the real shed would remove them. They were always backed up, but nothing said why. `lshed scan` is the read-only way to look.
8
+ - No longer crashes with an `EPIPE` stack trace when output is piped into something that exits early (`lshed status | head`).
9
+ - The smoke script now models a machine that already runs the agent: a skill whose name collides with the shed, a skill only that machine has, and its own MCP and settings. It checks that a collision is backed up and replaced, that machine-only parts survive, that nothing is removed, and that `add` pushes them into the shed.
10
+ - README: how to join a machine that already has a setup, and why not to `init` there.
11
+
12
+ ## 0.7.1 — 2026-09-03
13
+
14
+ Groundwork for running on Windows and macOS. Not yet verified on a real machine.
15
+
16
+ - Package `install:` commands run through the platform shell (`sh` or `cmd.exe`) instead of a hard-coded `sh -c`.
17
+ - On Windows, `claude` and other wrappers installed as `.cmd` are found by spawning through the shell.
18
+ - Home-directory paths with backslashes are also rewritten to `${HOME}`.
19
+ - `sync` explains what to do when git has no user identity.
20
+ - `npm run smoke` drives the built CLI through init, restore, add, diff, save, profile switch, list and sync in a temporary directory without touching the real `~/.claude`. Use it on a new OS before trusting a real restore.
21
+ - GitHub Actions matrix: ubuntu, macOS, Windows × Node 20, 22. Tests use directory junctions on Windows so no elevated privileges are needed.
22
+
23
+ ## 0.7.0 — 2026-09-03
24
+
25
+ `settings.json` travels, without merging.
26
+
27
+ - New category `settings` for Claude Code: each top-level key of `~/.claude/settings.json` (`hooks`, `permissions`, `env`, `model`, `theme`, …) is one component, stored as `settings/<key>.json`. `restore` writes only the keys the profile lists; a profile can carry `permissions` and leave `model` to each machine. `enabledPlugins` is skipped because the plugin packages own it.
28
+ - Absolute paths under the home directory are stored as `${HOME}/…` (MCP entries too), so hook commands survive a different home. Claude Code does not expand variables in `settings.json`, so `restore` expands `${HOME}` and other `${VAR}` there from the shell; MCP placeholders are still left for Claude Code.
29
+ - `env` in settings is a secret map: secret-looking keys are masked.
30
+ - The secret heuristic now matches whole words. `CLAUDE_CODE_MAX_OUTPUT_TOKENS` is not a token; `BYPASS_PERMISSIONS` is not a password.
31
+ - `init` and `add` flag an entry whose value points inside a package (a hook a toolkit's installer wrote) and suggest `exclude:`.
32
+ - The MCP adapter became a generic "one JSON key = one entry" store used by both categories.
33
+
3
34
  ## 0.6.0 — 2026-09-03
4
35
 
5
36
  - `lshed sync [-m <msg>] [--no-push] [--dry-run]`: commits everything in the shed, `git pull --rebase`, `git push` (setting the upstream the first time). Without `origin` it only commits. When commits come in it says to run `lshed restore`. On a conflict it aborts the rebase, leaves your commit in place and hands you the git command. It warns first if `diff` shows edits you have not saved, and never runs `save` for you.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # lshed
2
2
 
3
- Keep your coding-agent harness — skills, subagents, commands, instructions, MCP servers — in a **shed**, and restore it on any machine with one command.
3
+ Keep your coding-agent harness — skills, subagents, commands, instructions, MCP servers, settings — in a **shed**, and restore it on any machine with one command.
4
4
 
5
5
  ```
6
6
  lshed init --shed ~/lshed # scan ~/.claude into a shed + write lshed.yaml
@@ -17,7 +17,7 @@ lshed adds three first-class ideas on top of "a directory in git":
17
17
 
18
18
  | Idea | What it gives you |
19
19
  |---|---|
20
- | **Components** | every skill / agent / command / instruction fragment / MCP server is one named part in the shed |
20
+ | **Components** | every skill / agent / command / instruction fragment / MCP server / settings key is one named part in the shed |
21
21
  | **Profiles** | named recipes — `research`, `work`, `minimal` — that pick a subset of parts |
22
22
  | **Managed set** | lshed remembers what it placed, so switching profiles removes only its own files and never touches yours |
23
23
 
@@ -89,6 +89,31 @@ lshed sync # commit the shed, pull, push
89
89
 
90
90
  `save` is the only path from `~/.claude` to the shed, and it only works for parts the shed owns (`file:` sources). `sync` warns if you have unsaved edits so you do not push a shed that is behind your machine.
91
91
 
92
+ ### A machine that already has a setup
93
+
94
+ The common case is not an empty machine: it already runs the agent and has skills, settings and MCP servers of its own. `restore` is built for that. With no prior lshed state it **removes nothing** — it places what the profile lists, and anything it overwrites goes to `~/.claude/lshed/backups/<timestamp>/` first. Parts that exist only on that machine are untouched.
95
+
96
+ ```
97
+ $ lshed restore default --shed ~/lshed --dry-run
98
+ + skills/mine
99
+ ~ skills/shared # same name, different content → backed up, then replaced
100
+ + mcp:exa (${EXA_API_KEY})
101
+ ~ settings:model
102
+ (dry-run) 변경 없음. 배치 5, 제거 0, 백업 예정 3
103
+ ```
104
+
105
+ Always run `--dry-run` first. `+` is new, `~` replaces with a backup, `-` removes with a backup. If the plan looks right, drop the flag.
106
+
107
+ Then push that machine's own parts up into the shed and both machines have everything:
108
+
109
+ ```
110
+ lshed add # lists what this machine has that the shed does not
111
+ lshed add windows-only mcp/my-local-server
112
+ lshed sync
113
+ ```
114
+
115
+ Do not run `init` on such a machine just to look around. `init` claims what it finds as lshed-managed, so a later `restore` from your real shed would treat those parts as removable (backed up, but removed). Use `lshed scan`, which only prints. If you do it anyway, `restore` warns you before removing anything and `lshed add` is the way out.
116
+
92
117
  ### A new machine
93
118
 
94
119
  ```
@@ -227,6 +252,9 @@ components:
227
252
  - id: research-style
228
253
  mcp:
229
254
  - id: exa # file:./mcp/exa.json — secrets replaced by ${VAR}
255
+ settings:
256
+ - id: permissions # file:./settings/permissions.json — one top-level key of settings.json
257
+ - id: hooks
230
258
 
231
259
  packages:
232
260
  - id: gstack
@@ -241,6 +269,7 @@ profiles:
241
269
  agents: [reviewer]
242
270
  instructions: [base, research-style] # order matters
243
271
  mcp: [exa]
272
+ settings: [permissions, hooks]
244
273
  teaching:
245
274
  skills: [grading-helper]
246
275
  commands: [summarize]
@@ -248,7 +277,7 @@ profiles:
248
277
  ```
249
278
 
250
279
  - Component `source` accepts `file:<path relative to the shed>`. Package `source` accepts `github:owner/repo@ref`, `git:<url>#ref`, `claude-marketplace:<owner/repo>`, `claude-plugin:<name>@<marketplace>`.
251
- - Category names come from the adapter. For Claude Code: `skills`, `agents`, `commands`, `instructions`, `mcp`.
280
+ - Category names come from the adapter. For Claude Code: `skills`, `agents`, `commands`, `instructions`, `mcp`, `settings`.
252
281
  - `ignore:` 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.
253
282
  - `exclude:` lists parts that exist locally but must not enter the shed. `init --exclude` writes it.
254
283
 
@@ -274,7 +303,7 @@ Claude Code plugins are packages with their own scheme. `init` finds user-scope
274
303
 
275
304
  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.
276
305
 
277
- **No secret value enters the shed.** `init` and `add` replace values under `env` and `headers` whose key looks like a secret (`key`, `token`, `secret`, `pass`, `auth`, `credential`, `cookie`, `session`) with a `${VAR}` placeholder:
306
+ **No secret value enters the shed.** `init` and `add` replace values under `env` and `headers` whose key contains a secret-looking word (`key`, `token`, `secret`, `password`, `auth`, `authorization`, `credential`, `cookie`, `session` — whole words, so `MAX_OUTPUT_TOKENS` is left alone) with a `${VAR}` placeholder:
278
307
 
279
308
  ```json
280
309
  { "type": "stdio", "command": "npx", "args": ["-y", "exa-mcp-server"],
@@ -285,6 +314,24 @@ User-scope MCP servers live in `~/.claude.json`, next to machine IDs and session
285
314
 
286
315
  `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. `diff` compares with placeholders as wildcards, so a machine holding real values is not drift.
287
316
 
317
+ ## Settings
318
+
319
+ `~/.claude/settings.json` holds hooks, permissions, `env`, the model, the theme, and some state Claude Code writes for itself. lshed does not merge it. Each **top-level key is one component** of category `settings`: the shed holds `settings/permissions.json`, `settings/hooks.json`, and so on, and `restore` writes exactly those keys, leaving the rest of the file alone. A profile can carry `permissions` and `hooks` and leave `model` to each machine.
320
+
321
+ ```
322
+ $ lshed add
323
+ 창고에 없는 항목 3개:
324
+ settings/hooks ! 패키지 gstack 안을 가리킵니다. 그 설치가 만든 것이면 exclude 하세요: settings/hooks
325
+ settings/model
326
+ settings/theme
327
+ ```
328
+
329
+ - `enabledPlugins` is never taken: the plugin packages own it, and `restore` rebuilds it by installing them.
330
+ - Absolute paths under your home directory become `${HOME}/…` in the shed, so a hook command written on one machine works on another. Claude Code does not expand variables in `settings.json`, so `restore` fills `${HOME}` and any `${VAR}` itself from your shell; unset variables are reported and left as placeholders.
331
+ - `env` is treated as a secret map: keys that look secret are masked, the rest (`CLAUDE_CODE_MAX_OUTPUT_TOKENS`, …) travel as they are.
332
+ - A value pointing inside a package (a hook a toolkit's installer wrote) is flagged. If the installer recreates it, put it in `exclude:` and let `restore --yes` bring it back.
333
+ - Since the shed owns the whole key, extra permissions you grant locally show up in `diff` and go into the shed with `save`, like any other edit.
334
+
288
335
  ## Commands
289
336
 
290
337
  ```
@@ -309,7 +356,7 @@ Global options: `--shed <dir>` (or `LSHED_HOME`; after the first restore lshed r
309
356
 
310
357
  0. Installs any package in the profile that is missing, at the version in `lshed.lock`.
311
358
  1. Removes paths that the **previous** profile placed and the new one doesn't need.
312
- 2. Copies every part of the new profile into place; writes every MCP entry into `~/.claude.json`.
359
+ 2. Copies every part of the new profile into place; writes MCP entries into `~/.claude.json` and settings keys into `settings.json`.
313
360
  3. Regenerates the instructions file.
314
361
 
315
362
  Anything it overwrites or removes is backed up first under `~/.claude/lshed/backups/<timestamp>/`, unless you pass `--no-backup`. Files lshed never placed are left alone. `--dry-run` prints the plan and writes nothing.
@@ -335,9 +382,11 @@ The shed is the source of truth for authored parts: `save` copies local edits ba
335
382
  lshed.lock package versions (generated)
336
383
  skills/<id>/ agents/<id>.md commands/<id>.md instructions/<id>.md
337
384
  mcp/<id>.json secrets as ${VAR}
385
+ settings/<id>.json one top-level key each; home paths as ${HOME}
338
386
 
339
387
  ~/.claude/
340
388
  skills/ agents/ commands/ CLAUDE.md ← placed by restore
389
+ settings.json <id> ← one key per settings component; the rest is untouched
341
390
  lshed/state.json ← which profile, which paths are managed
342
391
  lshed/instructions/<id>.md ← fragments imported by CLAUDE.md
343
392
  lshed/backups/<timestamp>/ ← whatever restore replaced
@@ -350,7 +399,6 @@ The shed is the source of truth for authored parts: `save` copies local edits ba
350
399
 
351
400
  - Secrets beyond "name the variable". Encrypted values, `op://` references and OS keychains are possible later; today lshed is deliberately no better than dotfiles here.
352
401
  - Project-scope MCP servers (`.mcp.json`, `~/.claude.json` `projects.*`) and project-scope plugins. They belong to the project.
353
- - `settings.json` merging (hooks, permissions).
354
402
  - Windows and macOS have not been tested. The code avoids platform-specific paths, but treat this as Linux/WSL for now.
355
403
 
356
404
  ## Troubleshooting
@@ -361,6 +409,7 @@ The shed is the source of truth for authored parts: `save` copies local edits ba
361
409
  - **`status` says a package differs from the lock** — something updated the clone or plugin behind lshed's back (Claude Code auto-updates plugins). `lshed update` records the new version.
362
410
  - **`status` keeps listing the same new things** — they are installer aliases or scratch. Add them to `exclude:` in `lshed.yaml`.
363
411
  - **restore says an MCP variable is missing** — export it in your shell profile and restart Claude Code. The placeholder in `~/.claude.json` is correct; Claude Code fills it at startup.
412
+ - **restore wrote a hook with the wrong path** — the shed stores home paths as `${HOME}/…`. If a command points elsewhere on this machine, edit the JSON in the shed to use `${HOME}` or another variable and `restore` again.
364
413
  - **sync stopped on a conflict** — `cd <shed> && git pull --rebase`, resolve, `git rebase --continue`, then `lshed sync` again.
365
414
 
366
415
  ## License
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
  import { createRequire } from "module";
6
- import os3 from "os";
6
+ import os4 from "os";
7
7
  import path19 from "path";
8
8
 
9
9
  // src/adapters/claude-code.ts
@@ -166,14 +166,62 @@ var pluginInstaller = {
166
166
  }
167
167
  };
168
168
 
169
- // src/adapters/claude-mcp.ts
170
- import { promises as fs3 } from "fs";
171
- import path3 from "path";
169
+ // src/adapters/json-entries.ts
170
+ import { promises as fs2 } from "fs";
171
+ import path2 from "path";
172
+ var JsonEntries = class {
173
+ constructor(spec) {
174
+ this.spec = spec;
175
+ this.name = spec.name;
176
+ this.secretKeys = spec.secretKeys;
177
+ this.secretRootIds = spec.secretRootIds;
178
+ this.expandsEnv = spec.expandsEnv;
179
+ }
180
+ spec;
181
+ kind = "entry";
182
+ name;
183
+ secretKeys;
184
+ secretRootIds;
185
+ expandsEnv;
186
+ file() {
187
+ return this.spec.file();
188
+ }
189
+ async load() {
190
+ const p = await this.file();
191
+ try {
192
+ return JSON.parse(await fs2.readFile(p, "utf8"));
193
+ } catch (e) {
194
+ if (e.code === "ENOENT") return {};
195
+ throw new Error(`${p} \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${e.message}`);
196
+ }
197
+ }
198
+ section(all) {
199
+ const s = this.spec.under ? all[this.spec.under] : all;
200
+ return s && typeof s === "object" && !Array.isArray(s) ? s : {};
201
+ }
202
+ async read() {
203
+ const out = { ...this.section(await this.load()) };
204
+ for (const k of this.spec.skip ?? []) delete out[k];
205
+ return out;
206
+ }
207
+ async write(id, value) {
208
+ const p = await this.file();
209
+ const all = await this.load();
210
+ const sect = { ...this.section(all) };
211
+ if (value === null) delete sect[id];
212
+ else sect[id] = value;
213
+ const next = this.spec.under ? { ...all, [this.spec.under]: sect } : sect;
214
+ await fs2.mkdir(path2.dirname(p), { recursive: true });
215
+ const tmp = `${p}.lshed-${process.pid}.tmp`;
216
+ await fs2.writeFile(tmp, JSON.stringify(next, null, 2) + "\n");
217
+ await fs2.rename(tmp, p);
218
+ }
219
+ };
172
220
 
173
221
  // src/fsutil.ts
174
- import { promises as fs2 } from "fs";
222
+ import { promises as fs3 } from "fs";
175
223
  import { createHash } from "crypto";
176
- import path2 from "path";
224
+ import path3 from "path";
177
225
 
178
226
  // src/ignore.ts
179
227
  var DEFAULT_IGNORE = [
@@ -198,7 +246,7 @@ function isIgnored(rel, patterns) {
198
246
  // src/fsutil.ts
199
247
  async function exists(p) {
200
248
  try {
201
- await fs2.access(p);
249
+ await fs3.access(p);
202
250
  return true;
203
251
  } catch {
204
252
  return false;
@@ -206,7 +254,7 @@ async function exists(p) {
206
254
  }
207
255
  async function isDir(p) {
208
256
  try {
209
- return (await fs2.stat(p)).isDirectory();
257
+ return (await fs3.stat(p)).isDirectory();
210
258
  } catch {
211
259
  return false;
212
260
  }
@@ -216,14 +264,14 @@ async function listFiles(root, ignore = DEFAULT_IGNORE) {
216
264
  if (!await isDir(root)) return [""];
217
265
  const out = [];
218
266
  async function walk2(dir, rel) {
219
- const entries = await fs2.readdir(dir, { withFileTypes: true });
267
+ const entries = await fs3.readdir(dir, { withFileTypes: true });
220
268
  for (const e of entries) {
221
269
  const r = rel ? `${rel}/${e.name}` : e.name;
222
270
  if (isIgnored(r, ignore)) continue;
223
- const full = path2.join(dir, e.name);
271
+ const full = path3.join(dir, e.name);
224
272
  let st;
225
273
  try {
226
- st = await fs2.stat(full);
274
+ st = await fs3.stat(full);
227
275
  } catch {
228
276
  continue;
229
277
  }
@@ -235,28 +283,28 @@ async function listFiles(root, ignore = DEFAULT_IGNORE) {
235
283
  return out.sort();
236
284
  }
237
285
  async function hashFile(p) {
238
- return createHash("sha256").update(await fs2.readFile(p)).digest("hex");
286
+ return createHash("sha256").update(await fs3.readFile(p)).digest("hex");
239
287
  }
240
288
  async function hashTree(root, ignore = DEFAULT_IGNORE) {
241
289
  if (!await exists(root)) return null;
242
290
  const h = createHash("sha256");
243
291
  for (const rel of await listFiles(root, ignore)) {
244
- h.update(rel).update("\0").update(await fs2.readFile(rel ? path2.join(root, rel) : root)).update("\0");
292
+ h.update(rel).update("\0").update(await fs3.readFile(rel ? path3.join(root, rel) : root)).update("\0");
245
293
  }
246
294
  return h.digest("hex");
247
295
  }
248
296
  async function copyTree(src, dst, ignore = DEFAULT_IGNORE) {
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, {
297
+ await fs3.rm(dst, { recursive: true, force: true });
298
+ await fs3.mkdir(path3.dirname(dst), { recursive: true });
299
+ const srcRoot = path3.resolve(src);
300
+ await fs3.cp(src, dst, {
253
301
  recursive: true,
254
302
  dereference: true,
255
303
  filter: async (from) => {
256
- const rel = path2.relative(srcRoot, path2.resolve(from)).split(path2.sep).join("/");
304
+ const rel = path3.relative(srcRoot, path3.resolve(from)).split(path3.sep).join("/");
257
305
  if (isIgnored(rel, ignore)) return false;
258
306
  try {
259
- if ((await fs2.lstat(from)).isSymbolicLink()) await fs2.stat(from);
307
+ if ((await fs3.lstat(from)).isSymbolicLink()) await fs3.stat(from);
260
308
  } catch {
261
309
  return false;
262
310
  }
@@ -265,15 +313,15 @@ async function copyTree(src, dst, ignore = DEFAULT_IGNORE) {
265
313
  });
266
314
  }
267
315
  async function removeTree(p) {
268
- await fs2.rm(p, { recursive: true, force: true });
316
+ await fs3.rm(p, { recursive: true, force: true });
269
317
  }
270
318
  async function diffTrees(local, shed, ignore = DEFAULT_IGNORE) {
271
319
  const l = new Set(await listFiles(local, ignore));
272
320
  const s = new Set(await listFiles(shed, ignore));
273
321
  const out = [];
274
322
  for (const f of [.../* @__PURE__ */ new Set([...l, ...s])].sort()) {
275
- const lp = f ? path2.join(local, f) : local;
276
- const sp = f ? path2.join(shed, f) : shed;
323
+ const lp = f ? path3.join(local, f) : local;
324
+ const sp = f ? path3.join(shed, f) : shed;
277
325
  if (l.has(f) && !s.has(f)) out.push({ status: "A", file: f });
278
326
  else if (!l.has(f) && s.has(f)) out.push({ status: "D", file: f });
279
327
  else if (await hashFile(lp) !== await hashFile(sp)) out.push({ status: "M", file: f });
@@ -281,48 +329,6 @@ async function diffTrees(local, shed, ignore = DEFAULT_IGNORE) {
281
329
  return out;
282
330
  }
283
331
 
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
332
  // src/adapters/claude-code.ts
327
333
  var CATEGORIES = [
328
334
  { name: "skills", root: "skills", kind: "dir" },
@@ -333,13 +339,42 @@ var CATEGORIES = [
333
339
  var ClaudeCodeAdapter = class {
334
340
  name = "claude-code";
335
341
  root;
336
- mcp;
342
+ entryCats;
337
343
  constructor(root) {
338
344
  this.root = root ?? process.env.CLAUDE_CONFIG_DIR ?? path4.join(os.homedir(), ".claude");
339
- this.mcp = new ClaudeMcpEntries(this.root);
345
+ const root_ = this.root;
346
+ this.entryCats = [
347
+ /**
348
+ * 사용자 범위 MCP (§7.4): ~/.claude 의 형제 ~/.claude.json 의 mcpServers. CLAUDE_CONFIG_DIR 처럼 루트 안에 있으면 그것.
349
+ * Claude Code 가 ${VAR} 를 모든 범위에서 스스로 확장하므로 자리표시자를 그대로 둔다.
350
+ */
351
+ new JsonEntries({
352
+ name: "mcp",
353
+ file: async () => {
354
+ const inside = path4.join(root_, ".claude.json");
355
+ return await exists(inside) ? inside : `${root_}.json`;
356
+ },
357
+ under: "mcpServers",
358
+ secretKeys: ["env", "headers"],
359
+ expandsEnv: true
360
+ }),
361
+ /**
362
+ * settings.json (§7.6): 최상위 키 하나 = 항목 하나 (hooks, permissions, env, model, ...).
363
+ * 병합하지 않는다. 키를 통째로 소유하고, 로컬 편집은 diff/save 로 되가져온다.
364
+ * enabledPlugins 는 플러그인 설치기가 만드는 상태라 담지 않는다. ${VAR} 는 Claude Code 가 안 채우므로 restore 가 채운다.
365
+ */
366
+ new JsonEntries({
367
+ name: "settings",
368
+ file: async () => path4.join(root_, "settings.json"),
369
+ secretKeys: [],
370
+ secretRootIds: ["env"],
371
+ expandsEnv: false,
372
+ skip: ["enabledPlugins"]
373
+ })
374
+ ];
340
375
  }
341
376
  entries() {
342
- return [this.mcp];
377
+ return this.entryCats;
343
378
  }
344
379
  categories() {
345
380
  return CATEGORIES;
@@ -413,7 +448,7 @@ var resetHard = (dir, sha) => git(["reset", "--hard", "--quiet", sha], dir);
413
448
  var pullFf = (dir) => git(["pull", "--ff-only", "--quiet"], dir);
414
449
  function runShell(cmd, cwd) {
415
450
  return new Promise((resolve, reject) => {
416
- const p = spawn("sh", ["-c", cmd], { cwd, stdio: "inherit" });
451
+ const p = spawn(cmd, { cwd, stdio: "inherit", shell: true });
417
452
  p.on("error", reject);
418
453
  p.on("close", (code) => code === 0 ? resolve() : reject(new Error(`\uBA85\uB839\uC774 ${code} \uB85C \uB05D\uB0AC\uC2B5\uB2C8\uB2E4: ${cmd}`)));
419
454
  });
@@ -609,7 +644,7 @@ async function writeState(adapter, state) {
609
644
  // src/core/context.ts
610
645
  function spawnExec(cmd, args, cwd) {
611
646
  return new Promise((resolve, reject) => {
612
- const p = spawn2(cmd, args, { cwd, stdio: "inherit" });
647
+ const p = spawn2(cmd, args, { cwd, stdio: "inherit", shell: process.platform === "win32" });
613
648
  p.on("error", reject);
614
649
  p.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} \uAC00 ${code} \uB85C \uB05D\uB0AC\uC2B5\uB2C8\uB2E4`)));
615
650
  });
@@ -904,7 +939,9 @@ async function discover(ctx, exclude = []) {
904
939
  ctx.log(` ! ${cat.name}/${id}: \uC774\uB984\uC5D0 \uC4F8 \uC218 \uC5C6\uB294 \uBB38\uC790\uAC00 \uC788\uC5B4 \uAC74\uB108\uB700`);
905
940
  continue;
906
941
  }
907
- items.push({ kind: "entry", category: cat.name, id, value: all2[id], cat });
942
+ const owner = kept.find((p) => p.path && JSON.stringify(all2[id]).includes(p.path));
943
+ const warn = owner ? `\uD328\uD0A4\uC9C0 ${owner.id} \uC548\uC744 \uAC00\uB9AC\uD0B5\uB2C8\uB2E4. \uADF8 \uC124\uCE58\uAC00 \uB9CC\uB4E0 \uAC83\uC774\uBA74 exclude \uD558\uC138\uC694: ${cat.name}/${id}` : void 0;
944
+ items.push({ kind: "entry", category: cat.name, id, value: all2[id], cat, warn });
908
945
  }
909
946
  }
910
947
  return { items, generated, excluded };
@@ -928,8 +965,12 @@ import { isSeq, isMap } from "yaml";
928
965
 
929
966
  // src/core/entries.ts
930
967
  import { promises as fs10 } from "fs";
968
+ import os2 from "os";
931
969
  import path14 from "path";
932
- var SECRET_KEY_RE = /key|token|secret|pass|auth|credential|cookie|session/i;
970
+ var SECRET_WORDS = /* @__PURE__ */ new Set(["key", "apikey", "token", "secret", "password", "passwd", "auth", "authorization", "credential", "credentials", "cookie", "session"]);
971
+ function isSecretKey(k) {
972
+ return k.split(/[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])/).some((w) => SECRET_WORDS.has(w.toLowerCase()));
973
+ }
933
974
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
934
975
  function placeholdersIn(v) {
935
976
  const out = /* @__PURE__ */ new Set();
@@ -946,27 +987,39 @@ function walk(v, onString, keyPath = []) {
946
987
  return v;
947
988
  }
948
989
  var envName = (...parts) => parts.join("_").replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase();
949
- function mask(id, entry, cat) {
990
+ function mask(id, entry, cat, home = os2.homedir()) {
991
+ entry = portable(entry, home);
950
992
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry;
951
- const out = { ...entry };
952
- for (const sk of cat.secretKeys) {
953
- const sect = out[sk];
954
- if (!sect || typeof sect !== "object" || Array.isArray(sect)) continue;
993
+ const maskSection = (sect, envLike) => {
955
994
  const masked = {};
956
995
  for (const [k, v] of Object.entries(sect)) {
957
- if (typeof v !== "string" || !SECRET_KEY_RE.test(k) || PLACEHOLDER_RE.test(v)) {
996
+ if (typeof v !== "string" || !isSecretKey(k) || PLACEHOLDER_RE.test(v)) {
958
997
  masked[k] = v;
959
998
  PLACEHOLDER_RE.lastIndex = 0;
960
999
  continue;
961
1000
  }
962
- const name = sk === "env" ? envName(k) : envName(id, k);
1001
+ const name = envLike ? envName(k) : envName(id, k);
963
1002
  const scheme = /^(\w+) \S+$/.exec(v);
964
1003
  masked[k] = scheme ? `${scheme[1]} \${${name}}` : `\${${name}}`;
965
1004
  }
966
- out[sk] = masked;
1005
+ return masked;
1006
+ };
1007
+ if (cat.secretRootIds?.includes(id)) return maskSection(entry, true);
1008
+ const out = { ...entry };
1009
+ for (const sk of cat.secretKeys) {
1010
+ const sect = out[sk];
1011
+ if (!sect || typeof sect !== "object" || Array.isArray(sect)) continue;
1012
+ out[sk] = maskSection(sect, sk === "env");
967
1013
  }
968
1014
  return out;
969
1015
  }
1016
+ function portable(entry, home = os2.homedir()) {
1017
+ if (!home || home === "/") return entry;
1018
+ return walk(entry, (s) => s === home || s.startsWith(home + "/") || s.startsWith(home + "\\") ? "${HOME}" + s.slice(home.length) : s);
1019
+ }
1020
+ function envWithHome(env = process.env) {
1021
+ return { HOME: os2.homedir(), ...env };
1022
+ }
970
1023
  function suspiciousStrings(entry) {
971
1024
  const out = [];
972
1025
  walk(entry, (s, kp) => {
@@ -1007,7 +1060,7 @@ function matches2(shed, local) {
1007
1060
  }
1008
1061
  return shed === local;
1009
1062
  }
1010
- function remask(id, local, shed, cat) {
1063
+ function remask(id, local, shed, cat, home = os2.homedir()) {
1011
1064
  const keep = (l, s) => {
1012
1065
  if (typeof l === "string" && typeof s === "string" && stringMatches(s, l)) return s;
1013
1066
  if (Array.isArray(l) && Array.isArray(s)) return l.map((x2, i) => keep(x2, s[i]));
@@ -1016,7 +1069,7 @@ function remask(id, local, shed, cat) {
1016
1069
  }
1017
1070
  return l;
1018
1071
  };
1019
- return mask(id, shed === null ? local : keep(local, shed), cat);
1072
+ return mask(id, shed === null ? local : keep(local, portable(shed, home)), cat, home);
1020
1073
  }
1021
1074
  function diffEntry(shed, local) {
1022
1075
  const out = [];
@@ -1086,9 +1139,10 @@ async function ingest(ctx, doc, profile, items, lock) {
1086
1139
  } else {
1087
1140
  const masked = mask(f.id, f.value, f.cat);
1088
1141
  await writeEntryFile(path15.join(ctx.shed, f.category, `${f.id}.json`), masked);
1089
- const vars = placeholdersIn(masked);
1142
+ const vars = placeholdersIn(masked).filter((v) => v !== "HOME");
1090
1143
  ctx.log(` + ${f.category}/${f.id}${vars.length ? ` (\uC2DC\uD06C\uB9BF \u2192 ${vars.map((v) => "${" + v + "}").join(", ")})` : ""}`);
1091
1144
  for (const where of suspiciousStrings(masked)) ctx.log(` ! ${where} \uAC00 \uC2DC\uD06C\uB9BF\uCC98\uB7FC \uBCF4\uC785\uB2C8\uB2E4. \uCC3D\uACE0\uC758 ${f.category}/${f.id}.json \uC5D0\uC11C \${VAR} \uB85C \uBC14\uAFB8\uC138\uC694`);
1145
+ if (f.warn) ctx.log(` ! ${f.warn}`);
1092
1146
  }
1093
1147
  const comps = seqAt(doc, ["components", f.category]);
1094
1148
  if (!comps.items.some((it) => isMap(it) && it.get("id") === f.id)) comps.add(doc.createNode({ id: f.id }));
@@ -1173,6 +1227,10 @@ async function restore(ctx, profileArg, opts = {}) {
1173
1227
  if (fragments.length) newManaged.add(instrRel);
1174
1228
  const oldManaged = new Set(state?.managed ?? []);
1175
1229
  const toRemove = [...oldManaged].filter((r) => !newManaged.has(r)).sort();
1230
+ if (state && state.shed !== ctx.shed && toRemove.length) {
1231
+ ctx.log(`! \uB9C8\uC9C0\uB9C9\uC73C\uB85C \uC801\uC6A9\uD55C \uCC3D\uACE0\uAC00 \uB2E4\uB985\uB2C8\uB2E4: ${state.shed}`);
1232
+ ctx.log(` \uC544\uB798 ${toRemove.length}\uAC1C\uB294 \uADF8 \uCC3D\uACE0\uC758 \uAD00\uB9AC \uBAA9\uB85D\uC5D0 \uC788\uC5B4 \uC81C\uAC70 \uB300\uC0C1\uC785\uB2C8\uB2E4 (\uBC31\uC5C5\uB428). \uC774 \uAE30\uAE30\uC758 \uAC83\uC744 \uC9C0\uD0A4\uB824\uBA74 \uBA3C\uC800 'lshed add' \uB85C \uCC3D\uACE0\uC5D0 \uB123\uC73C\uC138\uC694.`);
1233
+ }
1176
1234
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1177
1235
  const backupDir = path17.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
1178
1236
  const backedUp = [];
@@ -1211,8 +1269,8 @@ async function restore(ctx, profileArg, opts = {}) {
1211
1269
  if (it.entry) {
1212
1270
  const shed = await readEntryFile(it.src);
1213
1271
  const local = (await entriesOf(it.entry))[it.id];
1214
- const vars = placeholdersIn(shed);
1215
- const ex = expand(shed);
1272
+ const vars = placeholdersIn(shed).filter((v) => v !== "HOME");
1273
+ const ex = expand(shed, envWithHome());
1216
1274
  if (ex.missing.length) missingEnv.push({ rel: it.rel, vars: ex.missing });
1217
1275
  const value = it.entry.expandsEnv ? shed : ex.value;
1218
1276
  const same2 = local !== void 0 && matches2(shed, local);
@@ -1324,7 +1382,7 @@ async function add(ctx, keys = [], opts = {}) {
1324
1382
  if (!c.fresh.length) ctx.log("\uCC3D\uACE0\uC5D0 \uC5C6\uB294 \uC0C8 \uD56D\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
1325
1383
  else {
1326
1384
  ctx.log(`\uCC3D\uACE0\uC5D0 \uC5C6\uB294 \uD56D\uBAA9 ${c.fresh.length}\uAC1C (\uB123\uC73C\uB824\uBA74 lshed add <key...> \uB610\uB294 --all):`);
1327
- for (const f of c.fresh) ctx.log(` ${f.kind === "package" ? "\u2261" : " "} ${keyOf(f)}${f.kind === "package" ? ` ${f.pkg.source}` : ""}`);
1385
+ for (const f of c.fresh) ctx.log(` ${f.kind === "package" ? "\u2261" : " "} ${keyOf(f)}${f.kind === "package" ? ` ${f.pkg.source}` : f.kind === "entry" && f.warn ? ` ! ${f.warn}` : ""}`);
1328
1386
  }
1329
1387
  hint(ctx, c, state.profile);
1330
1388
  return [];
@@ -1380,7 +1438,7 @@ async function status(ctx) {
1380
1438
  const missingEnv = [];
1381
1439
  for (const it of planProfile(ctx, m, state.profile).filter((p) => p.entry)) {
1382
1440
  const shed = await readEntryFile(it.src);
1383
- const missing = shed === null ? [] : expand(shed).missing;
1441
+ const missing = shed === null ? [] : expand(shed, envWithHome()).missing;
1384
1442
  if (missing.length) missingEnv.push({ rel: it.rel, vars: missing });
1385
1443
  }
1386
1444
  const fresh = (await candidates(ctx, m, state.profile)).fresh.map(keyOf);
@@ -1553,7 +1611,7 @@ ${removed.length}\uAC1C \uC81C\uAC70. \uCC3D\uACE0\uB97C \uCEE4\uBC0B\uD558\uC13
1553
1611
  }
1554
1612
 
1555
1613
  // src/core/sync.ts
1556
- import os2 from "os";
1614
+ import os3 from "os";
1557
1615
  async function sync(ctx, opts = {}) {
1558
1616
  const shed = ctx.shed;
1559
1617
  const push = opts.push ?? true;
@@ -1576,7 +1634,16 @@ async function sync(ctx, opts = {}) {
1576
1634
  ctx.log(` ${opts.dryRun ? "(dry-run) " : ""}commit ${dirty.length}\uAC1C: ${dirty.slice(0, 5).join(", ")}${dirty.length > 5 ? ` \uC678 ${dirty.length - 5}` : ""}`);
1577
1635
  if (!opts.dryRun) {
1578
1636
  await git(["add", "-A"], shed);
1579
- await git(["commit", "--quiet", "-m", msg], shed);
1637
+ try {
1638
+ await git(["commit", "--quiet", "-m", msg], shed);
1639
+ } catch (e) {
1640
+ if (/Author identity unknown|Please tell me who you are/.test(e.message)) {
1641
+ throw new Error(`git \uC0AC\uC6A9\uC790 \uC815\uBCF4\uAC00 \uC5C6\uC5B4 \uCEE4\uBC0B\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uD55C \uBC88\uB9CC \uC124\uC815\uD558\uC138\uC694:
1642
+ git config --global user.name "\uC774\uB984"
1643
+ git config --global user.email "\uBA54\uC77C"`);
1644
+ }
1645
+ throw e;
1646
+ }
1580
1647
  }
1581
1648
  res.committed = dirty;
1582
1649
  } else {
@@ -1632,12 +1699,17 @@ function defaultMessage(paths, profile) {
1632
1699
  const head2 = parts.slice(0, 3).join(", ") + (parts.length > 3 ? ` +${parts.length - 3}` : "");
1633
1700
  return `lshed sync: ${head2}
1634
1701
 
1635
- ${os2.hostname()}${profile ? ` \xB7 profile ${profile}` : ""}`;
1702
+ ${os3.hostname()}${profile ? ` \xB7 profile ${profile}` : ""}`;
1636
1703
  }
1637
1704
  var firstLine = (s) => s.split("\n").find((l) => l.trim() && !l.startsWith("Command failed")) ?? s;
1638
1705
 
1639
1706
  // src/cli.ts
1640
1707
  var { version } = createRequire(import.meta.url)("../package.json");
1708
+ for (const s of [process.stdout, process.stderr]) {
1709
+ s.on("error", (e) => {
1710
+ if (e.code === "EPIPE") process.exit(0);
1711
+ });
1712
+ }
1641
1713
  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)");
1642
1714
  function adapterFromOpts() {
1643
1715
  const { root } = program.opts();
@@ -1648,7 +1720,7 @@ async function ctxFor(cmd) {
1648
1720
  const { shed: flag } = program.opts();
1649
1721
  let shed = flag ?? process.env.LSHED_HOME;
1650
1722
  if (!shed && cmd === "other") shed = (await readState(adapter))?.shed;
1651
- if (!shed && cmd === "init") shed = path19.join(os3.homedir(), "lshed");
1723
+ if (!shed && cmd === "init") shed = path19.join(os4.homedir(), "lshed");
1652
1724
  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.");
1653
1725
  return { adapter, shed: path19.resolve(shed), log: (l) => console.log(l), exec: spawnExec };
1654
1726
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lshed",
3
- "version": "0.6.0",
3
+ "version": "0.7.2",
4
4
  "description": "Portable harness environment manager for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "leesongheon <leesongheon1209@gmail.com>",
@@ -22,6 +22,7 @@
22
22
  "dev": "tsx src/cli.ts",
23
23
  "test": "vitest run",
24
24
  "typecheck": "tsc --noEmit",
25
+ "smoke": "node scripts/smoke.mjs",
25
26
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
26
27
  },
27
28
  "keywords": [