lshed 0.1.0 → 0.2.1

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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 — 2026-09-02
4
+
5
+ - `lshed list [--unused]` shows everything in the shed and which profiles use it.
6
+ - `lshed remove <key>` deletes a component or package from the shed. Refused while any profile still references it. Packages leave the manifest and lock only; the local clone stays.
7
+ - `lshed prune [--yes]` removes everything no profile uses. Lists without `--yes`.
8
+ - Manifest edits preserve your comments. Empty `packages:` is no longer written.
9
+
10
+ ## 0.2.0 — 2026-09-02
11
+
12
+ A harness holds three kinds of things, and 0.1 treated them all the same. Running against a real `~/.claude` showed that 55 of 62 "skills" were files generated by one toolkit's installer, and the toolkit itself was a git clone.
13
+
14
+ - **Packages.** A `packages:` list in `lshed.yaml` records things you *installed* by source and version instead of copying them: `source: github:owner/repo@ref` (or `git:<url>#ref`), `into: <path under the agent root>`, optional `install: <command>`. `restore` clones a missing package at the commit pinned in `lshed.lock`; a package already present is never touched.
15
+ - **Generated files are skipped.** `init` recognises a directory as a package when it contains a `.git` with a remote, and recognises a stub as generated when one of its symlinks points inside a package. Both are left out of the shed and the managed set. Aliases that an installer creates without symlinks are not detected; use `init --exclude`.
16
+ - **`lshed.lock`** pins each package to a commit. Written by `init` from the existing clone, by `restore` on first clone, and by `update`.
17
+ - **`lshed update [ids...]`** fast-forwards packages and refreshes the lock.
18
+ - **Install commands run only with `--yes`.** Without it, `restore` and `update` print the commands and stop. A shed can be cloned from anywhere; running its shell commands should be a deliberate act.
19
+ - `git:` source scheme for non-GitHub remotes.
20
+
21
+ Packages are additive: switching to a profile that does not list one leaves it on disk. Removing a clone with a backup would mean copying a repository, which is the wrong tool for that job.
22
+
23
+ ## 0.1.1 — 2026-09-02
24
+
25
+ Both fixes came from running 0.1.0 against a real 62-skill `~/.claude`.
26
+
27
+ - Skip regenerable directories when copying: `node_modules`, `.git`, `__pycache__`, `.venv`, cache dirs, `*.log`. A real harness went from 1.6 GB to 6.2 MB. Build output such as `dist/` is **not** ignored by default, because for some skills it is the deliverable.
28
+ - Extend the list with `ignore:` in `lshed.yaml`; the same list applies to `diff`, `save` and backups, so ignored files never show up as drift.
29
+ - Follow symlinks. A skill symlinked into `~/.claude/skills` used to be skipped silently; it is now captured and copied by content. Broken links are skipped.
30
+ - `init --exclude <id...>` leaves out components that do not belong in a shed, such as a toolkit with its own installer.
31
+
3
32
  ## 0.1.0 — 2026-09-02
4
33
 
5
34
  First working release. Claude Code only.
package/README.md CHANGED
@@ -76,24 +76,70 @@ profiles:
76
76
  instructions: [base]
77
77
  ```
78
78
 
79
- - `source` accepts `file:<path relative to the shed>`. `github:owner/repo@ref` is parsed and reserved for a future release; using it today is an error, not silent misbehaviour.
79
+ - Component `source` accepts `file:<path relative to the shed>`. Package `source` accepts `github:owner/repo@ref` or `git:<url>#ref`.
80
80
  - Category names come from the adapter. For Claude Code: `skills`, `agents`, `commands`, `instructions`.
81
+ - `ignore:` at the top level adds to the built-in list of things never copied: `node_modules`, `.git`, `__pycache__`, `.venv`, cache directories, `*.log`. Build output like `dist/` is not ignored by default, since some skills ship it. Add it yourself if your parts rebuild from source.
81
82
  - Instructions are not merged. `restore` writes a `CLAUDE.md` that `@`-imports each fragment in order, so a fragment edit shows up without re-running anything. Your original `CLAUDE.md` is backed up the first time.
82
83
 
84
+ ## Three kinds of things
85
+
86
+ A real `~/.claude` mixes three kinds of content, and they need different handling:
87
+
88
+ | Kind | Example | What lshed does |
89
+ |---|---|---|
90
+ | **Authored** | a skill you wrote, your `CLAUDE.md` | copies it into the shed |
91
+ | **Installed** | a toolkit you `git clone`d, a plugin | records source + commit; `restore` clones it back |
92
+ | **Generated** | stub skills an installer wrote for you | skips them; they return when the installer runs |
93
+
94
+ `init` sorts this out for you. A directory with a `.git` and a remote becomes a **package**. A skill whose symlink points inside a package is treated as generated and skipped. Everything else is authored and copied.
95
+
96
+ ```yaml
97
+ packages:
98
+ - id: gstack
99
+ source: github:garrytan/gstack@main # git:<url>#ref for other hosts
100
+ into: skills/gstack # where it lives under ~/.claude
101
+ install: ./setup # optional; run after clone, only with --yes
102
+
103
+ profiles:
104
+ default:
105
+ packages: [gstack]
106
+ skills: [add-drivers, domain-modeling]
107
+ ```
108
+
109
+ `lshed.lock` pins each package to a commit, so a fresh machine gets the same version you had. `lshed update` moves it forward.
110
+
111
+ Rules that keep this safe:
112
+
113
+ - A package that is already present is never touched by `restore`. Your local checkout is yours.
114
+ - `install:` is a shell command. `restore` and `update` **print it and stop** unless you pass `--yes`.
115
+ - Packages are not part of the managed set. Switching profiles never deletes a clone.
116
+ - Installers sometimes create aliases without symlinks, which `init` cannot tell from authored skills. Leave those out with `--exclude`:
117
+
118
+ ```bash
119
+ lshed init --shed ~/harness --exclude _gstack-command connect-chrome
120
+ ```
121
+
83
122
  ## Commands
84
123
 
85
124
  ```
86
- lshed init [--shed <dir>] [--profile <name>] scan the current environment into a shed
87
- lshed restore [profile] [--dry-run] [--no-backup]
88
- lshed status applied profile, managed paths, drift
125
+ lshed init [--shed <dir>] [--profile <name>] [--exclude <id...>]
126
+ lshed restore [profile] [--dry-run] [--no-backup] [--yes]
127
+ lshed update [ids...] [--dry-run] [--yes] pull packages forward, refresh lshed.lock
128
+ lshed status applied profile, managed paths, drift, packages
89
129
  lshed diff files that differ between local and shed
90
130
  lshed save [ids...] copy local edits back into the shed
131
+ lshed list [--unused] what is in the shed, and which profiles use it
132
+ lshed remove <key> drop a component or package from the shed
133
+ lshed prune [--yes] drop everything no profile uses
91
134
  ```
92
135
 
136
+ `remove` and `prune` delete from the shed without a backup. The shed is meant to live in git; commit before you prune. Both refuse to touch anything a profile still lists, so the way to retire a part is to take it out of the profiles first.
137
+
93
138
  Global options: `--shed <dir>` (or `LSHED_HOME`; after the first restore lshed remembers it), `--root <dir>` (agent config root, default `~/.claude`).
94
139
 
95
140
  ### What `restore` does
96
141
 
142
+ 0. Clones any package in the profile that is missing, at the commit in `lshed.lock`.
97
143
  1. Removes paths that the **previous** profile placed and the new one doesn't need.
98
144
  2. Copies every part of the new profile into place.
99
145
  3. Regenerates the instructions file.
