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 +11 -0
- package/README.md +30 -6
- package/dist/cli.js +141 -87
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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/
|
|
170
|
-
import { promises as
|
|
171
|
-
import
|
|
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
|
|
222
|
+
import { promises as fs3 } from "fs";
|
|
175
223
|
import { createHash } from "crypto";
|
|
176
|
-
import
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
271
|
+
const full = path3.join(dir, e.name);
|
|
224
272
|
let st;
|
|
225
273
|
try {
|
|
226
|
-
st = await
|
|
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
|
|
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
|
|
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
|
|
250
|
-
await
|
|
251
|
-
const srcRoot =
|
|
252
|
-
await
|
|
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 =
|
|
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
|
|
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
|
|
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 ?
|
|
276
|
-
const sp = f ?
|
|
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
|
-
|
|
342
|
+
entryCats;
|
|
337
343
|
constructor(root) {
|
|
338
344
|
this.root = root ?? process.env.CLAUDE_CONFIG_DIR ?? path4.join(os.homedir(), ".claude");
|
|
339
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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" || !
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
${
|
|
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(
|
|
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
|
}
|