lshed 0.2.1 → 0.3.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-09-02
4
+
5
+ Claude Code plugins are packages now. On the machine this was built against, the five installed plugins were the only thing a fresh `restore` still left out, and two of them carry MCP servers.
6
+
7
+ - `init` records each user-scope plugin as `claude-plugin:<name>@<marketplace>` and each GitHub-backed marketplace as `claude-marketplace:<owner/repo>`. Project-scope plugins belong to their project and are skipped.
8
+ - `restore` adds missing marketplaces first, then installs missing plugins through `claude plugin install`. Both are the agent's own package manager, so they run without `--yes`; `--yes` is forwarded as `-y` to accept a marketplace-declared install command.
9
+ - Plugins cannot be pinned. `lshed.lock` records the version that actually got installed and `status` shows when it differs from what another machine had. `update` runs `claude plugin update`.
10
+ - Installers are an interface now. `github:`/`git:` live in core; an adapter contributes its own (`ClaudeCodeAdapter` provides the two above). Install order follows installer priority, then manifest order.
11
+ - Lock entries use `rev` instead of `commit`. Old locks still read.
12
+
3
13
  ## 0.2.1 — 2026-09-02
4
14
 
5
15
  - `lshed list [--unused]` shows everything in the shed and which profiles use it.
package/README.md CHANGED
@@ -108,10 +108,22 @@ profiles:
108
108
 
109
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
110
 
111
+ Claude Code plugins are packages too, with their own scheme. `init` finds them in `~/.claude/plugins`:
112
+
113
+ ```yaml
114
+ packages:
115
+ - id: claude-plugins-official
116
+ source: claude-marketplace:anthropics/claude-plugins-official
117
+ - id: exa
118
+ source: claude-plugin:exa@claude-plugins-official
119
+ ```
120
+
121
+ `restore` adds the marketplace, then runs `claude plugin install exa@claude-plugins-official`. Plugins cannot be pinned to a version, so the lock records whatever got installed and `status` tells you when it differs from the machine you came from. Plugins that bundle MCP servers bring them along.
122
+
111
123
  Rules that keep this safe:
112
124
 
113
125
  - 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`.
126
+ - `install:` is a shell command. `restore` and `update` **print it and stop** unless you pass `--yes`. Plugin installs go through Claude Code's own package manager and run without it; `--yes` is forwarded as `-y` for plugins that declare an install command.
115
127
  - Packages are not part of the managed set. Switching profiles never deletes a clone.
116
128
  - Installers sometimes create aliases without symlinks, which `init` cannot tell from authored skills. Leave those out with `--exclude`:
117
129
 
@@ -171,7 +183,7 @@ The shed is the source of truth for authored parts: `save` copies local edits ba
171
183
  - MCP servers and secrets. Planned: the manifest names the keys, values are injected locally, nothing secret enters the shed.
172
184
  - `settings.json` merging (hooks, permissions).
173
185
  - `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.
186
+ - MCP servers configured by hand in `~/.claude.json`. Plugin-bundled ones are covered.
175
187
  - Windows and macOS have not been tested. The code avoids platform-specific paths, but treat 0.1 as Linux/WSL.
176
188
 
177
189
  ## Troubleshooting
package/dist/cli.js CHANGED
@@ -4,12 +4,169 @@
4
4
  import { Command } from "commander";
5
5
  import { createRequire } from "module";
6
6
  import os2 from "os";
7
- import path13 from "path";
7
+ import path15 from "path";
8
8
 
9
9
  // src/adapters/claude-code.ts
10
+ import { promises as fs2 } from "fs";
11
+ import path2 from "path";
12
+ import os from "os";
13
+
14
+ // src/installers/claude-plugin.ts
10
15
  import { promises as fs } from "fs";
11
16
  import path from "path";