@@ -102,7 +148,7 @@ Anything it overwrites or removes is backed up first under `~/.claude/lshed/back
102
148
 
103
149
  ### Ownership
104
150
 
105
- The shed is the source of truth. `save` copies local edits back for `file:` parts only. Remote parts will be read-only and refreshed with `update` once remote sources land.
151
+ The shed is the source of truth for authored parts: `save` copies local edits back for `file:` components. Packages are owned by their upstream: `update` pulls them, `save` ignores them.
106
152
 
107
153
  ## Where things live
108
154
 
@@ -124,7 +170,8 @@ The shed is the source of truth. `save` copies local edits back for `file:` part
124
170
 
125
171
  - MCP servers and secrets. Planned: the manifest names the keys, values are injected locally, nothing secret enters the shed.
126
172
  - `settings.json` merging (hooks, permissions).
127
- - Remote sources (`github:`), lock file, `update`, `list --unused`, `prune`.
173
+ - `sync` (a git pull/push wrapper). Use git in the shed directly for now.
174
+ - Plugins installed through Claude Code's marketplace. They are packages too; recording them is next.
128
175
  - Windows and macOS have not been tested. The code avoids platform-specific paths, but treat 0.1 as Linux/WSL.
129
176
 
130
177
  ## Troubleshooting
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { Command } from "commander";
5
5
  import { createRequire } from "module";
6
6
  import os2 from "os";
7
- import path9 from "path";
7
+ import path13 from "path";
8
8
 
9
9
  // src/adapters/claude-code.ts
10
10
  import { promises as fs } from "fs";
@@ -43,10 +43,17 @@ var ClaudeCodeAdapter = class {
43
43
  }
44
44
  for (const e of entries) {
45
45
  if (e.name.startsWith(".")) continue;
46
- if (cat.kind === "dir" && e.isDirectory()) {
47
- out.push({ category: cat.name, id: e.name, path: path.join(dir, e.name) });
48
- } else if (cat.kind === "file" && e.isFile() && e.name.endsWith(".md")) {
49
- out.push({ category: cat.name, id: e.name.slice(0, -3), path: path.join(dir, e.name) });
46
+ const full = path.join(dir, e.name);
47
+ let st;
48
+ try {
49
+ st = await fs.stat(full);
50
+ } catch {
51
+ continue;
52
+ }
53
+ if (cat.kind === "dir" && st.isDirectory()) {
54
+ out.push({ category: cat.name, id: e.name, path: full });
55
+ } else if (cat.kind === "file" && st.isFile() && e.name.endsWith(".md")) {
56
+ out.push({ category: cat.name, id: e.name.slice(0, -3), path: full });
50
57
  }
51
58
  }
52
59
  }
@@ -55,8 +62,8 @@ var ClaudeCodeAdapter = class {
55
62
  };
56
63
 
57
64
  // src/core/init.ts
58
- import { promises as fs5 } from "fs";
59
- import path7 from "path";
65
+ import { promises as fs7 } from "fs";
66
+ import path10 from "path";
60
67
 
61
68
  // src/core/context.ts
62
69
  import { promises as fs3 } from "fs";
@@ -85,10 +92,37 @@ function parseSource(raw) {
85
92
  const [, owner, repo, ref, subpath] = m;
86
93
  return { scheme, owner, repo, ref, subpath };
87
94
  }
95
+ case "git": {
96
+ const hash = rest.lastIndexOf("#");
97
+ const url = hash >= 0 ? rest.slice(0, hash) : rest;
98
+ const ref = hash >= 0 ? rest.slice(hash + 1) : void 0;
99
+ if (!url) throw new Error(`git: \uB4A4\uC5D0 URL \uC774 \uC5C6\uC2B5\uB2C8\uB2E4: "${raw}"`);
100
+ return { scheme, url, ref: ref || void 0 };
101
+ }
88
102
  default:
89
103
  throw new Error(`\uC54C \uC218 \uC5C6\uB294 \uC2A4\uD0B4 "${scheme}": "${raw}"`);
90
104
  }
91
105
  }
106
+ function formatSource(s) {
107
+ switch (s.scheme) {
108
+ case "file":
109
+ return `file:${s.path}`;
110
+ case "github":
111
+ return `github:${s.owner}/${s.repo}${s.ref ? `@${s.ref}` : ""}${s.subpath ? `#${s.subpath}` : ""}`;
112
+ case "git":
113
+ return `git:${s.url}${s.ref ? `#${s.ref}` : ""}`;
114
+ }
115
+ }
116
+ function cloneTarget(s) {
117
+ if (s.scheme === "github") return { url: `https://github.com/${s.owner}/${s.repo}.git`, ref: s.ref };
118
+ if (s.scheme === "git") return { url: s.url, ref: s.ref };
119
+ throw new Error(`file: \uCD9C\uCC98\uB294 clone \uB300\uC0C1\uC774 \uC544\uB2D9\uB2C8\uB2E4`);
120
+ }
121
+ function sourceFromRemote(url, ref) {
122
+ const m = /^(?:https?:\/\/github\.com\/|git@github\.com:)([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url);
123
+ if (m) return formatSource({ scheme: "github", owner: m[1], repo: m[2], ref });
124
+ return formatSource({ scheme: "git", url, ref });
125
+ }
92
126
 
93
127
  // src/manifest.ts
94
128
  var ComponentSchema = z.object({
@@ -96,12 +130,22 @@ var ComponentSchema = z.object({
96
130
  source: z.string().optional(),
97
131
  tags: z.array(z.string()).optional()
98
132
  });
133
+ var PackageSchema = z.object({
134
+ id: z.string().regex(/^[\w.-]+$/, "id\uB294 \uC601\uBB38\xB7\uC22B\uC790\xB7._- \uB9CC \uD5C8\uC6A9"),
135
+ source: z.string(),
136
+ into: z.string().regex(/^[^/\\][^\\]*$/, "into \uB294 \uB8E8\uD2B8 \uAE30\uC900 \uC0C1\uB300 \uACBD\uB85C (POSIX)"),
137
+ install: z.string().optional()
138
+ });
139
+ var PACKAGES = "packages";
99
140
  var ComponentsSchema = z.record(z.string(), z.array(ComponentSchema));
100
141
  var ProfileSchema = z.record(z.string(), z.array(z.string()));
101
142
  var ManifestSchema = z.object({
102
143
  version: z.literal(1),
103
144
  agent: z.string().default("claude-code"),
145
+ /** 창고에 담지 않을 이름들. 기본값(DEFAULT_IGNORE)에 더해진다. */
146
+ ignore: z.array(z.string()).optional(),
104
147
  components: ComponentsSchema.default({}),
148
+ packages: z.array(PackageSchema).default([]),
105
149
  profiles: z.record(z.string(), ProfileSchema).default({})
106
150
  });
107
151
  var ManifestError = class extends Error {
@@ -131,8 +175,22 @@ ${lines.join("\n")}`);
131
175
  }
132
176
  }
133
177
  }
178
+ const pkgIds = /* @__PURE__ */ new Set();
179
+ for (const p of m.packages) {
180
+ if (pkgIds.has(p.id)) problems.push(`packages: id "${p.id}" \uC911\uBCF5`);
181
+ pkgIds.add(p.id);
182
+ try {
183
+ if (parseSource(p.source).scheme === "file") problems.push(`packages/${p.id}: \uD328\uD0A4\uC9C0 \uCD9C\uCC98\uB294 github: \uB610\uB294 git: \uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4`);
184
+ } catch (e) {
185
+ problems.push(`packages/${p.id}: ${e.message}`);
186
+ }
187
+ }
134
188
  for (const [profile, cats] of Object.entries(m.profiles)) {
135
189
  for (const [cat, ids] of Object.entries(cats)) {
190
+ if (cat === PACKAGES) {
191
+ for (const id of ids) if (!pkgIds.has(id)) problems.push(`profiles.${profile}.packages: "${id}" \uB294 packages \uC5D0 \uC5C6\uC74C`);
192
+ continue;
193
+ }
136
194
  const available = new Set((m.components[cat] ?? []).map((c) => c.id));
137
195
  for (const id of ids) {
138
196
  if (!available.has(id)) problems.push(`profiles.${profile}.${cat}: "${id}" \uB294 components\uC5D0 \uC5C6\uC74C`);
@@ -147,7 +205,14 @@ function effectiveSource(category, c, kind = "dir") {
147
205
  return c.source ?? `file:./${category}/${c.id}${kind === "file" ? ".md" : ""}`;
148
206
  }
149
207
  function stringifyManifest(m) {
150
- return YAML.stringify(m, { lineWidth: 0 });
208
+ const out = { ...m };
209
+ if (!m.packages.length) delete out.packages;
210
+ if (!m.ignore?.length) delete out.ignore;
211
+ return YAML.stringify(out, { lineWidth: 0 });
212
+ }
213
+ function packagesOf(m, profile) {
214
+ const ids = m.profiles[profile]?.[PACKAGES] ?? [];
215
+ return ids.map((id) => m.packages.find((p) => p.id === id));
151
216
  }
152
217
 
153
218
  // src/resolvers/file.ts
@@ -191,6 +256,26 @@ async function writeState(adapter, state) {
191
256
  await fs2.writeFile(p, JSON.stringify(state, null, 2) + "\n");
192
257
  }
193
258
 
259
+ // src/ignore.ts
260
+ var DEFAULT_IGNORE = [
261
+ "node_modules",
262
+ ".git",
263
+ "__pycache__",
264
+ ".venv",
265
+ ".mypy_cache",
266
+ ".pytest_cache",
267
+ ".DS_Store",
268
+ "*.log"
269
+ ];
270
+ function matches(name, pattern) {
271
+ if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
272
+ return name === pattern;
273
+ }
274
+ function isIgnored(rel, patterns) {
275
+ if (!rel) return false;
276
+ return rel.split("/").some((seg) => patterns.some((p) => matches(seg, p)));
277
+ }
278
+
194
279
  // src/core/context.ts
195
280
  var MANIFEST_FILE = "lshed.yaml";
196
281
  var INSTRUCTIONS = "instructions";
@@ -209,7 +294,12 @@ async function loadManifest(ctx) {
209
294
  throw new Error(`\uCC3D\uACE0\uC5D0 ${MANIFEST_FILE} \uC774 \uC5C6\uC2B5\uB2C8\uB2E4: ${ctx.shed}
210
295
  \uBA3C\uC800 'lshed init --shed ${ctx.shed}' \uB97C \uC2E4\uD589\uD558\uC138\uC694.`);
211
296
  }
212
- return parseManifest(text, knownCategories(ctx.adapter));
297
+ const m = parseManifest(text, knownCategories(ctx.adapter));
298
+ ctx.ignore = [...DEFAULT_IGNORE, ...m.ignore ?? []];
299
+ return m;
300
+ }
301
+ function ignoreOf(ctx) {
302
+ return ctx.ignore ?? DEFAULT_IGNORE;
213
303
  }
214
304
  function targetRel(cat, id) {
215
305
  if (cat === INSTRUCTIONS) return `${FRAGMENTS_DIR}/${id}.md`;
@@ -221,7 +311,7 @@ function sourcePath(ctx, category, c) {
221
311
  return resolveSource(ctx.shed, effectiveSource(category, c, kind));
222
312
  }
223
313
  function findComponent(m, category, id) {
224
- const c = (m.components[category] ?? []).find((x) => x.id === id);
314
+ const c = (m.components[category] ?? []).find((x2) => x2.id === id);
225
315
  if (!c) throw new Error(`${category}/${id} \uB294 components \uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4`);
226
316
  return c;
227
317
  }
@@ -233,6 +323,7 @@ function planProfile(ctx, m, profile) {
233
323
  }
234
324
  const items = [];
235
325
  for (const [category, ids] of Object.entries(p)) {
326
+ if (category === PACKAGES) continue;
236
327
  const cat = category === INSTRUCTIONS ? INSTRUCTIONS : ctx.adapter.categories().find((k) => k.name === category);
237
328
  if (!cat) throw new Error(`\uD504\uB85C\uD544 "${profile}": \uC5B4\uB311\uD130 ${ctx.adapter.name} \uC740 \uCE74\uD14C\uACE0\uB9AC "${category}" \uB97C \uBAA8\uB985\uB2C8\uB2E4`);
238
329
  for (const id of ids) {
@@ -265,7 +356,7 @@ async function isDir(p) {
265
356
  return false;
266
357
  }
267
358
  }
268
- async function listFiles(root) {
359
+ async function listFiles(root, ignore = DEFAULT_IGNORE) {
269
360
  if (!await exists(root)) return [];
270
361
  if (!await isDir(root)) return [""];
271
362
  const out = [];
@@ -273,8 +364,16 @@ async function listFiles(root) {
273
364
  const entries = await fs4.readdir(dir, { withFileTypes: true });
274
365
  for (const e of entries) {
275
366
  const r = rel ? `${rel}/${e.name}` : e.name;
276
- if (e.isDirectory()) await walk(path5.join(dir, e.name), r);
277
- else if (e.isFile()) out.push(r);
367
+ if (isIgnored(r, ignore)) continue;
368
+ const full = path5.join(dir, e.name);
369
+ let st;
370
+ try {
371
+ st = await fs4.stat(full);
372
+ } catch {
373
+ continue;
374
+ }
375
+ if (st.isDirectory()) await walk(full, r);
376
+ else if (st.isFile()) out.push(r);
278
377
  }
279
378
  }
280
379
  await walk(root, "");
@@ -283,25 +382,39 @@ async function listFiles(root) {
283
382
  async function hashFile(p) {
284
383
  return createHash("sha256").update(await fs4.readFile(p)).digest("hex");
285
384
  }
286
- async function hashTree(root) {
385
+ async function hashTree(root, ignore = DEFAULT_IGNORE) {
287
386
  if (!await exists(root)) return null;
288
387
  const h = createHash("sha256");
289
- for (const rel of await listFiles(root)) {
388
+ for (const rel of await listFiles(root, ignore)) {
290
389
  h.update(rel).update("\0").update(await fs4.readFile(rel ? path5.join(root, rel) : root)).update("\0");
291
390
  }
292
391
  return h.digest("hex");
293
392
  }
294
- async function copyTree(src, dst) {
393
+ async function copyTree(src, dst, ignore = DEFAULT_IGNORE) {
295
394
  await fs4.rm(dst, { recursive: true, force: true });
296
395
  await fs4.mkdir(path5.dirname(dst), { recursive: true });
297
- await fs4.cp(src, dst, { recursive: true });
396
+ const srcRoot = path5.resolve(src);
397
+ await fs4.cp(src, dst, {
398
+ recursive: true,
399
+ dereference: true,
400
+ filter: async (from) => {
401
+ const rel = path5.relative(srcRoot, path5.resolve(from)).split(path5.sep).join("/");
402
+ if (isIgnored(rel, ignore)) return false;
403
+ try {
404
+ if ((await fs4.lstat(from)).isSymbolicLink()) await fs4.stat(from);
405
+ } catch {
406
+ return false;
407
+ }
408
+ return true;
409
+ }
410
+ });
298
411
  }
299
412
  async function removeTree(p) {
300
413
  await fs4.rm(p, { recursive: true, force: true });
301
414
  }
302
- async function diffTrees(local, shed) {
303
- const l = new Set(await listFiles(local));
304
- const s = new Set(await listFiles(shed));
415
+ async function diffTrees(local, shed, ignore = DEFAULT_IGNORE) {
416
+ const l = new Set(await listFiles(local, ignore));
417
+ const s = new Set(await listFiles(shed, ignore));
305
418
  const out = [];
306
419
  for (const f of [.../* @__PURE__ */ new Set([...l, ...s])].sort()) {
307
420
  const lp = f ? path5.join(local, f) : local;
@@ -323,37 +436,229 @@ function isGenerated(text) {
323
436
  return text.trimStart().startsWith(MARKER);
324
437
  }
325
438
  function renderInstructions(ctx, profile, fragments) {
326
- const head = `${MARKER}; profile: ${profile} -->
439
+ const head2 = `${MARKER}; profile: ${profile} -->
327
440
  <!-- Do not edit this file. Edit the fragments in your shed and run 'lshed restore'. -->
328
441
 
329
442
  `;
330
443
  if (ctx.adapter.instructionsStrategy() === "import") {
331
- return head + fragments.map((f) => `@${FRAGMENTS_DIR}/${f.id}.md`).join("\n") + "\n";
444
+ return head2 + fragments.map((f) => `@${FRAGMENTS_DIR}/${f.id}.md`).join("\n") + "\n";
332
445
  }
333
- return head + fragments.map((f) => `<!-- ${f.id} -->
446
+ return head2 + fragments.map((f) => `<!-- ${f.id} -->
334
447
  ${f.content.trimEnd()}
335
448
  `).join("\n");
336
449
  }
337
450
 
451
+ // src/core/packages.ts
452
+ import { promises as fs6 } from "fs";
453
+ import path9 from "path";
454
+
455
+ // src/git.ts
456
+ import { execFile, spawn } from "child_process";
457
+ import { promisify } from "util";
458
+ import path7 from "path";
459
+ var x = promisify(execFile);
460
+ async function git(args, cwd) {
461
+ const { stdout } = await x("git", args, { cwd, maxBuffer: 1 << 24 });
462
+ return stdout.trim();
463
+ }
464
+ var isRepo = (dir) => exists(path7.join(dir, ".git"));
465
+ var remoteUrl = (dir) => git(["remote", "get-url", "origin"], dir).catch(() => null);
466
+ var head = (dir) => git(["rev-parse", "HEAD"], dir);
467
+ var branch = async (dir) => {
468
+ const b = await git(["rev-parse", "--abbrev-ref", "HEAD"], dir);
469
+ return b === "HEAD" ? void 0 : b;
470
+ };
471
+ var clone = (url, dir, ref) => git(["clone", "--quiet", ...ref ? ["--branch", ref] : [], url, dir]);
472
+ var resetHard = (dir, sha) => git(["reset", "--hard", "--quiet", sha], dir);
473
+ var pullFf = (dir) => git(["pull", "--ff-only", "--quiet"], dir);
474
+ function runShell(cmd, cwd) {
475
+ return new Promise((resolve, reject) => {
476
+ const p = spawn("sh", ["-c", cmd], { cwd, stdio: "inherit" });
477
+ p.on("error", reject);
478
+ p.on("close", (code) => code === 0 ? resolve() : reject(new Error(`\uBA85\uB839\uC774 ${code} \uB85C \uB05D\uB0AC\uC2B5\uB2C8\uB2E4: ${cmd}`)));
479
+ });
480
+ }
481
+
482
+ // src/lock.ts
483
+ import { promises as fs5 } from "fs";
484
+ import path8 from "path";
485
+ import YAML2 from "yaml";
486
+ import { z as z3 } from "zod";
487
+ var LockSchema = z3.object({
488
+ version: z3.literal(1),
489
+ packages: z3.record(z3.string(), z3.object({ source: z3.string(), commit: z3.string() })).default({})
490
+ });
491
+ var LOCK_FILE = "lshed.lock";
492
+ async function readLock(shed) {
493
+ try {
494
+ return LockSchema.parse(YAML2.parse(await fs5.readFile(path8.join(shed, LOCK_FILE), "utf8")));
495
+ } catch (e) {
496
+ if (e.code === "ENOENT") return { version: 1, packages: {} };
497
+ throw new Error(`${LOCK_FILE} \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${e.message}`);
498
+ }
499
+ }
500
+ async function writeLock(shed, lock) {
501
+ const sorted = { version: 1, packages: Object.fromEntries(Object.entries(lock.packages).sort()) };
502
+ await fs5.writeFile(path8.join(shed, LOCK_FILE), "# generated by lshed \u2014 do not edit; 'lshed update' refreshes it\n" + YAML2.stringify(sorted));
503
+ }
504
+
505
+ // src/core/packages.ts
506
+ async function detectPackages(ctx, found) {
507
+ const out = [];
508
+ for (const f of found) {
509
+ if (!await isRepo(f.path)) continue;
510
+ const url = await remoteUrl(f.path);
511
+ if (!url) continue;
512
+ const into = path9.relative(ctx.adapter.root, f.path).split(path9.sep).join("/");
513
+ out.push({ id: f.id, into, source: sourceFromRemote(url, await branch(f.path)), commit: await head(f.path), path: f.path });
514
+ }
515
+ return out;
516
+ }
517
+ async function detectGenerated(found, pkgs) {
518
+ const out = /* @__PURE__ */ new Map();
519
+ if (!pkgs.length) return out;
520
+ const roots = await Promise.all(pkgs.map(async (p) => ({ id: p.id, real: await fs6.realpath(p.path) })));
521
+ for (const f of found) {
522
+ if (pkgs.some((p) => p.path === f.path)) continue;
523
+ let entries;
524
+ try {
525
+ if (!(await fs6.stat(f.path)).isDirectory()) continue;
526
+ entries = await fs6.readdir(f.path);
527
+ } catch {
528
+ continue;
529
+ }
530
+ for (const name of entries) {
531
+ const p = path9.join(f.path, name);
532
+ let target;
533
+ try {
534
+ if (!(await fs6.lstat(p)).isSymbolicLink()) continue;
535
+ target = await fs6.realpath(p).catch(async () => path9.resolve(f.path, await fs6.readlink(p)));
536
+ } catch {
537
+ continue;
538
+ }
539
+ const owner = roots.find((r) => target === r.real || target.startsWith(r.real + path9.sep));
540
+ if (owner) {
541
+ out.set(`${f.category}/${f.id}`, owner.id);
542
+ break;
543
+ }
544
+ }
545
+ }
546
+ return out;
547
+ }
548
+ async function packageStatus(ctx, pkg, lock) {
549
+ const dir = abs(ctx, pkg.into);
550
+ const present = await isRepo(dir);
551
+ return { pkg, dir, present, head: present ? await head(dir) : void 0, locked: lock.packages[pkg.id]?.commit };
552
+ }
553
+ async function ensurePackages(ctx, pkgs, opts = {}) {
554
+ const lock = await readLock(ctx.shed);
555
+ const res = { cloned: [], pendingInstalls: [], lockChanged: false };
556
+ for (const pkg of pkgs) {
557
+ const st = await packageStatus(ctx, pkg, lock);
558
+ if (st.present) {
559
+ const note = st.locked && st.head !== st.locked ? ` (HEAD ${st.head.slice(0, 7)} \u2260 lock ${st.locked.slice(0, 7)})` : "";
560
+ ctx.log(` = package ${pkg.id}${note}`);
561
+ continue;
562
+ }
563
+ if (await exists(st.dir)) throw new Error(`package ${pkg.id}: ${st.dir} \uAC00 \uC788\uC9C0\uB9CC git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2D9\uB2C8\uB2E4. \uCE58\uC6B0\uAC70\uB098 into \uB97C \uBC14\uAFB8\uC138\uC694.`);
564
+ const { url, ref } = cloneTarget(parseSource(pkg.source));
565
+ ctx.log(` + package ${pkg.id} (clone ${url}${ref ? ` @${ref}` : ""}${st.locked ? ` \u2192 ${st.locked.slice(0, 7)}` : ""})`);
566
+ if (opts.dryRun) continue;
567
+ await fs6.mkdir(path9.dirname(st.dir), { recursive: true });
568
+ await clone(url, st.dir, ref);
569
+ if (st.locked) {
570
+ await resetHard(st.dir, st.locked).catch(() => {
571
+ throw new Error(`package ${pkg.id}: \uB77D\uC758 \uCEE4\uBC0B ${st.locked.slice(0, 7)} \uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. 'lshed update ${pkg.id}' \uB85C \uB77D\uC744 \uAC31\uC2E0\uD558\uC138\uC694.`);
572
+ });
573
+ } else {
574
+ lock.packages[pkg.id] = { source: pkg.source, commit: await head(st.dir) };
575
+ res.lockChanged = true;
576
+ }
577
+ res.cloned.push(pkg.id);
578
+ if (pkg.install) await maybeInstall(ctx, pkg, st.dir, opts, res);
579
+ }
580
+ if (res.lockChanged) await writeLock(ctx.shed, lock);
581
+ return res;
582
+ }
583
+ async function maybeInstall(ctx, pkg, dir, opts, res) {
584
+ if (!pkg.install) return;
585
+ if (opts.yes) {
586
+ ctx.log(` $ (${pkg.into}) ${pkg.install}`);
587
+ await runShell(pkg.install, dir);
588
+ } else {
589
+ res.pendingInstalls.push({ id: pkg.id, dir, cmd: pkg.install });
590
+ }
591
+ }
592
+ function reportPending(ctx, res) {
593
+ if (!res.pendingInstalls.length) return;
594
+ ctx.log(`
595
+ \uC124\uCE58 \uBA85\uB839 ${res.pendingInstalls.length}\uAC1C\uB97C \uC2E4\uD589\uD558\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. \uD655\uC778 \uD6C4 '--yes' \uB85C \uB2E4\uC2DC \uC2E4\uD589\uD558\uAC70\uB098 \uC9C1\uC811 \uB3CC\uB9AC\uC138\uC694:`);
596
+ for (const p of res.pendingInstalls) ctx.log(` cd ${p.dir} && ${p.cmd}`);
597
+ }
598
+ async function updatePackages(ctx, pkgs, opts = {}) {
599
+ const lock = await readLock(ctx.shed);
600
+ const res = { cloned: [], pendingInstalls: [], lockChanged: false };
601
+ for (const pkg of pkgs) {
602
+ const st = await packageStatus(ctx, pkg, lock);
603
+ if (!st.present) {
604
+ ctx.log(` ! package ${pkg.id}: \uC124\uCE58\uB418\uC5B4 \uC788\uC9C0 \uC54A\uC74C. \uBA3C\uC800 restore \uD558\uC138\uC694`);
605
+ continue;
606
+ }
607
+ if (opts.dryRun) {
608
+ ctx.log(` ~ package ${pkg.id} (git pull --ff-only)`);
609
+ continue;
610
+ }
611
+ await pullFf(st.dir);
612
+ const now = await head(st.dir);
613
+ const before = lock.packages[pkg.id]?.commit;
614
+ if (now !== before) {
615
+ lock.packages[pkg.id] = { source: pkg.source, commit: now };
616
+ res.lockChanged = true;
617
+ ctx.log(` \u2191 package ${pkg.id} ${before ? before.slice(0, 7) : "(\uC5C6\uC74C)"} \u2192 ${now.slice(0, 7)}`);
618
+ await maybeInstall(ctx, pkg, st.dir, opts, res);
619
+ } else {
620
+ ctx.log(` = package ${pkg.id} ${now.slice(0, 7)} (\uCD5C\uC2E0)`);
621
+ }
622
+ }
623
+ if (res.lockChanged) await writeLock(ctx.shed, lock);
624
+ return res;
625
+ }
626
+
338
627
  // src/core/init.ts
339
628
  async function init(ctx, opts = {}) {
340
629
  const profileName = opts.profile ?? "default";
630
+ const exclude = opts.exclude ?? [];
631
+ const skipped = [];
341
632
  if (await exists(manifestPath(ctx))) {
342
633
  throw new Error(`\uC774\uBBF8 \uCD08\uAE30\uD654\uB41C \uCC3D\uACE0\uC785\uB2C8\uB2E4: ${manifestPath(ctx)}
343
634
  \uB2E4\uB978 \uD658\uACBD\uC758 \uC124\uC815\uC744 \uC774 \uCC3D\uACE0\uB85C \uAC00\uC838\uC624\uB824\uBA74 'lshed restore' \uD6C4 'lshed save' \uB97C \uC4F0\uC138\uC694.`);
344
635
  }
345
- const found = await ctx.adapter.scan();
346
- const m = { version: 1, agent: ctx.adapter.name, components: {}, profiles: { [profileName]: {} } };
636
+ const all = await ctx.adapter.scan();
637
+ const m = { version: 1, agent: ctx.adapter.name, components: {}, packages: [], profiles: { [profileName]: {} } };
638
+ const isExcluded = (cat, id) => exclude.some((e) => e === id || e === `${cat}/${id}`);
639
+ const pkgs = (await detectPackages(ctx, all)).filter((p) => !isExcluded("", p.id));
640
+ const generated = await detectGenerated(all, pkgs);
641
+ const found = all.filter((f) => !pkgs.some((p) => p.path === f.path) && !generated.has(`${f.category}/${f.id}`));
642
+ for (const p of pkgs) {
643
+ m.packages.push({ id: p.id, source: p.source, into: p.into });
644
+ ctx.log(` \u2261 package ${p.id} ${p.source} @${p.commit.slice(0, 7)} (\uCC38\uC870\uB9CC \uAE30\uB85D)`);
645
+ }
646
+ if (pkgs.length) m.profiles[profileName][PACKAGES] = pkgs.map((p) => p.id);
647
+ for (const [key, by] of generated) ctx.log(` \xB7 ${key} (${by} \uAC00 \uC0DD\uC131\uD55C \uAC83 \u2192 \uAC74\uB108\uB700)`);
347
648
  const managed = [];
348
649
  let copied = 0;
349
650
  for (const cat of ctx.adapter.categories()) {
350
- const mine = found.filter((f) => f.category === cat.name);
651
+ const mine = found.filter((f) => f.category === cat.name && !isExcluded(f.category, f.id));
652
+ for (const f of found.filter((f2) => f2.category === cat.name && isExcluded(f2.category, f2.id))) {
653
+ skipped.push(`${f.category}/${f.id}`);
654
+ ctx.log(` - ${f.category}/${f.id} (--exclude)`);
655
+ }
351
656
  if (!mine.length) continue;
352
657
  m.components[cat.name] = [];
353
658
  m.profiles[profileName][cat.name] = [];
354
659
  for (const f of mine) {
355
- const dst = path7.join(ctx.shed, cat.root, cat.kind === "dir" ? f.id : `${f.id}.md`);
356
- await copyTree(f.path, dst);
660
+ const dst = path10.join(ctx.shed, cat.root, cat.kind === "dir" ? f.id : `${f.id}.md`);
661
+ await copyTree(f.path, dst, ignoreOf(ctx));
357
662
  m.components[cat.name].push({ id: f.id });
358
663
  m.profiles[profileName][cat.name].push(f.id);
359
664
  managed.push(targetRel(cat, f.id));
@@ -363,32 +668,47 @@ async function init(ctx, opts = {}) {
363
668
  }
364
669
  const instr = instructionsFile(ctx);
365
670
  if (await exists(instr)) {
366
- const text = await fs5.readFile(instr, "utf8");
671
+ const text = await fs7.readFile(instr, "utf8");
367
672
  if (!isGenerated(text)) {
368
- const dst = path7.join(ctx.shed, INSTRUCTIONS, "main.md");
369
- await fs5.mkdir(path7.dirname(dst), { recursive: true });
370
- await fs5.writeFile(dst, text);
673
+ const dst = path10.join(ctx.shed, INSTRUCTIONS, "main.md");
674
+ await fs7.mkdir(path10.dirname(dst), { recursive: true });
675
+ await fs7.writeFile(dst, text);
371
676
  const fragRel = targetRel(INSTRUCTIONS, "main");
372
- await copyTree(dst, abs(ctx, fragRel));
677
+ await copyTree(dst, abs(ctx, fragRel), ignoreOf(ctx));
373
678
  managed.push(fragRel);
374
679
  m.components[INSTRUCTIONS] = [{ id: "main" }];
375
680
  m.profiles[profileName][INSTRUCTIONS] = ["main"];
376
681
  copied++;
377
- ctx.log(` + ${INSTRUCTIONS}/main (${path7.basename(instr)})`);
682
+ ctx.log(` + ${INSTRUCTIONS}/main (${path10.basename(instr)})`);
378
683
  }
379
684
  }
380
- await fs5.mkdir(ctx.shed, { recursive: true });
381
- await fs5.writeFile(manifestPath(ctx), `# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed
382
- ` + stringifyManifest(m));
685
+ await fs7.mkdir(ctx.shed, { recursive: true });
686
+ let yamlText = stringifyManifest(m);
687
+ for (const p of pkgs) {
688
+ yamlText = yamlText.replace(` into: ${p.into}
689
+ `, ` into: ${p.into}
690
+ # install: ./setup # \u2190 \uBCF5\uC6D0 \uD6C4 \uC2E4\uD589\uD560 \uBA85\uB839\uC774 \uC788\uC73C\uBA74 \uCC44\uC6B0\uC138\uC694 (--yes \uB85C \uC2E4\uD589)
691
+ `);
692
+ }
693
+ await fs7.writeFile(manifestPath(ctx), `# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed
694
+ ` + yamlText);
695
+ if (pkgs.length) {
696
+ await writeLock(ctx.shed, { version: 1, packages: Object.fromEntries(pkgs.map((p) => [p.id, { source: p.source, commit: p.commit }])) });
697
+ }
383
698
  await writeState(ctx.adapter, { profile: profileName, shed: ctx.shed, managed, appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
699
+ const parts = [`\uBD80\uD488 ${copied}\uAC1C`];
700
+ if (pkgs.length) parts.push(`\uD328\uD0A4\uC9C0 ${pkgs.length}\uAC1C`);
701
+ if (generated.size) parts.push(`\uC0DD\uC131\uBB3C ${generated.size}\uAC1C \uAC74\uB108\uB700`);
702
+ if (skipped.length) parts.push(`\uC81C\uC678 ${skipped.length}\uAC1C`);
384
703
  ctx.log(`
385
- ${MANIFEST_FILE} \uC0DD\uC131: ${manifestPath(ctx)} (\uBD80\uD488 ${copied}\uAC1C, \uD504\uB85C\uD544 "${profileName}")`);
386
- return { manifest: m, copied };
704
+ ${MANIFEST_FILE} \uC0DD\uC131: ${manifestPath(ctx)} (${parts.join(", ")}, \uD504\uB85C\uD544 "${profileName}")`);
705
+ if (pkgs.length) ctx.log(`\uD328\uD0A4\uC9C0\uC758 \uC124\uCE58 \uBA85\uB839(install:)\uC740 lshed.yaml \uC5D0\uC11C \uC9C1\uC811 \uCC44\uC6B0\uC138\uC694.`);
706
+ return { manifest: m, copied, skipped, packages: pkgs.map((p) => p.id), generated: [...generated.keys()] };
387
707
  }
388
708
 
389
709
  // src/core/restore.ts
390
- import { promises as fs6 } from "fs";
391
- import path8 from "path";
710
+ import { promises as fs8 } from "fs";
711
+ import path11 from "path";
392
712
  async function restore(ctx, profileArg, opts = {}) {
393
713
  const backup = opts.backup ?? true;
394
714
  const state = await readState(ctx.adapter);
@@ -399,6 +719,7 @@ async function restore(ctx, profileArg, opts = {}) {
399
719
  for (const it of plan) {
400
720
  if (!await exists(it.src)) throw new Error(`${it.category}/${it.id}: \uCC3D\uACE0\uC5D0 \uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4: ${it.src}`);
401
721
  }
722
+ const pkgRes = await ensurePackages(ctx, packagesOf(m, profile), { dryRun: opts.dryRun, yes: opts.yes });
402
723
  const instrRel = ctx.adapter.instructionsFileName();
403
724
  const fragments = plan.filter((p) => p.category === INSTRUCTIONS);
404
725
  const newManaged = new Set(plan.map((p) => p.rel));
@@ -406,7 +727,7 @@ async function restore(ctx, profileArg, opts = {}) {
406
727
  const oldManaged = new Set(state?.managed ?? []);
407
728
  const toRemove = [...oldManaged].filter((r) => !newManaged.has(r)).sort();
408
729
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
409
- const backupDir = path8.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
730
+ const backupDir = path11.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
410
731
  const backedUp = [];
411
732
  const placed = [];
412
733
  async function backUp(rel) {
@@ -414,7 +735,7 @@ async function restore(ctx, profileArg, opts = {}) {
414
735
  if (!await exists(from)) return;
415
736
  backedUp.push(rel);
416
737
  if (opts.dryRun || !backup) return;
417
- await copyTree(from, path8.join(backupDir, ...rel.split("/")));
738
+ await copyTree(from, path11.join(backupDir, ...rel.split("/")), ignoreOf(ctx));
418
739
  }
419
740
  for (const rel of toRemove) {
420
741
  ctx.log(` - ${rel}`);
@@ -423,7 +744,7 @@ async function restore(ctx, profileArg, opts = {}) {
423
744
  }
424
745
  for (const it of plan) {
425
746
  const target = abs(ctx, it.rel);
426
- const same = await hashTree(target) === await hashTree(it.src);
747
+ const same = await hashTree(target, ignoreOf(ctx)) === await hashTree(it.src, ignoreOf(ctx));
427
748
  const mark = same ? "=" : await exists(target) ? "~" : "+";
428
749
  ctx.log(` ${mark} ${it.rel}`);
429
750
  if (same) {
@@ -431,20 +752,20 @@ async function restore(ctx, profileArg, opts = {}) {
431
752
  continue;
432
753
  }
433
754
  if (await exists(target)) await backUp(it.rel);
434
- if (!opts.dryRun) await copyTree(it.src, target);
755
+ if (!opts.dryRun) await copyTree(it.src, target, ignoreOf(ctx));
435
756
  placed.push(it.rel);
436
757
  }
437
758
  const instrPath = instructionsFile(ctx);
438
759
  if (fragments.length) {
439
760
  const contents = [];
440
- for (const f of fragments) contents.push({ id: f.id, content: await fs6.readFile(f.src, "utf8") });
761
+ for (const f of fragments) contents.push({ id: f.id, content: await fs8.readFile(f.src, "utf8") });
441
762
  const rendered = renderInstructions(ctx, profile, contents);
442
- const existing = await exists(instrPath) ? await fs6.readFile(instrPath, "utf8") : null;
763
+ const existing = await exists(instrPath) ? await fs8.readFile(instrPath, "utf8") : null;
443
764
  if (existing !== rendered) {
444
765
  const mark = existing === null ? "+" : "~";
445
766
  ctx.log(` ${mark} ${instrRel}${existing !== null && !isGenerated(existing) ? " (\uAE30\uC874 \uD30C\uC77C\uC740 lshed \uC0DD\uC131\uBB3C\uC774 \uC544\uB2D8 \u2192 \uBC31\uC5C5)" : ""}`);
446
767
  if (existing !== null) await backUp(instrRel);
447
- if (!opts.dryRun) await fs6.writeFile(instrPath, rendered);
768
+ if (!opts.dryRun) await fs8.writeFile(instrPath, rendered);
448
769
  } else {
449
770
  ctx.log(` = ${instrRel}`);
450
771
  }
@@ -453,12 +774,14 @@ async function restore(ctx, profileArg, opts = {}) {
453
774
  if (opts.dryRun) {
454
775
  ctx.log(`
455
776
  (dry-run) \uBCC0\uACBD \uC5C6\uC74C. \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}, \uBC31\uC5C5 \uC608\uC815 ${backedUp.length}`);
777
+ reportPending(ctx, pkgRes);
456
778
  return { profile, placed, removed: toRemove, backedUp, backupDir: null };
457
779
  }
458
780
  await writeState(ctx.adapter, { profile, shed: ctx.shed, managed: [...newManaged].sort(), appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
459
781
  const bdir = backup && backedUp.length ? backupDir : null;
460
782
  ctx.log(`
461
- \uD504\uB85C\uD544 "${profile}" \uC801\uC6A9: \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}${bdir ? `, \uBC31\uC5C5 ${backedUp.length} \u2192 ${bdir}` : ""}`);
783
+ \uD504\uB85C\uD544 "${profile}" \uC801\uC6A9: \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}${pkgRes.cloned.length ? `, \uD328\uD0A4\uC9C0 clone ${pkgRes.cloned.length}` : ""}${bdir ? `, \uBC31\uC5C5 ${backedUp.length} \u2192 ${bdir}` : ""}`);
784
+ reportPending(ctx, pkgRes);
462
785
  return { profile, placed, removed: toRemove, backedUp, backupDir: bdir };
463
786
  }
464
787
 
@@ -469,7 +792,7 @@ async function diff(ctx) {
469
792
  const m = await loadManifest(ctx);
470
793
  const out = [];
471
794
  for (const item of planProfile(ctx, m, state.profile)) {
472
- const changes = await diffTrees(abs(ctx, item.rel), item.src);
795
+ const changes = await diffTrees(abs(ctx, item.rel), item.src, ignoreOf(ctx));
473
796
  if (changes.length) out.push({ item, changes });
474
797
  }
475
798
  return out;
@@ -488,9 +811,12 @@ function formatDiff(diffs) {
488
811
  // src/core/status.ts
489
812
  async function status(ctx) {
490
813
  const state = await readState(ctx.adapter);
491
- if (!state) return { state: null, drifted: [] };
814
+ if (!state) return { state: null, drifted: [], packages: [] };
492
815
  const d = await diff(ctx);
493
- return { state, drifted: d.map((x) => `${x.item.category}/${x.item.id}`) };
816
+ const m = await loadManifest(ctx);
817
+ const lock = await readLock(ctx.shed);
818
+ const packages = await Promise.all(packagesOf(m, state.profile).map((p) => packageStatus(ctx, p, lock)));
819
+ return { state, drifted: d.map((x2) => `${x2.item.category}/${x2.item.id}`), packages };
494
820
  }
495
821
  function formatStatus(s, adapterRoot) {
496
822
  if (!s.state) return `\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4 (${adapterRoot}).
@@ -502,6 +828,10 @@ function formatStatus(s, adapterRoot) {
502
828
  `\uAD00\uB9AC \uC911 ${s.state.managed.length}\uAC1C \uACBD\uB85C (${adapterRoot})`
503
829
  ];
504
830
  lines.push(s.drifted.length ? `\uB4DC\uB9AC\uD504\uD2B8 ${s.drifted.length}\uAC1C: ${s.drifted.join(", ")} \u2192 lshed diff` : "\uB4DC\uB9AC\uD504\uD2B8 \uC5C6\uC74C");
831
+ for (const p of s.packages) {
832
+ const where = !p.present ? "\uC124\uCE58 \uC548 \uB428 \u2192 lshed restore" : !p.locked ? `${p.head.slice(0, 7)} (\uB77D \uC5C6\uC74C)` : p.head === p.locked ? `${p.head.slice(0, 7)} = lock` : `${p.head.slice(0, 7)} \u2260 lock ${p.locked.slice(0, 7)} \u2192 lshed update`;
833
+ lines.push(`\uD328\uD0A4\uC9C0 ${p.pkg.id} ${where}`);
834
+ }
505
835
  return lines.join("\n");
506
836
  }
507
837
 
@@ -533,8 +863,8 @@ async function save(ctx, ids = []) {
533
863
  ctx.log(` ! ${it.category}/${it.id}: \uB85C\uCEEC\uC5D0 \uC5C6\uC74C (\uAC74\uB108\uB700)`);
534
864
  continue;
535
865
  }
536
- if (await hashTree(local) === await hashTree(it.src)) continue;
537
- await copyTree(local, it.src);
866
+ if (await hashTree(local, ignoreOf(ctx)) === await hashTree(it.src, ignoreOf(ctx))) continue;
867
+ await copyTree(local, it.src, ignoreOf(ctx));
538
868
  saved.push(`${it.category}/${it.id}`);
539
869
  ctx.log(` \u2713 ${it.category}/${it.id} \u2192 \uCC3D\uACE0`);
540
870
  }
@@ -543,21 +873,115 @@ ${saved.length}\uAC1C \uBC18\uC601. \uCC3D\uACE0\uB97C \uCEE4\uBC0B/\uB3D9\uAE30
543
873
  return saved;
544
874
  }
545
875
 
876
+ // src/core/list.ts
877
+ function listRows(m) {
878
+ const usedBy = (cat, id) => Object.entries(m.profiles).filter(([, cats]) => (cats[cat] ?? []).includes(id)).map(([name]) => name);
879
+ const rows = [];
880
+ for (const [cat, comps] of Object.entries(m.components)) {
881
+ for (const c of comps) rows.push({ kind: "component", category: cat, id: c.id, usedBy: usedBy(cat, c.id) });
882
+ }
883
+ for (const p of m.packages) rows.push({ kind: "package", category: PACKAGES, id: p.id, usedBy: usedBy(PACKAGES, p.id) });
884
+ return rows;
885
+ }
886
+ function formatRows(rows, m) {
887
+ if (!rows.length) return "(\uBE44\uC5B4 \uC788\uC74C)";
888
+ const w = Math.max(...rows.map((r) => `${r.category}/${r.id}`.length));
889
+ const lines = rows.map((r) => {
890
+ const key = `${r.category}/${r.id}`.padEnd(w);
891
+ const use = r.usedBy.length ? r.usedBy.join(", ") : "(\uBBF8\uC0AC\uC6A9)";
892
+ return `${r.kind === "package" ? "\u2261" : " "} ${key} ${use}`;
893
+ });
894
+ const unused = rows.filter((r) => !r.usedBy.length).length;
895
+ lines.push("", `${rows.length}\uAC1C, \uD504\uB85C\uD544 ${Object.keys(m.profiles).length}\uAC1C${unused ? `, \uBBF8\uC0AC\uC6A9 ${unused}\uAC1C \u2192 lshed prune` : ""}`);
896
+ return lines.join("\n");
897
+ }
898
+
899
+ // src/core/remove.ts
900
+ import { promises as fs9 } from "fs";
901
+ import path12 from "path";
902
+ import YAML3, { isSeq, isMap } from "yaml";
903
+ function resolveKey(m, raw) {
904
+ const rows = listRows(m);
905
+ const [a, b] = raw.includes("/") ? raw.split("/", 2) : [void 0, raw];
906
+ const hits = rows.filter((r) => r.id === b && (a === void 0 || r.category === a));
907
+ if (!hits.length) throw new Error(`"${raw}" \uB294 \uCC3D\uACE0\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4`);
908
+ if (hits.length > 1) throw new Error(`"${raw}" \uAC00 \uBAA8\uD638\uD569\uB2C8\uB2E4: ${hits.map((h) => `${h.category}/${h.id}`).join(", ")}`);
909
+ return { category: hits[0].category, id: hits[0].id };
910
+ }
911
+ async function remove(ctx, raw) {
912
+ const m = await loadManifest(ctx);
913
+ const { category, id } = resolveKey(m, raw);
914
+ const users = listRows(m).find((r) => r.category === category && r.id === id).usedBy;
915
+ if (users.length) throw new Error(`${category}/${id} \uB294 \uD504\uB85C\uD544 ${users.join(", ")} \uC774 \uC4F0\uACE0 \uC788\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 \uD504\uB85C\uD544\uC5D0\uC11C \uBE7C\uC138\uC694.`);
916
+ const text = await fs9.readFile(manifestPath(ctx), "utf8");
917
+ const doc = YAML3.parseDocument(text);
918
+ let deleted;
919
+ if (category === PACKAGES) {
920
+ const seq = doc.get(PACKAGES);
921
+ if (!isSeq(seq)) throw new Error("packages \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4");
922
+ const idx = seq.items.findIndex((it) => isMap(it) && it.get("id") === id);
923
+ seq.delete(idx);
924
+ if (!seq.items.length) doc.delete(PACKAGES);
925
+ const lock = await readLock(ctx.shed);
926
+ if (lock.packages[id]) {
927
+ delete lock.packages[id];
928
+ await writeLock(ctx.shed, lock);
929
+ }
930
+ ctx.log(` - package ${id} (\uB9E4\uB2C8\uD398\uC2A4\uD2B8\xB7\uB77D\uC5D0\uC11C \uC81C\uAC70. \uB85C\uCEEC clone \uC740 \uADF8\uB300\uB85C)`);
931
+ } else {
932
+ const seq = doc.getIn(["components", category]);
933
+ if (!isSeq(seq)) throw new Error(`components.${category} \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4`);
934
+ const idx = seq.items.findIndex((it) => isMap(it) && it.get("id") === id);
935
+ seq.delete(idx);
936
+ if (!seq.items.length) doc.deleteIn(["components", category]);
937
+ const src = sourcePath(ctx, category, findComponent(m, category, id));
938
+ const inside = !path12.relative(ctx.shed, src).startsWith("..");
939
+ if (inside && await exists(src)) {
940
+ await removeTree(src);
941
+ deleted = src;
942
+ }
943
+ ctx.log(` - ${category}/${id}${deleted ? "" : " (\uCC3D\uACE0 \uBC16 \uACBD\uB85C\uB77C \uD30C\uC77C\uC740 \uB450\uC5C8\uC74C)"}`);
944
+ }
945
+ await fs9.writeFile(manifestPath(ctx), doc.toString());
946
+ return { category, id, deleted };
947
+ }
948
+ async function prune(ctx, opts = {}) {
949
+ const m = await loadManifest(ctx);
950
+ const unused = listRows(m).filter((r) => !r.usedBy.length);
951
+ if (!unused.length) {
952
+ ctx.log("\uBBF8\uC0AC\uC6A9 \uD56D\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
953
+ return [];
954
+ }
955
+ if (!opts.yes) {
956
+ ctx.log(`\uBBF8\uC0AC\uC6A9 ${unused.length}\uAC1C (\uC9C0\uC6B0\uB824\uBA74 --yes):`);
957
+ for (const r of unused) ctx.log(` ${r.category}/${r.id}`);
958
+ return [];
959
+ }
960
+ const removed = [];
961
+ for (const r of unused) {
962
+ await remove(ctx, `${r.category}/${r.id}`);
963
+ removed.push(`${r.category}/${r.id}`);
964
+ }
965
+ ctx.log(`
966
+ ${removed.length}\uAC1C \uC81C\uAC70. \uCC3D\uACE0\uB97C \uCEE4\uBC0B\uD558\uC138\uC694: ${ctx.shed}`);
967
+ return removed;
968
+ }
969
+
546
970
  // src/cli.ts
547
971
  var { version } = createRequire(import.meta.url)("../package.json");
548
972
  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)");
549
973
  function adapterFromOpts() {
550
974
  const { root } = program.opts();
551
- return new ClaudeCodeAdapter(root ? path9.resolve(root) : void 0);
975
+ return new ClaudeCodeAdapter(root ? path13.resolve(root) : void 0);
552
976
  }
553
977
  async function ctxFor(cmd) {
554
978
  const adapter = adapterFromOpts();
555
979
  const { shed: flag } = program.opts();
556
980
  let shed = flag ?? process.env.LSHED_HOME;
557
981
  if (!shed && cmd === "other") shed = (await readState(adapter))?.shed;
558
- if (!shed && cmd === "init") shed = path9.join(os2.homedir(), "lshed");
982
+ if (!shed && cmd === "init") shed = path13.join(os2.homedir(), "lshed");
559
983
  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.");
560
- return { adapter, shed: path9.resolve(shed), log: (l) => console.log(l) };
984
+ return { adapter, shed: path13.resolve(shed), log: (l) => console.log(l) };
561
985
  }
562
986
  async function run(fn) {
563
987
  try {
@@ -567,22 +991,42 @@ async function run(fn) {
567
991
  process.exitCode = 1;
568
992
  }
569
993
  }
570
- program.command("init").description("scan the current environment into a shed and write lshed.yaml").option("--profile <name>", "name of the initial profile", "default").action((o) => run(async () => {
994
+ program.command("init").description("scan the current environment into a shed and write lshed.yaml").option("--profile <name>", "name of the initial profile", "default").option("--exclude <id...>", "components to leave out (id or category/id)").action((o) => run(async () => {
571
995
  const ctx = await ctxFor("init");
572
996
  console.log(`\uC2A4\uCE94: ${ctx.adapter.root} \u2192 \uCC3D\uACE0: ${ctx.shed}`);
573
- await init(ctx, { profile: o.profile });
997
+ await init(ctx, { profile: o.profile, exclude: o.exclude });
574
998
  console.log(`
575
999
  \uB2E4\uC74C: \uCC3D\uACE0\uB97C git \uC73C\uB85C \uAD00\uB9AC\uD558\uC138\uC694. cd ${ctx.shed} && git init`);
576
1000
  }));
577
- program.command("restore [profile]").description("apply a profile (defaults to the last applied one)").option("--dry-run", "print what would change without touching anything").option("--no-backup", "skip backing up files that get replaced or removed").action((profile, o) => run(async () => {
1001
+ program.command("restore [profile]").description("apply a profile (defaults to the last applied one)").option("--dry-run", "print what would change without touching anything").option("--no-backup", "skip backing up files that get replaced or removed").option("--yes", "run package install commands (they are shown, not run, without this)").action((profile, o) => run(async () => {
578
1002
  const ctx = await ctxFor("other");
579
- await restore(ctx, profile, { dryRun: o.dryRun, backup: o.backup });
1003
+ await restore(ctx, profile, { dryRun: o.dryRun, backup: o.backup, yes: o.yes });
1004
+ }));
1005
+ program.command("update [ids...]").description("pull packages to their latest upstream and refresh lshed.lock").option("--dry-run", "show what would be updated").option("--yes", "run package install commands after updating").action((ids, o) => run(async () => {
1006
+ const ctx = await ctxFor("other");
1007
+ const state = await readState(ctx.adapter);
1008
+ if (!state) throw new Error("\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 'lshed restore <profile>' \uC744 \uC2E4\uD589\uD558\uC138\uC694.");
1009
+ const m = await loadManifest(ctx);
1010
+ let pkgs = packagesOf(m, state.profile);
1011
+ if (ids.length) {
1012
+ pkgs = ids.map((id) => {
1013
+ const p = m.packages.find((x2) => x2.id === id);
1014
+ if (!p) throw new Error(`\uD328\uD0A4\uC9C0 "${id}" \uAC00 \uC5C6\uC2B5\uB2C8\uB2E4`);
1015
+ return p;
1016
+ });
1017
+ }
1018
+ if (!pkgs.length) {
1019
+ console.log("\uAC31\uC2E0\uD560 \uD328\uD0A4\uC9C0\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.");
1020
+ return;
1021
+ }
1022
+ const res = await updatePackages(ctx, pkgs, { dryRun: o.dryRun, yes: o.yes });
1023
+ reportPending(ctx, res);
580
1024
  }));
581
1025
  program.command("status").description("show the applied profile, managed paths and drift").action(() => run(async () => {
582
1026
  const adapter = adapterFromOpts();
583
1027
  const state = await readState(adapter);
584
1028
  if (!state) {
585
- console.log(formatStatus({ state: null, drifted: [] }, adapter.root));
1029
+ console.log(formatStatus({ state: null, drifted: [], packages: [] }, adapter.root));
586
1030
  return;
587
1031
  }
588
1032
  const ctx = await ctxFor("other");
@@ -596,6 +1040,20 @@ program.command("save [ids...]").description("copy local edits back into the she
596
1040
  const ctx = await ctxFor("other");
597
1041
  await save(ctx, ids);
598
1042
  }));
1043
+ program.command("list").description("everything in the shed and which profiles use it").option("--unused", "only things no profile uses").action((o) => run(async () => {
1044
+ const ctx = await ctxFor("other");
1045
+ const m = await loadManifest(ctx);
1046
+ const rows = listRows(m).filter((r) => !o.unused || !r.usedBy.length);
1047
+ console.log(o.unused && !rows.length ? "\uBBF8\uC0AC\uC6A9 \uD56D\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4." : formatRows(rows, m));
1048
+ }));
1049
+ program.command("remove <key>").description("delete a component or package from the shed (refused while a profile uses it)").action((key) => run(async () => {
1050
+ const ctx = await ctxFor("other");
1051
+ await remove(ctx, key);
1052
+ }));
1053
+ program.command("prune").description("remove everything no profile uses").option("--yes", "actually delete; without it, just list").action((o) => run(async () => {
1054
+ const ctx = await ctxFor("other");
1055
+ await prune(ctx, { yes: o.yes });
1056
+ }));
599
1057
  program.command("scan").description("(debug) list components found in the agent config root").action(() => run(async () => {
600
1058
  const adapter = adapterFromOpts();
601
1059
  const found = await adapter.scan();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lshed",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Portable harness environment manager for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "leesongheon <leesongheon1209@gmail.com>",