lshed 0.6.0 → 0.7.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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 — 2026-09-03
4
+
5
+ `settings.json` travels, without merging.
6
+
7
+ - 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.
8
+ - 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.
9
+ - `env` in settings is a secret map: secret-looking keys are masked.
10
+ - The secret heuristic now matches whole words. `CLAUDE_CODE_MAX_OUTPUT_TOKENS` is not a token; `BYPASS_PERMISSIONS` is not a password.
11
+ - `init` and `add` flag an entry whose value points inside a package (a hook a toolkit's installer wrote) and suggest `exclude:`.
12
+ - The MCP adapter became a generic "one JSON key = one entry" store used by both categories.
13
+
3
14
  ## 0.6.0 — 2026-09-03
4
15
 
5
16
  - `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
 
@@ -227,6 +227,9 @@ components:
227
227
  - id: research-style
228
228
  mcp:
229
229
  - id: exa # file:./mcp/exa.json — secrets replaced by ${VAR}
230
+ settings:
231
+ - id: permissions # file:./settings/permissions.json — one top-level key of settings.json
232
+ - id: hooks
230
233
 
231
234
  packages:
232
235
  - id: gstack
@@ -241,6 +244,7 @@ profiles:
241
244
  agents: [reviewer]
242
245
  instructions: [base, research-style] # order matters
243
246
  mcp: [exa]
247
+ settings: [permissions, hooks]
244
248
  teaching:
245
249
  skills: [grading-helper]
246
250
  commands: [summarize]
@@ -248,7 +252,7 @@ profiles:
248
252
  ```
249
253
 
250
254
  - 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`.
255
+ - Category names come from the adapter. For Claude Code: `skills`, `agents`, `commands`, `instructions`, `mcp`, `settings`.
252
256
  - `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
257
  - `exclude:` lists parts that exist locally but must not enter the shed. `init --exclude` writes it.
254
258
 
@@ -274,7 +278,7 @@ Claude Code plugins are packages with their own scheme. `init` finds user-scope
274
278
 
275
279
  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
280
 
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:
281
+ **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
282
 
279
283
  ```json
280
284
  { "type": "stdio", "command": "npx", "args": ["-y", "exa-mcp-server"],
@@ -285,6 +289,24 @@ User-scope MCP servers live in `~/.claude.json`, next to machine IDs and session
285
289
 
286
290
  `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
291
 
292
+ ## Settings
293
+
294
+ `~/.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.
295
+
296
+ ```
297
+ $ lshed add
298
+ 창고에 없는 항목 3개:
299
+ settings/hooks ! 패키지 gstack 안을 가리킵니다. 그 설치가 만든 것이면 exclude 하세요: settings/hooks
300
+ settings/model
301
+ settings/theme
302
+ ```
303
+
304
+ - `enabledPlugins` is never taken: the plugin packages own it, and `restore` rebuilds it by installing them.
305
+ - 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.
306
+ - `env` is treated as a secret map: keys that look secret are masked, the rest (`CLAUDE_CODE_MAX_OUTPUT_TOKENS`, …) travel as they are.
307
+ - 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.
308
+ - 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.
309
+
288
310
  ## Commands
289
311
 
290
312
  ```
@@ -309,7 +331,7 @@ Global options: `--shed <dir>` (or `LSHED_HOME`; after the first restore lshed r
309
331
 
310
332
  0. Installs any package in the profile that is missing, at the version in `lshed.lock`.
311
333
  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`.
334
+ 2. Copies every part of the new profile into place; writes MCP entries into `~/.claude.json` and settings keys into `settings.json`.
313
335
  3. Regenerates the instructions file.
314
336
 
315
337
  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 +357,11 @@ The shed is the source of truth for authored parts: `save` copies local edits ba
335
357
  lshed.lock package versions (generated)
336
358
  skills/<id>/ agents/<id>.md commands/<id>.md instructions/<id>.md
337
359
  mcp/<id>.json secrets as ${VAR}
360
+ settings/<id>.json one top-level key each; home paths as ${HOME}
338
361
 
339
362
  ~/.claude/
340
363
  skills/ agents/ commands/ CLAUDE.md ← placed by restore
364
+ settings.json <id> ← one key per settings component; the rest is untouched
341
365
  lshed/state.json ← which profile, which paths are managed
342
366
  lshed/instructions/<id>.md ← fragments imported by CLAUDE.md
343
367
  lshed/backups/<timestamp>/ ← whatever restore replaced
@@ -350,7 +374,6 @@ The shed is the source of truth for authored parts: `save` copies local edits ba
350
374
 
351
375
  - 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
376
  - 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
377
  - Windows and macOS have not been tested. The code avoids platform-specific paths, but treat this as Linux/WSL for now.
355
378
 
356
379
  ## Troubleshooting
@@ -361,6 +384,7 @@ The shed is the source of truth for authored parts: `save` copies local edits ba
361
384
  - **`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
385
  - **`status` keeps listing the same new things** — they are installer aliases or scratch. Add them to `exclude:` in `lshed.yaml`.
363
386
  - **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.
387
+ - **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
388
  - **sync stopped on a conflict** — `cd <shed> && git pull --rebase`, resolve, `git rebase --continue`, then `lshed sync` again.
365
389
 
366
390
  ## 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;
@@ -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 + "/") ? "${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 }));
@@ -1211,8 +1265,8 @@ async function restore(ctx, profileArg, opts = {}) {
1211
1265
  if (it.entry) {
1212
1266
  const shed = await readEntryFile(it.src);
1213
1267
  const local = (await entriesOf(it.entry))[it.id];
1214
- const vars = placeholdersIn(shed);
1215
- const ex = expand(shed);
1268
+ const vars = placeholdersIn(shed).filter((v) => v !== "HOME");
1269
+ const ex = expand(shed, envWithHome());
1216
1270
  if (ex.missing.length) missingEnv.push({ rel: it.rel, vars: ex.missing });
1217
1271
  const value = it.entry.expandsEnv ? shed : ex.value;
1218
1272
  const same2 = local !== void 0 && matches2(shed, local);
@@ -1324,7 +1378,7 @@ async function add(ctx, keys = [], opts = {}) {
1324
1378
  if (!c.fresh.length) ctx.log("\uCC3D\uACE0\uC5D0 \uC5C6\uB294 \uC0C8 \uD56D\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
1325
1379
  else {
1326
1380
  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}` : ""}`);
1381
+ 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
1382
  }
1329
1383
  hint(ctx, c, state.profile);
1330
1384
  return [];
@@ -1380,7 +1434,7 @@ async function status(ctx) {
1380
1434
  const missingEnv = [];
1381
1435
  for (const it of planProfile(ctx, m, state.profile).filter((p) => p.entry)) {
1382
1436
  const shed = await readEntryFile(it.src);
1383
- const missing = shed === null ? [] : expand(shed).missing;
1437
+ const missing = shed === null ? [] : expand(shed, envWithHome()).missing;
1384
1438
  if (missing.length) missingEnv.push({ rel: it.rel, vars: missing });
1385
1439
  }
1386
1440
  const fresh = (await candidates(ctx, m, state.profile)).fresh.map(keyOf);
@@ -1553,7 +1607,7 @@ ${removed.length}\uAC1C \uC81C\uAC70. \uCC3D\uACE0\uB97C \uCEE4\uBC0B\uD558\uC13
1553
1607
  }
1554
1608
 
1555
1609
  // src/core/sync.ts
1556
- import os2 from "os";
1610
+ import os3 from "os";
1557
1611
  async function sync(ctx, opts = {}) {
1558
1612
  const shed = ctx.shed;
1559
1613
  const push = opts.push ?? true;
@@ -1632,7 +1686,7 @@ function defaultMessage(paths, profile) {
1632
1686
  const head2 = parts.slice(0, 3).join(", ") + (parts.length > 3 ? ` +${parts.length - 3}` : "");
1633
1687
  return `lshed sync: ${head2}
1634
1688
 
1635
- ${os2.hostname()}${profile ? ` \xB7 profile ${profile}` : ""}`;
1689
+ ${os3.hostname()}${profile ? ` \xB7 profile ${profile}` : ""}`;
1636
1690
  }
1637
1691
  var firstLine = (s) => s.split("\n").find((l) => l.trim() && !l.startsWith("Command failed")) ?? s;
1638
1692
 
@@ -1648,7 +1702,7 @@ async function ctxFor(cmd) {
1648
1702
  const { shed: flag } = program.opts();
1649
1703
  let shed = flag ?? process.env.LSHED_HOME;
1650
1704
  if (!shed && cmd === "other") shed = (await readState(adapter))?.shed;
1651
- if (!shed && cmd === "init") shed = path19.join(os3.homedir(), "lshed");
1705
+ if (!shed && cmd === "init") shed = path19.join(os4.homedir(), "lshed");
1652
1706
  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
1707
  return { adapter, shed: path19.resolve(shed), log: (l) => console.log(l), exec: spawnExec };
1654
1708
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lshed",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Portable harness environment manager for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "leesongheon <leesongheon1209@gmail.com>",