12
- import os from "os";
17
+
18
+ // src/source.ts
19
+ var GITHUB_RE = /^([\w.-]+)\/([\w.-]+)(?:@([^#]+))?(?:#(.+))?$/;
20
+ function parseSource(raw) {
21
+ const idx = raw.indexOf(":");
22
+ if (idx <= 0) {
23
+ throw new Error(`source\uC5D0 \uC2A4\uD0B4\uC774 \uC5C6\uC2B5\uB2C8\uB2E4: "${raw}" (\uC608: file:./skills/x, github:user/repo@v1)`);
24
+ }
25
+ const scheme = raw.slice(0, idx);
26
+ const rest2 = raw.slice(idx + 1);
27
+ switch (scheme) {
28
+ case "file":
29
+ if (!rest2) throw new Error(`file: \uB4A4\uC5D0 \uACBD\uB85C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4: "${raw}"`);
30
+ return { scheme, path: rest2 };
31
+ case "github": {
32
+ const m = GITHUB_RE.exec(rest2);
33
+ if (!m) throw new Error(`github: \uD615\uC2DD\uC774 \uC544\uB2D9\uB2C8\uB2E4: "${raw}" (\uC608: github:user/repo@v1.0#sub/path)`);
34
+ const [, owner, repo, ref, subpath] = m;
35
+ return { scheme, owner, repo, ref, subpath };
36
+ }
37
+ case "git": {
38
+ const hash = rest2.lastIndexOf("#");
39
+ const url = hash >= 0 ? rest2.slice(0, hash) : rest2;
40
+ const ref = hash >= 0 ? rest2.slice(hash + 1) : void 0;
41
+ if (!url) throw new Error(`git: \uB4A4\uC5D0 URL \uC774 \uC5C6\uC2B5\uB2C8\uB2E4: "${raw}"`);
42
+ return { scheme, url, ref: ref || void 0 };
43
+ }
44
+ default:
45
+ if (!/^[a-z][\w-]*$/.test(scheme)) throw new Error(`\uC54C \uC218 \uC5C6\uB294 \uC2A4\uD0B4 "${scheme}": "${raw}"`);
46
+ return { scheme: "other", name: scheme, rest: rest2 };
47
+ }
48
+ }
49
+ function isComponentSource(s) {
50
+ return s.scheme === "file" || s.scheme === "github" || s.scheme === "git";
51
+ }
52
+ function formatSource(s) {
53
+ switch (s.scheme) {
54
+ case "file":
55
+ return `file:${s.path}`;
56
+ case "github":
57
+ return `github:${s.owner}/${s.repo}${s.ref ? `@${s.ref}` : ""}${s.subpath ? `#${s.subpath}` : ""}`;
58
+ case "git":
59
+ return `git:${s.url}${s.ref ? `#${s.ref}` : ""}`;
60
+ case "other":
61
+ return `${s.name}:${s.rest}`;
62
+ }
63
+ }
64
+ function cloneTarget(s) {
65
+ if (s.scheme === "github") return { url: `https://github.com/${s.owner}/${s.repo}.git`, ref: s.ref };
66
+ if (s.scheme === "git") return { url: s.url, ref: s.ref };
67
+ throw new Error(`file: \uCD9C\uCC98\uB294 clone \uB300\uC0C1\uC774 \uC544\uB2D9\uB2C8\uB2E4`);
68
+ }
69
+ function sourceFromRemote(url, ref) {
70
+ const m = /^(?:https?:\/\/github\.com\/|git@github\.com:)([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url);
71
+ if (m) return formatSource({ scheme: "github", owner: m[1], repo: m[2], ref });
72
+ return formatSource({ scheme: "git", url, ref });
73
+ }
74
+
75
+ // src/installers/claude-plugin.ts
76
+ async function readJson(p, fallback) {
77
+ try {
78
+ return JSON.parse(await fs.readFile(p, "utf8"));
79
+ } catch {
80
+ return fallback;
81
+ }
82
+ }
83
+ var installedPath = (ctx) => path.join(ctx.adapter.root, "plugins", "installed_plugins.json");
84
+ var marketplacesPath = (ctx) => path.join(ctx.adapter.root, "plugins", "known_marketplaces.json");
85
+ function rest(pkg) {
86
+ const s = parseSource(pkg.source);
87
+ if (s.scheme !== "other") throw new Error(`package ${pkg.id}: ${pkg.source} \uB294 \uD50C\uB7EC\uADF8\uC778 \uCD9C\uCC98\uAC00 \uC544\uB2D9\uB2C8\uB2E4`);
88
+ return s.rest;
89
+ }
90
+ async function claude(ctx, args) {
91
+ try {
92
+ await ctx.exec("claude", args);
93
+ } catch (e) {
94
+ if (e.code === "ENOENT") throw new Error("claude CLI \uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. Claude Code \uAC00 \uC124\uCE58\uB418\uC5B4 \uC788\uC5B4\uC57C \uD50C\uB7EC\uADF8\uC778\uC744 \uBCF5\uC6D0\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.");
95
+ throw e;
96
+ }
97
+ }
98
+ var marketplaceInstaller = {
99
+ name: "claude-marketplace",
100
+ schemes: ["claude-marketplace"],
101
+ priority: 10,
102
+ async detect(ctx) {
103
+ const m = await readJson(marketplacesPath(ctx), {});
104
+ const out = [];
105
+ for (const [name, v] of Object.entries(m)) {
106
+ const src = v.source;
107
+ if (src.source === "github" && src.repo) out.push({ id: name, source: `claude-marketplace:${src.repo}`, rev: src.repo });
108
+ else ctx.log(` ! marketplace ${name}: ${src.source} \uCD9C\uCC98\uB294 \uC544\uC9C1 \uAE30\uB85D\uD558\uC9C0 \uBABB\uD569\uB2C8\uB2E4 (\uAC74\uB108\uB700)`);
109
+ }
110
+ return out;
111
+ },
112
+ async status(ctx, pkg) {
113
+ const m = await readJson(marketplacesPath(ctx), {});
114
+ const v = m[pkg.id];
115
+ return { present: !!v, rev: v?.source.repo };
116
+ },
117
+ async install(ctx, pkg, _locked, _opts) {
118
+ const repo = rest(pkg);
119
+ await claude(ctx, ["plugin", "marketplace", "add", repo, "--scope", "user"]);
120
+ return repo;
121
+ },
122
+ async update(ctx, pkg) {
123
+ await claude(ctx, ["plugin", "marketplace", "update", pkg.id]);
124
+ return rest(pkg);
125
+ },
126
+ describe(pkg) {
127
+ return `claude plugin marketplace add ${rest(pkg)}`;
128
+ },
129
+ cwd(ctx) {
130
+ return ctx.adapter.root;
131
+ }
132
+ };
133
+ var pluginInstaller = {
134
+ name: "claude-plugin",
135
+ schemes: ["claude-plugin"],
136
+ priority: 20,
137
+ async detect(ctx) {
138
+ const f = await readJson(installedPath(ctx), { version: 2, plugins: {} });
139
+ const out = [];
140
+ for (const [key, entries] of Object.entries(f.plugins)) {
141
+ const e = entries.find((x2) => x2.scope === "user");
142
+ if (!e) continue;
143
+ const [name] = key.split("@");
144
+ out.push({ id: name, source: `claude-plugin:${key}`, rev: e.version });
145
+ }
146
+ return out;
147
+ },
148
+ async status(ctx, pkg) {
149
+ const f = await readJson(installedPath(ctx), { version: 2, plugins: {} });
150
+ const e = f.plugins[rest(pkg)]?.find((x2) => x2.scope === "user");
151
+ return { present: !!e, rev: e?.version };
152
+ },
153
+ async install(ctx, pkg, _locked, opts) {
154
+ await claude(ctx, ["plugin", "install", rest(pkg), "--scope", "user", ...opts.yes ? ["-y"] : []]);
155
+ return (await this.status(ctx, pkg)).rev ?? "?";
156
+ },
157
+ async update(ctx, pkg) {
158
+ await claude(ctx, ["plugin", "update", rest(pkg)]);
159
+ return (await this.status(ctx, pkg)).rev ?? "?";
160
+ },
161
+ describe(pkg, locked) {
162
+ return `claude plugin install ${rest(pkg)}${locked ? ` (\uC804\uC5D0 ${locked}; \uACE0\uC815\uC740 \uC548 \uB428)` : ""}`;
163
+ },
164
+ cwd(ctx) {
165
+ return ctx.adapter.root;
166
+ }
167
+ };
168
+
169
+ // src/adapters/claude-code.ts
13
170
  var CATEGORIES = [
14
171
  { name: "skills", root: "skills", kind: "dir" },
15
172
  { name: "agents", root: "agents", kind: "file" },
@@ -20,7 +177,7 @@ var ClaudeCodeAdapter = class {
20
177
  name = "claude-code";
21
178
  root;
22
179
  constructor(root) {
23
- this.root = root ?? path.join(os.homedir(), ".claude");
180
+ this.root = root ?? path2.join(os.homedir(), ".claude");
24
181
  }
25
182
  categories() {
26
183
  return CATEGORIES;
@@ -31,22 +188,25 @@ var ClaudeCodeAdapter = class {
31
188
  instructionsFileName() {
32
189
  return "CLAUDE.md";
33
190
  }
191
+ installers() {
192
+ return [marketplaceInstaller, pluginInstaller];
193
+ }
34
194
  async scan() {
35
195
  const out = [];
36
196
  for (const cat of CATEGORIES) {
37
- const dir = path.join(this.root, cat.root);
197
+ const dir = path2.join(this.root, cat.root);
38
198
  let entries;
39
199
  try {
40
- entries = await fs.readdir(dir, { withFileTypes: true });
200
+ entries = await fs2.readdir(dir, { withFileTypes: true });
41
201
  } catch {
42
202
  continue;
43
203
  }
44
204
  for (const e of entries) {
45
205
  if (e.name.startsWith(".")) continue;
46
- const full = path.join(dir, e.name);
206
+ const full = path2.join(dir, e.name);
47
207
  let st;
48
208
  try {
49
- st = await fs.stat(full);
209
+ st = await fs2.stat(full);
50
210
  } catch {
51
211
  continue;
52
212
  }
@@ -61,70 +221,208 @@ var ClaudeCodeAdapter = class {
61
221
  }
62
222
  };
63
223
 
64
- // src/core/init.ts
65
- import { promises as fs7 } from "fs";
66
- import path10 from "path";
67
-
68
224
  // src/core/context.ts
69
- import { promises as fs3 } from "fs";
225
+ import { promises as fs6 } from "fs";
226
+ import path8 from "path";
227
+ import { spawn as spawn2 } from "child_process";
228
+
229
+ // src/installers/git.ts
230
+ import { promises as fs4 } from "fs";
231
+ import path5 from "path";
232
+
233
+ // src/git.ts
234
+ import { execFile, spawn } from "child_process";
235
+ import { promisify } from "util";
70
236
  import path4 from "path";
71
237
 
72
- // src/manifest.ts
73
- import { z } from "zod";
74
- import YAML from "yaml";
238
+ // src/fsutil.ts
239
+ import { promises as fs3 } from "fs";
240
+ import { createHash } from "crypto";
241
+ import path3 from "path";
75
242
 
76
- // src/source.ts
77
- var GITHUB_RE = /^([\w.-]+)\/([\w.-]+)(?:@([^#]+))?(?:#(.+))?$/;
78
- function parseSource(raw) {
79
- const idx = raw.indexOf(":");
80
- if (idx <= 0) {
81
- throw new Error(`source\uC5D0 \uC2A4\uD0B4\uC774 \uC5C6\uC2B5\uB2C8\uB2E4: "${raw}" (\uC608: file:./skills/x, github:user/repo@v1)`);
243
+ // src/ignore.ts
244
+ var DEFAULT_IGNORE = [
245
+ "node_modules",
246
+ ".git",
247
+ "__pycache__",
248
+ ".venv",
249
+ ".mypy_cache",
250
+ ".pytest_cache",
251
+ ".DS_Store",
252
+ "*.log"
253
+ ];
254
+ function matches(name, pattern) {
255
+ if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
256
+ return name === pattern;
257
+ }
258
+ function isIgnored(rel, patterns) {
259
+ if (!rel) return false;
260
+ return rel.split("/").some((seg) => patterns.some((p) => matches(seg, p)));
261
+ }
262
+
263
+ // src/fsutil.ts
264
+ async function exists(p) {
265
+ try {
266
+ await fs3.access(p);
267
+ return true;
268
+ } catch {
269
+ return false;
82
270
  }
83
- const scheme = raw.slice(0, idx);
84
- const rest = raw.slice(idx + 1);
85
- switch (scheme) {
86
- case "file":
87
- if (!rest) throw new Error(`file: \uB4A4\uC5D0 \uACBD\uB85C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4: "${raw}"`);
88
- return { scheme, path: rest };
89
- case "github": {
90
- const m = GITHUB_RE.exec(rest);
91
- if (!m) throw new Error(`github: \uD615\uC2DD\uC774 \uC544\uB2D9\uB2C8\uB2E4: "${raw}" (\uC608: github:user/repo@v1.0#sub/path)`);
92
- const [, owner, repo, ref, subpath] = m;
93
- return { scheme, owner, repo, ref, subpath };
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 };
271
+ }
272
+ async function isDir(p) {
273
+ try {
274
+ return (await fs3.stat(p)).isDirectory();
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+ async function listFiles(root, ignore = DEFAULT_IGNORE) {
280
+ if (!await exists(root)) return [];
281
+ if (!await isDir(root)) return [""];
282
+ const out = [];
283
+ async function walk(dir, rel) {
284
+ const entries = await fs3.readdir(dir, { withFileTypes: true });
285
+ for (const e of entries) {
286
+ const r = rel ? `${rel}/${e.name}` : e.name;
287
+ if (isIgnored(r, ignore)) continue;
288
+ const full = path3.join(dir, e.name);
289
+ let st;
290
+ try {
291
+ st = await fs3.stat(full);
292
+ } catch {
293
+ continue;
294
+ }
295
+ if (st.isDirectory()) await walk(full, r);
296
+ else if (st.isFile()) out.push(r);
101
297
  }
102
- default:
103
- throw new Error(`\uC54C \uC218 \uC5C6\uB294 \uC2A4\uD0B4 "${scheme}": "${raw}"`);
104
298
  }
299
+ await walk(root, "");
300
+ return out.sort();
105
301
  }
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}` : ""}`;
302
+ async function hashFile(p) {
303
+ return createHash("sha256").update(await fs3.readFile(p)).digest("hex");
304
+ }
305
+ async function hashTree(root, ignore = DEFAULT_IGNORE) {
306
+ if (!await exists(root)) return null;
307
+ const h = createHash("sha256");
308
+ for (const rel of await listFiles(root, ignore)) {
309
+ h.update(rel).update("\0").update(await fs3.readFile(rel ? path3.join(root, rel) : root)).update("\0");
114
310
  }
311
+ return h.digest("hex");
115
312
  }
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`);
313
+ async function copyTree(src, dst, ignore = DEFAULT_IGNORE) {
314
+ await fs3.rm(dst, { recursive: true, force: true });
315
+ await fs3.mkdir(path3.dirname(dst), { recursive: true });
316
+ const srcRoot = path3.resolve(src);
317
+ await fs3.cp(src, dst, {
318
+ recursive: true,
319
+ dereference: true,
320
+ filter: async (from) => {
321
+ const rel = path3.relative(srcRoot, path3.resolve(from)).split(path3.sep).join("/");
322
+ if (isIgnored(rel, ignore)) return false;
323
+ try {
324
+ if ((await fs3.lstat(from)).isSymbolicLink()) await fs3.stat(from);
325
+ } catch {
326
+ return false;
327
+ }
328
+ return true;
329
+ }
330
+ });
120
331
  }
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 });
332
+ async function removeTree(p) {
333
+ await fs3.rm(p, { recursive: true, force: true });
334
+ }
335
+ async function diffTrees(local, shed, ignore = DEFAULT_IGNORE) {
336
+ const l = new Set(await listFiles(local, ignore));
337
+ const s = new Set(await listFiles(shed, ignore));
338
+ const out = [];
339
+ for (const f of [.../* @__PURE__ */ new Set([...l, ...s])].sort()) {
340
+ const lp = f ? path3.join(local, f) : local;
341
+ const sp = f ? path3.join(shed, f) : shed;
342
+ if (l.has(f) && !s.has(f)) out.push({ status: "A", file: f });
343
+ else if (!l.has(f) && s.has(f)) out.push({ status: "D", file: f });
344
+ else if (await hashFile(lp) !== await hashFile(sp)) out.push({ status: "M", file: f });
345
+ }
346
+ return out;
347
+ }
348
+
349
+ // src/git.ts
350
+ var x = promisify(execFile);
351
+ async function git(args, cwd) {
352
+ const { stdout } = await x("git", args, { cwd, maxBuffer: 1 << 24 });
353
+ return stdout.trim();
354
+ }
355
+ var isRepo = (dir) => exists(path4.join(dir, ".git"));
356
+ var remoteUrl = (dir) => git(["remote", "get-url", "origin"], dir).catch(() => null);
357
+ var head = (dir) => git(["rev-parse", "HEAD"], dir);
358
+ var branch = async (dir) => {
359
+ const b = await git(["rev-parse", "--abbrev-ref", "HEAD"], dir);
360
+ return b === "HEAD" ? void 0 : b;
361
+ };
362
+ var clone = (url, dir, ref) => git(["clone", "--quiet", ...ref ? ["--branch", ref] : [], url, dir]);
363
+ var resetHard = (dir, sha) => git(["reset", "--hard", "--quiet", sha], dir);
364
+ var pullFf = (dir) => git(["pull", "--ff-only", "--quiet"], dir);
365
+ function runShell(cmd, cwd) {
366
+ return new Promise((resolve, reject) => {
367
+ const p = spawn("sh", ["-c", cmd], { cwd, stdio: "inherit" });
368
+ p.on("error", reject);
369
+ p.on("close", (code) => code === 0 ? resolve() : reject(new Error(`\uBA85\uB839\uC774 ${code} \uB85C \uB05D\uB0AC\uC2B5\uB2C8\uB2E4: ${cmd}`)));
370
+ });
125
371
  }
126
372
 
373
+ // src/installers/git.ts
374
+ function dirOf(ctx, pkg) {
375
+ if (!pkg.into) throw new Error(`package ${pkg.id}: git \uACC4\uC5F4 \uD328\uD0A4\uC9C0\uB294 into \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4`);
376
+ return path5.join(ctx.adapter.root, ...pkg.into.split("/"));
377
+ }
378
+ var gitInstaller = {
379
+ name: "git",
380
+ schemes: ["github", "git"],
381
+ priority: 0,
382
+ async detect(ctx, found) {
383
+ const out = [];
384
+ for (const f of found) {
385
+ if (!await isRepo(f.path)) continue;
386
+ const url = await remoteUrl(f.path);
387
+ if (!url) continue;
388
+ const into = path5.relative(ctx.adapter.root, f.path).split(path5.sep).join("/");
389
+ out.push({ id: f.id, into, source: sourceFromRemote(url, await branch(f.path)), rev: await head(f.path), path: f.path });
390
+ }
391
+ return out;
392
+ },
393
+ async status(ctx, pkg) {
394
+ const dir = dirOf(ctx, pkg);
395
+ const present = await isRepo(dir);
396
+ return { present, rev: present ? await head(dir) : void 0 };
397
+ },
398
+ async install(ctx, pkg, locked, _opts) {
399
+ const dir = dirOf(ctx, pkg);
400
+ if (await exists(dir)) throw new Error(`package ${pkg.id}: ${dir} \uAC00 \uC788\uC9C0\uB9CC git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2D9\uB2C8\uB2E4. \uCE58\uC6B0\uAC70\uB098 into \uB97C \uBC14\uAFB8\uC138\uC694.`);
401
+ const { url, ref } = cloneTarget(parseSource(pkg.source));
402
+ await fs4.mkdir(path5.dirname(dir), { recursive: true });
403
+ await clone(url, dir, ref);
404
+ if (locked) {
405
+ await resetHard(dir, locked).catch(() => {
406
+ throw new Error(`package ${pkg.id}: \uB77D\uC758 \uCEE4\uBC0B ${locked.slice(0, 7)} \uC744 \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. 'lshed update ${pkg.id}' \uB85C \uB77D\uC744 \uAC31\uC2E0\uD558\uC138\uC694.`);
407
+ });
408
+ }
409
+ return head(dir);
410
+ },
411
+ async update(ctx, pkg) {
412
+ const dir = dirOf(ctx, pkg);
413
+ await pullFf(dir);
414
+ return head(dir);
415
+ },
416
+ describe(pkg, locked) {
417
+ const { url, ref } = cloneTarget(parseSource(pkg.source));
418
+ return `clone ${url}${ref ? ` @${ref}` : ""}${locked ? ` \u2192 ${locked.slice(0, 7)}` : ""}`;
419
+ },
420
+ cwd: dirOf
421
+ };
422
+
127
423
  // src/manifest.ts
424
+ import { z } from "zod";
425
+ import YAML from "yaml";
128
426
  var ComponentSchema = z.object({
129
427
  id: z.string().regex(/^[\w.-]+$/, "id\uB294 \uC601\uBB38\xB7\uC22B\uC790\xB7._- \uB9CC \uD5C8\uC6A9"),
130
428
  source: z.string().optional(),
@@ -133,7 +431,9 @@ var ComponentSchema = z.object({
133
431
  var PackageSchema = z.object({
134
432
  id: z.string().regex(/^[\w.-]+$/, "id\uB294 \uC601\uBB38\xB7\uC22B\uC790\xB7._- \uB9CC \uD5C8\uC6A9"),
135
433
  source: z.string(),
136
- into: z.string().regex(/^[^/\\][^\\]*$/, "into \uB294 \uB8E8\uD2B8 \uAE30\uC900 \uC0C1\uB300 \uACBD\uB85C (POSIX)"),
434
+ /** git 계열 패키지의 위치 (어댑터 루트 기준). 어댑터 설치기 스킴은 필요 없다 */
435
+ into: z.string().regex(/^[^/\\][^\\]*$/, "into \uB294 \uB8E8\uD2B8 \uAE30\uC900 \uC0C1\uB300 \uACBD\uB85C (POSIX)").optional(),
436
+ /** 설치 후 실행할 셸 명령. --yes 일 때만 실행 */
137
437
  install: z.string().optional()
138
438
  });
139
439
  var PACKAGES = "packages";
@@ -150,7 +450,7 @@ var ManifestSchema = z.object({
150
450
  });
151
451
  var ManifestError = class extends Error {
152
452
  };
153
- function parseManifest(text, knownCategories2) {
453
+ function parseManifest(text, knownCategories2, knownSchemes) {
154
454
  const raw = YAML.parse(text);
155
455
  const result = ManifestSchema.safeParse(raw);
156
456
  if (!result.success) {
@@ -169,7 +469,7 @@ ${lines.join("\n")}`);
169
469
  if (seen.has(c.id)) problems.push(`${cat}: id "${c.id}" \uC911\uBCF5`);
170
470
  seen.add(c.id);
171
471
  try {
172
- parseSource(effectiveSource(cat, c));
472
+ if (!isComponentSource(parseSource(effectiveSource(cat, c)))) problems.push(`${cat}/${c.id}: \uBD80\uD488 \uCD9C\uCC98\uB294 file:/github:/git: \uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4`);
173
473
  } catch (e) {
174
474
  problems.push(`${cat}/${c.id}: ${e.message}`);
175
475
  }
@@ -180,7 +480,11 @@ ${lines.join("\n")}`);
180
480
  if (pkgIds.has(p.id)) problems.push(`packages: id "${p.id}" \uC911\uBCF5`);
181
481
  pkgIds.add(p.id);
182
482
  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`);
483
+ const src = parseSource(p.source);
484
+ const scheme = src.scheme === "other" ? src.name : src.scheme;
485
+ if (scheme === "file") problems.push(`packages/${p.id}: \uD328\uD0A4\uC9C0 \uCD9C\uCC98\uB294 file: \uC77C \uC218 \uC5C6\uC2B5\uB2C8\uB2E4`);
486
+ else if (knownSchemes && !knownSchemes.includes(scheme)) problems.push(`packages/${p.id}: \uC2A4\uD0B4 "${scheme}" \uC744 \uB2E4\uB8F0 \uC124\uCE58\uAE30\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4 (${knownSchemes.join(", ")})`);
487
+ if ((scheme === "github" || scheme === "git") && !p.into) problems.push(`packages/${p.id}: ${scheme}: \uD328\uD0A4\uC9C0\uB294 into \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4`);
184
488
  } catch (e) {
185
489
  problems.push(`packages/${p.id}: ${e.message}`);
186
490
  }
@@ -216,10 +520,10 @@ function packagesOf(m, profile) {
216
520
  }
217
521
 
218
522
  // src/resolvers/file.ts
219
- import path2 from "path";
523
+ import path6 from "path";
220
524
  function resolveSource(shed, raw) {
221
525
  const s = parseSource(raw);
222
- if (s.scheme === "file") return path2.resolve(shed, s.path);
526
+ if (s.scheme === "file") return path6.resolve(shed, s.path);
223
527
  throw new Error(`"${raw}": ${s.scheme}: \uCD9C\uCC98\uB294 v0.2\uC5D0\uC11C \uC9C0\uC6D0\uB429\uB2C8\uB2E4. \uC9C0\uAE08\uC740 file: \uB9CC \uC0AC\uC6A9\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.`);
224
528
  }
225
529
  function isSaveable(raw) {
@@ -227,8 +531,8 @@ function isSaveable(raw) {
227
531
  }
228
532
 
229
533
  // src/state.ts
230
- import { promises as fs2 } from "fs";
231
- import path3 from "path";
534
+ import { promises as fs5 } from "fs";
535
+ import path7 from "path";
232
536
  import { z as z2 } from "zod";
233
537
  var StateSchema = z2.object({
234
538
  profile: z2.string(),
@@ -239,11 +543,11 @@ var StateSchema = z2.object({
239
543
  });
240
544
  var LSHED_DIR = "lshed";
241
545
  function statePath(adapter) {
242
- return path3.join(adapter.root, LSHED_DIR, "state.json");
546
+ return path7.join(adapter.root, LSHED_DIR, "state.json");
243
547
  }
244
548
  async function readState(adapter) {
245
549
  try {
246
- const raw = JSON.parse(await fs2.readFile(statePath(adapter), "utf8"));
550
+ const raw = JSON.parse(await fs5.readFile(statePath(adapter), "utf8"));
247
551
  return StateSchema.parse(raw);
248
552
  } catch (e) {
249
553
  if (e.code === "ENOENT") return null;
@@ -252,36 +556,33 @@ async function readState(adapter) {
252
556
  }
253
557
  async function writeState(adapter, state) {
254
558
  const p = statePath(adapter);
255
- await fs2.mkdir(path3.dirname(p), { recursive: true });
256
- await fs2.writeFile(p, JSON.stringify(state, null, 2) + "\n");
559
+ await fs5.mkdir(path7.dirname(p), { recursive: true });
560
+ await fs5.writeFile(p, JSON.stringify(state, null, 2) + "\n");
257
561
  }
258
562
 
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;
563
+ // src/core/context.ts
564
+ function spawnExec(cmd, args, cwd) {
565
+ return new Promise((resolve, reject) => {
566
+ const p = spawn2(cmd, args, { cwd, stdio: "inherit" });
567
+ p.on("error", reject);
568
+ p.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} \uAC00 ${code} \uB85C \uB05D\uB0AC\uC2B5\uB2C8\uB2E4`)));
569
+ });
273
570
  }
274
- function isIgnored(rel, patterns) {
275
- if (!rel) return false;
276
- return rel.split("/").some((seg) => patterns.some((p) => matches(seg, p)));
571
+ function installersFor(ctx) {
572
+ return [gitInstaller, ...ctx.adapter.installers()].sort((a, b) => a.priority - b.priority);
573
+ }
574
+ function installerFor(ctx, source) {
575
+ const s = parseSource(source);
576
+ const scheme = s.scheme === "other" ? s.name : s.scheme;
577
+ const inst = installersFor(ctx).find((i) => i.schemes.includes(scheme));
578
+ if (!inst) throw new Error(`"${source}": \uC2A4\uD0B4 ${scheme} \uC744 \uB2E4\uB8F0 \uC124\uCE58\uAE30\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4`);
579
+ return inst;
277
580
  }
278
-
279
- // src/core/context.ts
280
581
  var MANIFEST_FILE = "lshed.yaml";
281
582
  var INSTRUCTIONS = "instructions";
282
583
  var FRAGMENTS_DIR = `${LSHED_DIR}/instructions`;
283
584
  function manifestPath(ctx) {
284
- return path4.join(ctx.shed, MANIFEST_FILE);
585
+ return path8.join(ctx.shed, MANIFEST_FILE);
285
586
  }
286
587
  function knownCategories(adapter) {
287
588
  return [...adapter.categories().map((c) => c.name), INSTRUCTIONS];
@@ -289,12 +590,12 @@ function knownCategories(adapter) {
289
590
  async function loadManifest(ctx) {
290
591
  let text;
291
592
  try {
292
- text = await fs3.readFile(manifestPath(ctx), "utf8");
593
+ text = await fs6.readFile(manifestPath(ctx), "utf8");
293
594
  } catch {
294
595
  throw new Error(`\uCC3D\uACE0\uC5D0 ${MANIFEST_FILE} \uC774 \uC5C6\uC2B5\uB2C8\uB2E4: ${ctx.shed}
295
596
  \uBA3C\uC800 'lshed init --shed ${ctx.shed}' \uB97C \uC2E4\uD589\uD558\uC138\uC694.`);
296
597
  }
297
- const m = parseManifest(text, knownCategories(ctx.adapter));
598
+ const m = parseManifest(text, knownCategories(ctx.adapter), installersFor(ctx).flatMap((i) => [...i.schemes]));
298
599
  ctx.ignore = [...DEFAULT_IGNORE, ...m.ignore ?? []];
299
600
  return m;
300
601
  }
@@ -334,103 +635,18 @@ function planProfile(ctx, m, profile) {
334
635
  return items;
335
636
  }
336
637
  function abs(ctx, rel) {
337
- return path4.join(ctx.adapter.root, ...rel.split("/"));
638
+ return path8.join(ctx.adapter.root, ...rel.split("/"));
338
639
  }
339
640
 
340
- // src/fsutil.ts
341
- import { promises as fs4 } from "fs";
342
- import { createHash } from "crypto";
343
- import path5 from "path";
344
- async function exists(p) {
345
- try {
346
- await fs4.access(p);
347
- return true;
348
- } catch {
349
- return false;
350
- }
351
- }
352
- async function isDir(p) {
353
- try {
354
- return (await fs4.stat(p)).isDirectory();
355
- } catch {
356
- return false;
357
- }
358
- }
359
- async function listFiles(root, ignore = DEFAULT_IGNORE) {
360
- if (!await exists(root)) return [];
361
- if (!await isDir(root)) return [""];
362
- const out = [];
363
- async function walk(dir, rel) {
364
- const entries = await fs4.readdir(dir, { withFileTypes: true });
365
- for (const e of entries) {
366
- const r = rel ? `${rel}/${e.name}` : e.name;
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);
377
- }
378
- }
379
- await walk(root, "");
380
- return out.sort();
381
- }
382
- async function hashFile(p) {
383
- return createHash("sha256").update(await fs4.readFile(p)).digest("hex");
384
- }
385
- async function hashTree(root, ignore = DEFAULT_IGNORE) {
386
- if (!await exists(root)) return null;
387
- const h = createHash("sha256");
388
- for (const rel of await listFiles(root, ignore)) {
389
- h.update(rel).update("\0").update(await fs4.readFile(rel ? path5.join(root, rel) : root)).update("\0");
390
- }
391
- return h.digest("hex");
392
- }
393
- async function copyTree(src, dst, ignore = DEFAULT_IGNORE) {
394
- await fs4.rm(dst, { recursive: true, force: true });
395
- await fs4.mkdir(path5.dirname(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
- });
411
- }
412
- async function removeTree(p) {
413
- await fs4.rm(p, { recursive: true, force: true });
414
- }
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));
418
- const out = [];
419
- for (const f of [.../* @__PURE__ */ new Set([...l, ...s])].sort()) {
420
- const lp = f ? path5.join(local, f) : local;
421
- const sp = f ? path5.join(shed, f) : shed;
422
- if (l.has(f) && !s.has(f)) out.push({ status: "A", file: f });
423
- else if (!l.has(f) && s.has(f)) out.push({ status: "D", file: f });
424
- else if (await hashFile(lp) !== await hashFile(sp)) out.push({ status: "M", file: f });
425
- }
426
- return out;
427
- }
641
+ // src/core/init.ts
642
+ import { promises as fs9 } from "fs";
643
+ import path12 from "path";
428
644
 
429
645
  // src/core/instructions.ts
430
- import path6 from "path";
646
+ import path9 from "path";
431
647
  var MARKER = "<!-- generated by lshed";
432
648
  function instructionsFile(ctx) {
433
- return path6.join(ctx.adapter.root, ctx.adapter.instructionsFileName());
649
+ return path9.join(ctx.adapter.root, ctx.adapter.instructionsFileName());
434
650
  }
435
651
  function isGenerated(text) {
436
652
  return text.trimStart().startsWith(MARKER);
@@ -449,49 +665,23 @@ ${f.content.trimEnd()}
449
665
  }
450
666
 
451
667
  // 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
- }
668
+ import { promises as fs8 } from "fs";
669
+ import path11 from "path";
481
670
 
482
671
  // src/lock.ts
483
- import { promises as fs5 } from "fs";
484
- import path8 from "path";
672
+ import { promises as fs7 } from "fs";
673
+ import path10 from "path";
485
674
  import YAML2 from "yaml";
486
675
  import { z as z3 } from "zod";
676
+ var EntrySchema = z3.object({ source: z3.string(), rev: z3.string().optional(), commit: z3.string().optional() }).transform((e) => ({ source: e.source, rev: e.rev ?? e.commit ?? "" }));
487
677
  var LockSchema = z3.object({
488
678
  version: z3.literal(1),
489
- packages: z3.record(z3.string(), z3.object({ source: z3.string(), commit: z3.string() })).default({})
679
+ packages: z3.record(z3.string(), EntrySchema).default({})
490
680
  });
491
681
  var LOCK_FILE = "lshed.lock";
492
682
  async function readLock(shed) {
493
683
  try {
494
- return LockSchema.parse(YAML2.parse(await fs5.readFile(path8.join(shed, LOCK_FILE), "utf8")));
684
+ return LockSchema.parse(YAML2.parse(await fs7.readFile(path10.join(shed, LOCK_FILE), "utf8")));
495
685
  } catch (e) {
496
686
  if (e.code === "ENOENT") return { version: 1, packages: {} };
497
687
  throw new Error(`${LOCK_FILE} \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${e.message}`);
@@ -499,44 +689,39 @@ async function readLock(shed) {
499
689
  }
500
690
  async function writeLock(shed, lock) {
501
691
  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));
692
+ await fs7.writeFile(path10.join(shed, LOCK_FILE), "# generated by lshed \u2014 do not edit; 'lshed update' refreshes it\n" + YAML2.stringify(sorted));
503
693
  }
504
694
 
505
695
  // src/core/packages.ts
506
696
  async function detectPackages(ctx, found) {
507
697
  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
- }
698
+ for (const inst of installersFor(ctx)) out.push(...await inst.detect(ctx, found));
515
699
  return out;
516
700
  }
517
701
  async function detectGenerated(found, pkgs) {
518
702
  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) })));
703
+ const located = pkgs.filter((p) => p.path);
704
+ if (!located.length) return out;
705
+ const roots = await Promise.all(located.map(async (p) => ({ id: p.id, real: await fs8.realpath(p.path) })));
521
706
  for (const f of found) {
522
- if (pkgs.some((p) => p.path === f.path)) continue;
707
+ if (located.some((p) => p.path === f.path)) continue;
523
708
  let entries;
524
709
  try {
525
- if (!(await fs6.stat(f.path)).isDirectory()) continue;
526
- entries = await fs6.readdir(f.path);
710
+ if (!(await fs8.stat(f.path)).isDirectory()) continue;
711
+ entries = await fs8.readdir(f.path);
527
712
  } catch {
528
713
  continue;
529
714
  }
530
715
  for (const name of entries) {
531
- const p = path9.join(f.path, name);
716
+ const p = path11.join(f.path, name);
532
717
  let target;
533
718
  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)));
719
+ if (!(await fs8.lstat(p)).isSymbolicLink()) continue;
720
+ target = await fs8.realpath(p).catch(async () => path11.resolve(f.path, await fs8.readlink(p)));
536
721
  } catch {
537
722
  continue;
538
723
  }
539
- const owner = roots.find((r) => target === r.real || target.startsWith(r.real + path9.sep));
724
+ const owner = roots.find((r) => target === r.real || target.startsWith(r.real + path11.sep));
540
725
  if (owner) {
541
726
  out.set(`${f.category}/${f.id}`, owner.id);
542
727
  break;
@@ -546,36 +731,34 @@ async function detectGenerated(found, pkgs) {
546
731
  return out;
547
732
  }
548
733
  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 };
734
+ const st = await installerFor(ctx, pkg.source).status(ctx, pkg);
735
+ return { pkg, ...st, locked: lock.packages[pkg.id]?.rev || void 0 };
736
+ }
737
+ var short = (r) => r && /^[0-9a-f]{40}$/.test(r) ? r.slice(0, 7) : r;
738
+ function ordered(ctx, pkgs) {
739
+ return pkgs.map((p, i) => ({ p, i, pr: installerFor(ctx, p.source).priority })).sort((a, b) => a.pr - b.pr || a.i - b.i).map((x2) => x2.p);
552
740
  }
553
741
  async function ensurePackages(ctx, pkgs, opts = {}) {
554
742
  const lock = await readLock(ctx.shed);
555
- const res = { cloned: [], pendingInstalls: [], lockChanged: false };
556
- for (const pkg of pkgs) {
743
+ const res = { installed: [], pendingInstalls: [], lockChanged: false };
744
+ for (const pkg of ordered(ctx, pkgs)) {
745
+ const inst = installerFor(ctx, pkg.source);
557
746
  const st = await packageStatus(ctx, pkg, lock);
558
747
  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)})` : "";
748
+ const note = st.locked && st.rev !== st.locked ? ` (${short(st.rev)} \u2260 lock ${short(st.locked)})` : "";
560
749
  ctx.log(` = package ${pkg.id}${note}`);
561
750
  continue;
562
751
  }
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)}` : ""})`);
752
+ ctx.log(` + package ${pkg.id} (${inst.describe(pkg, st.locked)})`);
566
753
  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) };
754
+ const rev = await inst.install(ctx, pkg, st.locked, opts);
755
+ if (rev !== st.locked) {
756
+ if (st.locked) ctx.log(` \uB77D\uC740 ${short(st.locked)} \uC774\uC9C0\uB9CC ${short(rev)} \uC774 \uC124\uCE58\uB428. \uB77D\uC744 \uB530\uB77C\uAC00\uAC8C \uAC31\uC2E0`);
757
+ lock.packages[pkg.id] = { source: pkg.source, rev };
575
758
  res.lockChanged = true;
576
759
  }
577
- res.cloned.push(pkg.id);
578
- if (pkg.install) await maybeInstall(ctx, pkg, st.dir, opts, res);
760
+ res.installed.push(pkg.id);
761
+ await maybeInstall(ctx, pkg, inst.cwd(ctx, pkg), opts, res);
579
762
  }
580
763
  if (res.lockChanged) await writeLock(ctx.shed, lock);
581
764
  return res;
@@ -583,7 +766,7 @@ async function ensurePackages(ctx, pkgs, opts = {}) {
583
766
  async function maybeInstall(ctx, pkg, dir, opts, res) {
584
767
  if (!pkg.install) return;
585
768
  if (opts.yes) {
586
- ctx.log(` $ (${pkg.into}) ${pkg.install}`);
769
+ ctx.log(` $ (${path11.relative(ctx.adapter.root, dir) || "."}) ${pkg.install}`);
587
770
  await runShell(pkg.install, dir);
588
771
  } else {
589
772
  res.pendingInstalls.push({ id: pkg.id, dir, cmd: pkg.install });
@@ -597,27 +780,27 @@ function reportPending(ctx, res) {
597
780
  }
598
781
  async function updatePackages(ctx, pkgs, opts = {}) {
599
782
  const lock = await readLock(ctx.shed);
600
- const res = { cloned: [], pendingInstalls: [], lockChanged: false };
601
- for (const pkg of pkgs) {
783
+ const res = { installed: [], pendingInstalls: [], lockChanged: false };
784
+ for (const pkg of ordered(ctx, pkgs)) {
785
+ const inst = installerFor(ctx, pkg.source);
602
786
  const st = await packageStatus(ctx, pkg, lock);
603
787
  if (!st.present) {
604
788
  ctx.log(` ! package ${pkg.id}: \uC124\uCE58\uB418\uC5B4 \uC788\uC9C0 \uC54A\uC74C. \uBA3C\uC800 restore \uD558\uC138\uC694`);
605
789
  continue;
606
790
  }
607
791
  if (opts.dryRun) {
608
- ctx.log(` ~ package ${pkg.id} (git pull --ff-only)`);
792
+ ctx.log(` ~ package ${pkg.id} (${inst.name} update)`);
609
793
  continue;
610
794
  }
611
- await pullFf(st.dir);
612
- const now = await head(st.dir);
613
- const before = lock.packages[pkg.id]?.commit;
795
+ const now = await inst.update(ctx, pkg, opts);
796
+ const before = lock.packages[pkg.id]?.rev;
614
797
  if (now !== before) {
615
- lock.packages[pkg.id] = { source: pkg.source, commit: now };
798
+ lock.packages[pkg.id] = { source: pkg.source, rev: now };
616
799
  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);
800
+ ctx.log(` \u2191 package ${pkg.id} ${before ? short(before) : "(\uC5C6\uC74C)"} \u2192 ${short(now)}`);
801
+ await maybeInstall(ctx, pkg, inst.cwd(ctx, pkg), opts, res);
619
802
  } else {
620
- ctx.log(` = package ${pkg.id} ${now.slice(0, 7)} (\uCD5C\uC2E0)`);
803
+ ctx.log(` = package ${pkg.id} ${short(now)} (\uCD5C\uC2E0)`);
621
804
  }
622
805
  }
623
806
  if (res.lockChanged) await writeLock(ctx.shed, lock);
@@ -640,8 +823,9 @@ async function init(ctx, opts = {}) {
640
823
  const generated = await detectGenerated(all, pkgs);
641
824
  const found = all.filter((f) => !pkgs.some((p) => p.path === f.path) && !generated.has(`${f.category}/${f.id}`));
642
825
  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)`);
826
+ m.packages.push(p.into ? { id: p.id, source: p.source, into: p.into } : { id: p.id, source: p.source });
827
+ const rev = /^[0-9a-f]{40}$/.test(p.rev) ? p.rev.slice(0, 7) : p.rev;
828
+ ctx.log(` \u2261 package ${p.id} ${p.source} @${rev} (\uCC38\uC870\uB9CC \uAE30\uB85D)`);
645
829
  }
646
830
  if (pkgs.length) m.profiles[profileName][PACKAGES] = pkgs.map((p) => p.id);
647
831
  for (const [key, by] of generated) ctx.log(` \xB7 ${key} (${by} \uAC00 \uC0DD\uC131\uD55C \uAC83 \u2192 \uAC74\uB108\uB700)`);
@@ -657,7 +841,7 @@ async function init(ctx, opts = {}) {
657
841
  m.components[cat.name] = [];
658
842
  m.profiles[profileName][cat.name] = [];
659
843
  for (const f of mine) {
660
- const dst = path10.join(ctx.shed, cat.root, cat.kind === "dir" ? f.id : `${f.id}.md`);
844
+ const dst = path12.join(ctx.shed, cat.root, cat.kind === "dir" ? f.id : `${f.id}.md`);
661
845
  await copyTree(f.path, dst, ignoreOf(ctx));
662
846
  m.components[cat.name].push({ id: f.id });
663
847
  m.profiles[profileName][cat.name].push(f.id);
@@ -668,32 +852,33 @@ async function init(ctx, opts = {}) {
668
852
  }
669
853
  const instr = instructionsFile(ctx);
670
854
  if (await exists(instr)) {
671
- const text = await fs7.readFile(instr, "utf8");
855
+ const text = await fs9.readFile(instr, "utf8");
672
856
  if (!isGenerated(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);
857
+ const dst = path12.join(ctx.shed, INSTRUCTIONS, "main.md");
858
+ await fs9.mkdir(path12.dirname(dst), { recursive: true });
859
+ await fs9.writeFile(dst, text);
676
860
  const fragRel = targetRel(INSTRUCTIONS, "main");
677
861
  await copyTree(dst, abs(ctx, fragRel), ignoreOf(ctx));
678
862
  managed.push(fragRel);
679
863
  m.components[INSTRUCTIONS] = [{ id: "main" }];
680
864
  m.profiles[profileName][INSTRUCTIONS] = ["main"];
681
865
  copied++;
682
- ctx.log(` + ${INSTRUCTIONS}/main (${path10.basename(instr)})`);
866
+ ctx.log(` + ${INSTRUCTIONS}/main (${path12.basename(instr)})`);
683
867
  }
684
868
  }
685
- await fs7.mkdir(ctx.shed, { recursive: true });
869
+ await fs9.mkdir(ctx.shed, { recursive: true });
686
870
  let yamlText = stringifyManifest(m);
687
871
  for (const p of pkgs) {
872
+ if (!p.into) continue;
688
873
  yamlText = yamlText.replace(` into: ${p.into}
689
874
  `, ` into: ${p.into}
690
875
  # install: ./setup # \u2190 \uBCF5\uC6D0 \uD6C4 \uC2E4\uD589\uD560 \uBA85\uB839\uC774 \uC788\uC73C\uBA74 \uCC44\uC6B0\uC138\uC694 (--yes \uB85C \uC2E4\uD589)
691
876
  `);
692
877
  }
693
- await fs7.writeFile(manifestPath(ctx), `# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed
878
+ await fs9.writeFile(manifestPath(ctx), `# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed
694
879
  ` + yamlText);
695
880
  if (pkgs.length) {
696
- await writeLock(ctx.shed, { version: 1, packages: Object.fromEntries(pkgs.map((p) => [p.id, { source: p.source, commit: p.commit }])) });
881
+ await writeLock(ctx.shed, { version: 1, packages: Object.fromEntries(pkgs.map((p) => [p.id, { source: p.source, rev: p.rev }])) });
697
882
  }
698
883
  await writeState(ctx.adapter, { profile: profileName, shed: ctx.shed, managed, appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
699
884
  const parts = [`\uBD80\uD488 ${copied}\uAC1C`];
@@ -702,13 +887,13 @@ async function init(ctx, opts = {}) {
702
887
  if (skipped.length) parts.push(`\uC81C\uC678 ${skipped.length}\uAC1C`);
703
888
  ctx.log(`
704
889
  ${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.`);
890
+ if (pkgs.some((p) => p.into)) ctx.log(`git \uD328\uD0A4\uC9C0\uC758 \uC124\uCE58 \uBA85\uB839(install:)\uC740 lshed.yaml \uC5D0\uC11C \uC9C1\uC811 \uCC44\uC6B0\uC138\uC694.`);
706
891
  return { manifest: m, copied, skipped, packages: pkgs.map((p) => p.id), generated: [...generated.keys()] };
707
892
  }
708
893
 
709
894
  // src/core/restore.ts
710
- import { promises as fs8 } from "fs";
711
- import path11 from "path";
895
+ import { promises as fs10 } from "fs";
896
+ import path13 from "path";
712
897
  async function restore(ctx, profileArg, opts = {}) {
713
898
  const backup = opts.backup ?? true;
714
899
  const state = await readState(ctx.adapter);
@@ -727,7 +912,7 @@ async function restore(ctx, profileArg, opts = {}) {
727
912
  const oldManaged = new Set(state?.managed ?? []);
728
913
  const toRemove = [...oldManaged].filter((r) => !newManaged.has(r)).sort();
729
914
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
730
- const backupDir = path11.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
915
+ const backupDir = path13.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
731
916
  const backedUp = [];
732
917
  const placed = [];
733
918
  async function backUp(rel) {
@@ -735,7 +920,7 @@ async function restore(ctx, profileArg, opts = {}) {
735
920
  if (!await exists(from)) return;
736
921
  backedUp.push(rel);
737
922
  if (opts.dryRun || !backup) return;
738
- await copyTree(from, path11.join(backupDir, ...rel.split("/")), ignoreOf(ctx));
923
+ await copyTree(from, path13.join(backupDir, ...rel.split("/")), ignoreOf(ctx));
739
924
  }
740
925
  for (const rel of toRemove) {
741
926
  ctx.log(` - ${rel}`);
@@ -758,14 +943,14 @@ async function restore(ctx, profileArg, opts = {}) {
758
943
  const instrPath = instructionsFile(ctx);
759
944
  if (fragments.length) {
760
945
  const contents = [];
761
- for (const f of fragments) contents.push({ id: f.id, content: await fs8.readFile(f.src, "utf8") });
946
+ for (const f of fragments) contents.push({ id: f.id, content: await fs10.readFile(f.src, "utf8") });
762
947
  const rendered = renderInstructions(ctx, profile, contents);
763
- const existing = await exists(instrPath) ? await fs8.readFile(instrPath, "utf8") : null;
948
+ const existing = await exists(instrPath) ? await fs10.readFile(instrPath, "utf8") : null;
764
949
  if (existing !== rendered) {
765
950
  const mark = existing === null ? "+" : "~";
766
951
  ctx.log(` ${mark} ${instrRel}${existing !== null && !isGenerated(existing) ? " (\uAE30\uC874 \uD30C\uC77C\uC740 lshed \uC0DD\uC131\uBB3C\uC774 \uC544\uB2D8 \u2192 \uBC31\uC5C5)" : ""}`);
767
952
  if (existing !== null) await backUp(instrRel);
768
- if (!opts.dryRun) await fs8.writeFile(instrPath, rendered);
953
+ if (!opts.dryRun) await fs10.writeFile(instrPath, rendered);
769
954
  } else {
770
955
  ctx.log(` = ${instrRel}`);
771
956
  }
@@ -780,7 +965,7 @@ async function restore(ctx, profileArg, opts = {}) {
780
965
  await writeState(ctx.adapter, { profile, shed: ctx.shed, managed: [...newManaged].sort(), appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
781
966
  const bdir = backup && backedUp.length ? backupDir : null;
782
967
  ctx.log(`
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}` : ""}`);
968
+ \uD504\uB85C\uD544 "${profile}" \uC801\uC6A9: \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}${pkgRes.installed.length ? `, \uD328\uD0A4\uC9C0 \uC124\uCE58 ${pkgRes.installed.length}` : ""}${bdir ? `, \uBC31\uC5C5 ${backedUp.length} \u2192 ${bdir}` : ""}`);
784
969
  reportPending(ctx, pkgRes);
785
970
  return { profile, placed, removed: toRemove, backedUp, backupDir: bdir };
786
971
  }
@@ -828,8 +1013,9 @@ function formatStatus(s, adapterRoot) {
828
1013
  `\uAD00\uB9AC \uC911 ${s.state.managed.length}\uAC1C \uACBD\uB85C (${adapterRoot})`
829
1014
  ];
830
1015
  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");
1016
+ const short2 = (r) => r && /^[0-9a-f]{40}$/.test(r) ? r.slice(0, 7) : r;
831
1017
  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`;
1018
+ const where = !p.present ? "\uC124\uCE58 \uC548 \uB428 \u2192 lshed restore" : !p.locked ? `${short2(p.rev)} (\uB77D \uC5C6\uC74C)` : p.rev === p.locked ? `${short2(p.rev)} = lock` : `${short2(p.rev)} \u2260 lock ${short2(p.locked)} \u2192 lshed update`;
833
1019
  lines.push(`\uD328\uD0A4\uC9C0 ${p.pkg.id} ${where}`);
834
1020
  }
835
1021
  return lines.join("\n");
@@ -897,8 +1083,8 @@ function formatRows(rows, m) {
897
1083
  }
898
1084
 
899
1085
  // src/core/remove.ts
900
- import { promises as fs9 } from "fs";
901
- import path12 from "path";
1086
+ import { promises as fs11 } from "fs";
1087
+ import path14 from "path";
902
1088
  import YAML3, { isSeq, isMap } from "yaml";
903
1089
  function resolveKey(m, raw) {
904
1090
  const rows = listRows(m);
@@ -913,7 +1099,7 @@ async function remove(ctx, raw) {
913
1099
  const { category, id } = resolveKey(m, raw);
914
1100
  const users = listRows(m).find((r) => r.category === category && r.id === id).usedBy;
915
1101
  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");
1102
+ const text = await fs11.readFile(manifestPath(ctx), "utf8");
917
1103
  const doc = YAML3.parseDocument(text);
918
1104
  let deleted;
919
1105
  if (category === PACKAGES) {
@@ -935,14 +1121,14 @@ async function remove(ctx, raw) {
935
1121
  seq.delete(idx);
936
1122
  if (!seq.items.length) doc.deleteIn(["components", category]);
937
1123
  const src = sourcePath(ctx, category, findComponent(m, category, id));
938
- const inside = !path12.relative(ctx.shed, src).startsWith("..");
1124
+ const inside = !path14.relative(ctx.shed, src).startsWith("..");
939
1125
  if (inside && await exists(src)) {
940
1126
  await removeTree(src);
941
1127
  deleted = src;
942
1128
  }
943
1129
  ctx.log(` - ${category}/${id}${deleted ? "" : " (\uCC3D\uACE0 \uBC16 \uACBD\uB85C\uB77C \uD30C\uC77C\uC740 \uB450\uC5C8\uC74C)"}`);
944
1130
  }
945
- await fs9.writeFile(manifestPath(ctx), doc.toString());
1131
+ await fs11.writeFile(manifestPath(ctx), doc.toString());
946
1132
  return { category, id, deleted };
947
1133
  }
948
1134
  async function prune(ctx, opts = {}) {
@@ -972,16 +1158,16 @@ var { version } = createRequire(import.meta.url)("../package.json");
972
1158
  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)");
973
1159
  function adapterFromOpts() {
974
1160
  const { root } = program.opts();
975
- return new ClaudeCodeAdapter(root ? path13.resolve(root) : void 0);
1161
+ return new ClaudeCodeAdapter(root ? path15.resolve(root) : void 0);
976
1162
  }
977
1163
  async function ctxFor(cmd) {
978
1164
  const adapter = adapterFromOpts();
979
1165
  const { shed: flag } = program.opts();
980
1166
  let shed = flag ?? process.env.LSHED_HOME;
981
1167
  if (!shed && cmd === "other") shed = (await readState(adapter))?.shed;
982
- if (!shed && cmd === "init") shed = path13.join(os2.homedir(), "lshed");
1168
+ if (!shed && cmd === "init") shed = path15.join(os2.homedir(), "lshed");
983
1169
  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.");
984
- return { adapter, shed: path13.resolve(shed), log: (l) => console.log(l) };
1170
+ return { adapter, shed: path15.resolve(shed), log: (l) => console.log(l), exec: spawnExec };
985
1171
  }
986
1172
  async function run(fn) {
987
1173
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lshed",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Portable harness environment manager for coding agents",
5
5
  "license": "MIT",
6
6
  "author": "leesongheon <leesongheon1209@gmail.com>",