lshed 0.0.0 → 0.1.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 ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-02
4
+
5
+ First working release. Claude Code only.
6
+
7
+ - `init` scans `~/.claude` (skills, agents, commands, CLAUDE.md) into a shed and writes `lshed.yaml`
8
+ - `restore <profile>` with managed-set semantics, backups on by default, `--dry-run`
9
+ - `status`, `diff`, `save`
10
+ - Instructions are assembled as an `@`-import list, not merged
11
+ - `file:` sources only; `github:` is parsed but rejected until 0.2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 leesongheon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # lshed
2
+
3
+ Keep your coding-agent harness — skills, subagents, commands, instructions — in a **shed**, and restore it on any machine with one command.
4
+
5
+ ```
6
+ lshed init --shed ~/lshed # scan ~/.claude into a shed + write lshed.yaml
7
+ lshed restore research # apply a profile anywhere
8
+ ```
9
+
10
+ The shed is a plain directory. Put it in a git repo, Dropbox, whatever. lshed does not sync it for you.
11
+
12
+ ## Why
13
+
14
+ Every new laptop, server, container or WSL box means setting up `~/.claude` again. dotfiles tools move **files**; they don't know what a skill, an agent or an instruction fragment is, they can't compose a subset per machine, and they can't tell which parts they placed and which were yours.
15
+
16
+ lshed adds three first-class ideas on top of "a directory in git":
17
+
18
+ | Idea | What it gives you |
19
+ |---|---|
20
+ | **Components** | every skill / agent / command / instruction fragment is one named part in the shed |
21
+ | **Profiles** | named recipes — `research`, `work`, `minimal` — that pick a subset of parts |
22
+ | **Managed set** | lshed remembers what it placed, so switching profiles removes only its own files and never touches yours |
23
+
24
+ Currently supports **Claude Code** (`~/.claude`). Other agents plug in through an adapter.
25
+
26
+ ## Install
27
+
28
+ ```
29
+ npm install -g lshed
30
+ ```
31
+
32
+ Node 20 or newer.
33
+
34
+ ## Quick start
35
+
36
+ ```bash
37
+ # 1. On the machine that already has your setup
38
+ lshed init --shed ~/lshed
39
+ cd ~/lshed && git init && git add -A && git commit -m "my harness" && git remote add origin <your private repo> && git push -u origin main
40
+
41
+ # 2. Edit ~/lshed/lshed.yaml — add profiles, drop parts you don't need everywhere
42
+
43
+ # 3. On any other machine
44
+ git clone <your private repo> ~/lshed
45
+ lshed restore research --shed ~/lshed # --shed only needed the first time
46
+ ```
47
+
48
+ ## The manifest
49
+
50
+ `lshed.yaml` lives at the root of the shed. `init` generates it; edit it by hand from then on.
51
+
52
+ ```yaml
53
+ version: 1
54
+ agent: claude-code
55
+
56
+ components:
57
+ skills:
58
+ - id: paper-review # source defaults to file:./skills/paper-review
59
+ - id: grading-helper
60
+ agents:
61
+ - id: reviewer # file:./agents/reviewer.md
62
+ commands:
63
+ - id: summarize
64
+ instructions:
65
+ - id: base # file:./instructions/base.md
66
+ - id: research-style
67
+
68
+ profiles:
69
+ research:
70
+ skills: [paper-review]
71
+ agents: [reviewer]
72
+ instructions: [base, research-style] # order matters
73
+ teaching:
74
+ skills: [grading-helper]
75
+ commands: [summarize]
76
+ instructions: [base]
77
+ ```
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.
80
+ - Category names come from the adapter. For Claude Code: `skills`, `agents`, `commands`, `instructions`.
81
+ - 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
+ ## Commands
84
+
85
+ ```
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
89
+ lshed diff files that differ between local and shed
90
+ lshed save [ids...] copy local edits back into the shed
91
+ ```
92
+
93
+ Global options: `--shed <dir>` (or `LSHED_HOME`; after the first restore lshed remembers it), `--root <dir>` (agent config root, default `~/.claude`).
94
+
95
+ ### What `restore` does
96
+
97
+ 1. Removes paths that the **previous** profile placed and the new one doesn't need.
98
+ 2. Copies every part of the new profile into place.
99
+ 3. Regenerates the instructions file.
100
+
101
+ Anything it overwrites or removes is backed up first under `~/.claude/lshed/backups/<timestamp>/`, unless you pass `--no-backup`. Files lshed never placed are left alone. `--dry-run` prints the plan and writes nothing.
102
+
103
+ ### Ownership
104
+
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.
106
+
107
+ ## Where things live
108
+
109
+ ```
110
+ <shed>/
111
+ lshed.yaml
112
+ skills/<id>/ agents/<id>.md commands/<id>.md instructions/<id>.md
113
+
114
+ ~/.claude/
115
+ skills/ agents/ commands/ CLAUDE.md ← placed by restore
116
+ lshed/state.json ← which profile, which paths are managed
117
+ lshed/instructions/<id>.md ← fragments imported by CLAUDE.md
118
+ lshed/backups/<timestamp>/ ← whatever restore replaced
119
+ ```
120
+
121
+ `state.json` is per machine and is not part of the shed.
122
+
123
+ ## Not in scope (yet)
124
+
125
+ - MCP servers and secrets. Planned: the manifest names the keys, values are injected locally, nothing secret enters the shed.
126
+ - `settings.json` merging (hooks, permissions).
127
+ - Remote sources (`github:`), lock file, `update`, `list --unused`, `prune`.
128
+ - Windows and macOS have not been tested. The code avoids platform-specific paths, but treat 0.1 as Linux/WSL.
129
+
130
+ ## Troubleshooting
131
+
132
+ - **"창고 위치를 모릅니다"** — pass `--shed <dir>` or set `LSHED_HOME`. After one successful `restore`, lshed remembers it.
133
+ - **restore replaced my `CLAUDE.md`** — it is in `~/.claude/lshed/backups/<timestamp>/CLAUDE.md`. Move its content into a fragment in the shed and add that fragment to your profile.
134
+ - **I edited a skill locally and want to keep it** — `lshed diff` to see, `lshed save <id>` to push it into the shed, then commit the shed.
135
+
136
+ ## License
137
+
138
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,605 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+ import { createRequire } from "module";
6
+ import os2 from "os";
7
+ import path9 from "path";
8
+
9
+ // src/adapters/claude-code.ts
10
+ import { promises as fs } from "fs";
11
+ import path from "path";
12
+ import os from "os";
13
+ var CATEGORIES = [
14
+ { name: "skills", root: "skills", kind: "dir" },
15
+ { name: "agents", root: "agents", kind: "file" },
16
+ { name: "commands", root: "commands", kind: "file" }
17
+ // instructions 는 단일 파일(CLAUDE.md)이라 스캔 대상이 아니라 생성 대상이다 (§3.3)
18
+ ];
19
+ var ClaudeCodeAdapter = class {
20
+ name = "claude-code";
21
+ root;
22
+ constructor(root) {
23
+ this.root = root ?? path.join(os.homedir(), ".claude");
24
+ }
25
+ categories() {
26
+ return CATEGORIES;
27
+ }
28
+ instructionsStrategy() {
29
+ return "import";
30
+ }
31
+ instructionsFileName() {
32
+ return "CLAUDE.md";
33
+ }
34
+ async scan() {
35
+ const out = [];
36
+ for (const cat of CATEGORIES) {
37
+ const dir = path.join(this.root, cat.root);
38
+ let entries;
39
+ try {
40
+ entries = await fs.readdir(dir, { withFileTypes: true });
41
+ } catch {
42
+ continue;
43
+ }
44
+ for (const e of entries) {
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) });
50
+ }
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ };
56
+
57
+ // src/core/init.ts
58
+ import { promises as fs5 } from "fs";
59
+ import path7 from "path";
60
+
61
+ // src/core/context.ts
62
+ import { promises as fs3 } from "fs";
63
+ import path4 from "path";
64
+
65
+ // src/manifest.ts
66
+ import { z } from "zod";
67
+ import YAML from "yaml";
68
+
69
+ // src/source.ts
70
+ var GITHUB_RE = /^([\w.-]+)\/([\w.-]+)(?:@([^#]+))?(?:#(.+))?$/;
71
+ function parseSource(raw) {
72
+ const idx = raw.indexOf(":");
73
+ if (idx <= 0) {
74
+ throw new Error(`source\uC5D0 \uC2A4\uD0B4\uC774 \uC5C6\uC2B5\uB2C8\uB2E4: "${raw}" (\uC608: file:./skills/x, github:user/repo@v1)`);
75
+ }
76
+ const scheme = raw.slice(0, idx);
77
+ const rest = raw.slice(idx + 1);
78
+ switch (scheme) {
79
+ case "file":
80
+ if (!rest) throw new Error(`file: \uB4A4\uC5D0 \uACBD\uB85C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4: "${raw}"`);
81
+ return { scheme, path: rest };
82
+ case "github": {
83
+ const m = GITHUB_RE.exec(rest);
84
+ if (!m) throw new Error(`github: \uD615\uC2DD\uC774 \uC544\uB2D9\uB2C8\uB2E4: "${raw}" (\uC608: github:user/repo@v1.0#sub/path)`);
85
+ const [, owner, repo, ref, subpath] = m;
86
+ return { scheme, owner, repo, ref, subpath };
87
+ }
88
+ default:
89
+ throw new Error(`\uC54C \uC218 \uC5C6\uB294 \uC2A4\uD0B4 "${scheme}": "${raw}"`);
90
+ }
91
+ }
92
+
93
+ // src/manifest.ts
94
+ var ComponentSchema = z.object({
95
+ id: z.string().regex(/^[\w.-]+$/, "id\uB294 \uC601\uBB38\xB7\uC22B\uC790\xB7._- \uB9CC \uD5C8\uC6A9"),
96
+ source: z.string().optional(),
97
+ tags: z.array(z.string()).optional()
98
+ });
99
+ var ComponentsSchema = z.record(z.string(), z.array(ComponentSchema));
100
+ var ProfileSchema = z.record(z.string(), z.array(z.string()));
101
+ var ManifestSchema = z.object({
102
+ version: z.literal(1),
103
+ agent: z.string().default("claude-code"),
104
+ components: ComponentsSchema.default({}),
105
+ profiles: z.record(z.string(), ProfileSchema).default({})
106
+ });
107
+ var ManifestError = class extends Error {
108
+ };
109
+ function parseManifest(text, knownCategories2) {
110
+ const raw = YAML.parse(text);
111
+ const result = ManifestSchema.safeParse(raw);
112
+ if (!result.success) {
113
+ const lines = result.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`);
114
+ throw new ManifestError(`lshed.yaml \uD615\uC2DD \uC624\uB958:
115
+ ${lines.join("\n")}`);
116
+ }
117
+ const m = result.data;
118
+ const problems = [];
119
+ for (const [cat, comps] of Object.entries(m.components)) {
120
+ if (knownCategories2 && !knownCategories2.includes(cat)) {
121
+ problems.push(`\uC54C \uC218 \uC5C6\uB294 \uCE74\uD14C\uACE0\uB9AC "${cat}" (\uC5B4\uB311\uD130 ${m.agent}: ${knownCategories2.join(", ")})`);
122
+ }
123
+ const seen = /* @__PURE__ */ new Set();
124
+ for (const c of comps) {
125
+ if (seen.has(c.id)) problems.push(`${cat}: id "${c.id}" \uC911\uBCF5`);
126
+ seen.add(c.id);
127
+ try {
128
+ parseSource(effectiveSource(cat, c));
129
+ } catch (e) {
130
+ problems.push(`${cat}/${c.id}: ${e.message}`);
131
+ }
132
+ }
133
+ }
134
+ for (const [profile, cats] of Object.entries(m.profiles)) {
135
+ for (const [cat, ids] of Object.entries(cats)) {
136
+ const available = new Set((m.components[cat] ?? []).map((c) => c.id));
137
+ for (const id of ids) {
138
+ if (!available.has(id)) problems.push(`profiles.${profile}.${cat}: "${id}" \uB294 components\uC5D0 \uC5C6\uC74C`);
139
+ }
140
+ }
141
+ }
142
+ if (problems.length) throw new ManifestError(`lshed.yaml \uCC38\uC870 \uC624\uB958:
143
+ ${problems.map((p) => " " + p).join("\n")}`);
144
+ return m;
145
+ }
146
+ function effectiveSource(category, c, kind = "dir") {
147
+ return c.source ?? `file:./${category}/${c.id}${kind === "file" ? ".md" : ""}`;
148
+ }
149
+ function stringifyManifest(m) {
150
+ return YAML.stringify(m, { lineWidth: 0 });
151
+ }
152
+
153
+ // src/resolvers/file.ts
154
+ import path2 from "path";
155
+ function resolveSource(shed, raw) {
156
+ const s = parseSource(raw);
157
+ if (s.scheme === "file") return path2.resolve(shed, s.path);
158
+ 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.`);
159
+ }
160
+ function isSaveable(raw) {
161
+ return parseSource(raw).scheme === "file";
162
+ }
163
+
164
+ // src/state.ts
165
+ import { promises as fs2 } from "fs";
166
+ import path3 from "path";
167
+ import { z as z2 } from "zod";
168
+ var StateSchema = z2.object({
169
+ profile: z2.string(),
170
+ shed: z2.string(),
171
+ /** 어댑터 루트 기준 상대 경로 (POSIX 구분자). lshed가 놓은 것만. */
172
+ managed: z2.array(z2.string()),
173
+ appliedAt: z2.string()
174
+ });
175
+ var LSHED_DIR = "lshed";
176
+ function statePath(adapter) {
177
+ return path3.join(adapter.root, LSHED_DIR, "state.json");
178
+ }
179
+ async function readState(adapter) {
180
+ try {
181
+ const raw = JSON.parse(await fs2.readFile(statePath(adapter), "utf8"));
182
+ return StateSchema.parse(raw);
183
+ } catch (e) {
184
+ if (e.code === "ENOENT") return null;
185
+ throw new Error(`state.json \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4 (${statePath(adapter)}): ${e.message}`);
186
+ }
187
+ }
188
+ async function writeState(adapter, state) {
189
+ const p = statePath(adapter);
190
+ await fs2.mkdir(path3.dirname(p), { recursive: true });
191
+ await fs2.writeFile(p, JSON.stringify(state, null, 2) + "\n");
192
+ }
193
+
194
+ // src/core/context.ts
195
+ var MANIFEST_FILE = "lshed.yaml";
196
+ var INSTRUCTIONS = "instructions";
197
+ var FRAGMENTS_DIR = `${LSHED_DIR}/instructions`;
198
+ function manifestPath(ctx) {
199
+ return path4.join(ctx.shed, MANIFEST_FILE);
200
+ }
201
+ function knownCategories(adapter) {
202
+ return [...adapter.categories().map((c) => c.name), INSTRUCTIONS];
203
+ }
204
+ async function loadManifest(ctx) {
205
+ let text;
206
+ try {
207
+ text = await fs3.readFile(manifestPath(ctx), "utf8");
208
+ } catch {
209
+ throw new Error(`\uCC3D\uACE0\uC5D0 ${MANIFEST_FILE} \uC774 \uC5C6\uC2B5\uB2C8\uB2E4: ${ctx.shed}
210
+ \uBA3C\uC800 'lshed init --shed ${ctx.shed}' \uB97C \uC2E4\uD589\uD558\uC138\uC694.`);
211
+ }
212
+ return parseManifest(text, knownCategories(ctx.adapter));
213
+ }
214
+ function targetRel(cat, id) {
215
+ if (cat === INSTRUCTIONS) return `${FRAGMENTS_DIR}/${id}.md`;
216
+ return cat.kind === "dir" ? `${cat.root}/${id}` : `${cat.root}/${id}.md`;
217
+ }
218
+ function sourcePath(ctx, category, c) {
219
+ const cat = ctx.adapter.categories().find((k) => k.name === category);
220
+ const kind = category === INSTRUCTIONS ? "file" : cat?.kind ?? "dir";
221
+ return resolveSource(ctx.shed, effectiveSource(category, c, kind));
222
+ }
223
+ function findComponent(m, category, id) {
224
+ const c = (m.components[category] ?? []).find((x) => x.id === id);
225
+ if (!c) throw new Error(`${category}/${id} \uB294 components \uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4`);
226
+ return c;
227
+ }
228
+ function planProfile(ctx, m, profile) {
229
+ const p = m.profiles[profile];
230
+ if (!p) {
231
+ const names = Object.keys(m.profiles);
232
+ throw new Error(`\uD504\uB85C\uD544 "${profile}" \uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uC788\uB294 \uD504\uB85C\uD544: ${names.length ? names.join(", ") : "(\uC5C6\uC74C)"}`);
233
+ }
234
+ const items = [];
235
+ for (const [category, ids] of Object.entries(p)) {
236
+ const cat = category === INSTRUCTIONS ? INSTRUCTIONS : ctx.adapter.categories().find((k) => k.name === category);
237
+ 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
+ for (const id of ids) {
239
+ const component = findComponent(m, category, id);
240
+ items.push({ category, id, rel: targetRel(cat, id), src: sourcePath(ctx, category, component), component });
241
+ }
242
+ }
243
+ return items;
244
+ }
245
+ function abs(ctx, rel) {
246
+ return path4.join(ctx.adapter.root, ...rel.split("/"));
247
+ }
248
+
249
+ // src/fsutil.ts
250
+ import { promises as fs4 } from "fs";
251
+ import { createHash } from "crypto";
252
+ import path5 from "path";
253
+ async function exists(p) {
254
+ try {
255
+ await fs4.access(p);
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+ async function isDir(p) {
262
+ try {
263
+ return (await fs4.stat(p)).isDirectory();
264
+ } catch {
265
+ return false;
266
+ }
267
+ }
268
+ async function listFiles(root) {
269
+ if (!await exists(root)) return [];
270
+ if (!await isDir(root)) return [""];
271
+ const out = [];
272
+ async function walk(dir, rel) {
273
+ const entries = await fs4.readdir(dir, { withFileTypes: true });
274
+ for (const e of entries) {
275
+ 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);
278
+ }
279
+ }
280
+ await walk(root, "");
281
+ return out.sort();
282
+ }
283
+ async function hashFile(p) {
284
+ return createHash("sha256").update(await fs4.readFile(p)).digest("hex");
285
+ }
286
+ async function hashTree(root) {
287
+ if (!await exists(root)) return null;
288
+ const h = createHash("sha256");
289
+ for (const rel of await listFiles(root)) {
290
+ h.update(rel).update("\0").update(await fs4.readFile(rel ? path5.join(root, rel) : root)).update("\0");
291
+ }
292
+ return h.digest("hex");
293
+ }
294
+ async function copyTree(src, dst) {
295
+ await fs4.rm(dst, { recursive: true, force: true });
296
+ await fs4.mkdir(path5.dirname(dst), { recursive: true });
297
+ await fs4.cp(src, dst, { recursive: true });
298
+ }
299
+ async function removeTree(p) {
300
+ await fs4.rm(p, { recursive: true, force: true });
301
+ }
302
+ async function diffTrees(local, shed) {
303
+ const l = new Set(await listFiles(local));
304
+ const s = new Set(await listFiles(shed));
305
+ const out = [];
306
+ for (const f of [.../* @__PURE__ */ new Set([...l, ...s])].sort()) {
307
+ const lp = f ? path5.join(local, f) : local;
308
+ const sp = f ? path5.join(shed, f) : shed;
309
+ if (l.has(f) && !s.has(f)) out.push({ status: "A", file: f });
310
+ else if (!l.has(f) && s.has(f)) out.push({ status: "D", file: f });
311
+ else if (await hashFile(lp) !== await hashFile(sp)) out.push({ status: "M", file: f });
312
+ }
313
+ return out;
314
+ }
315
+
316
+ // src/core/instructions.ts
317
+ import path6 from "path";
318
+ var MARKER = "<!-- generated by lshed";
319
+ function instructionsFile(ctx) {
320
+ return path6.join(ctx.adapter.root, ctx.adapter.instructionsFileName());
321
+ }
322
+ function isGenerated(text) {
323
+ return text.trimStart().startsWith(MARKER);
324
+ }
325
+ function renderInstructions(ctx, profile, fragments) {
326
+ const head = `${MARKER}; profile: ${profile} -->
327
+ <!-- Do not edit this file. Edit the fragments in your shed and run 'lshed restore'. -->
328
+
329
+ `;
330
+ if (ctx.adapter.instructionsStrategy() === "import") {
331
+ return head + fragments.map((f) => `@${FRAGMENTS_DIR}/${f.id}.md`).join("\n") + "\n";
332
+ }
333
+ return head + fragments.map((f) => `<!-- ${f.id} -->
334
+ ${f.content.trimEnd()}
335
+ `).join("\n");
336
+ }
337
+
338
+ // src/core/init.ts
339
+ async function init(ctx, opts = {}) {
340
+ const profileName = opts.profile ?? "default";
341
+ if (await exists(manifestPath(ctx))) {
342
+ throw new Error(`\uC774\uBBF8 \uCD08\uAE30\uD654\uB41C \uCC3D\uACE0\uC785\uB2C8\uB2E4: ${manifestPath(ctx)}
343
+ \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
+ }
345
+ const found = await ctx.adapter.scan();
346
+ const m = { version: 1, agent: ctx.adapter.name, components: {}, profiles: { [profileName]: {} } };
347
+ const managed = [];
348
+ let copied = 0;
349
+ for (const cat of ctx.adapter.categories()) {
350
+ const mine = found.filter((f) => f.category === cat.name);
351
+ if (!mine.length) continue;
352
+ m.components[cat.name] = [];
353
+ m.profiles[profileName][cat.name] = [];
354
+ 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);
357
+ m.components[cat.name].push({ id: f.id });
358
+ m.profiles[profileName][cat.name].push(f.id);
359
+ managed.push(targetRel(cat, f.id));
360
+ copied++;
361
+ ctx.log(` + ${cat.name}/${f.id}`);
362
+ }
363
+ }
364
+ const instr = instructionsFile(ctx);
365
+ if (await exists(instr)) {
366
+ const text = await fs5.readFile(instr, "utf8");
367
+ 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);
371
+ const fragRel = targetRel(INSTRUCTIONS, "main");
372
+ await copyTree(dst, abs(ctx, fragRel));
373
+ managed.push(fragRel);
374
+ m.components[INSTRUCTIONS] = [{ id: "main" }];
375
+ m.profiles[profileName][INSTRUCTIONS] = ["main"];
376
+ copied++;
377
+ ctx.log(` + ${INSTRUCTIONS}/main (${path7.basename(instr)})`);
378
+ }
379
+ }
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));
383
+ await writeState(ctx.adapter, { profile: profileName, shed: ctx.shed, managed, appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
384
+ ctx.log(`
385
+ ${MANIFEST_FILE} \uC0DD\uC131: ${manifestPath(ctx)} (\uBD80\uD488 ${copied}\uAC1C, \uD504\uB85C\uD544 "${profileName}")`);
386
+ return { manifest: m, copied };
387
+ }
388
+
389
+ // src/core/restore.ts
390
+ import { promises as fs6 } from "fs";
391
+ import path8 from "path";
392
+ async function restore(ctx, profileArg, opts = {}) {
393
+ const backup = opts.backup ?? true;
394
+ const state = await readState(ctx.adapter);
395
+ const profile = profileArg ?? state?.profile;
396
+ if (!profile) throw new Error("\uD504\uB85C\uD544\uC744 \uC9C0\uC815\uD558\uC138\uC694: lshed restore <profile> (\uC774\uC804\uC5D0 \uC801\uC6A9\uD55C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4)");
397
+ const m = await loadManifest(ctx);
398
+ const plan = planProfile(ctx, m, profile);
399
+ for (const it of plan) {
400
+ 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
+ }
402
+ const instrRel = ctx.adapter.instructionsFileName();
403
+ const fragments = plan.filter((p) => p.category === INSTRUCTIONS);
404
+ const newManaged = new Set(plan.map((p) => p.rel));
405
+ if (fragments.length) newManaged.add(instrRel);
406
+ const oldManaged = new Set(state?.managed ?? []);
407
+ const toRemove = [...oldManaged].filter((r) => !newManaged.has(r)).sort();
408
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
409
+ const backupDir = path8.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
410
+ const backedUp = [];
411
+ const placed = [];
412
+ async function backUp(rel) {
413
+ const from = abs(ctx, rel);
414
+ if (!await exists(from)) return;
415
+ backedUp.push(rel);
416
+ if (opts.dryRun || !backup) return;
417
+ await copyTree(from, path8.join(backupDir, ...rel.split("/")));
418
+ }
419
+ for (const rel of toRemove) {
420
+ ctx.log(` - ${rel}`);
421
+ await backUp(rel);
422
+ if (!opts.dryRun) await removeTree(abs(ctx, rel));
423
+ }
424
+ for (const it of plan) {
425
+ const target = abs(ctx, it.rel);
426
+ const same = await hashTree(target) === await hashTree(it.src);
427
+ const mark = same ? "=" : await exists(target) ? "~" : "+";
428
+ ctx.log(` ${mark} ${it.rel}`);
429
+ if (same) {
430
+ placed.push(it.rel);
431
+ continue;
432
+ }
433
+ if (await exists(target)) await backUp(it.rel);
434
+ if (!opts.dryRun) await copyTree(it.src, target);
435
+ placed.push(it.rel);
436
+ }
437
+ const instrPath = instructionsFile(ctx);
438
+ if (fragments.length) {
439
+ const contents = [];
440
+ for (const f of fragments) contents.push({ id: f.id, content: await fs6.readFile(f.src, "utf8") });
441
+ const rendered = renderInstructions(ctx, profile, contents);
442
+ const existing = await exists(instrPath) ? await fs6.readFile(instrPath, "utf8") : null;
443
+ if (existing !== rendered) {
444
+ const mark = existing === null ? "+" : "~";
445
+ ctx.log(` ${mark} ${instrRel}${existing !== null && !isGenerated(existing) ? " (\uAE30\uC874 \uD30C\uC77C\uC740 lshed \uC0DD\uC131\uBB3C\uC774 \uC544\uB2D8 \u2192 \uBC31\uC5C5)" : ""}`);
446
+ if (existing !== null) await backUp(instrRel);
447
+ if (!opts.dryRun) await fs6.writeFile(instrPath, rendered);
448
+ } else {
449
+ ctx.log(` = ${instrRel}`);
450
+ }
451
+ placed.push(instrRel);
452
+ }
453
+ if (opts.dryRun) {
454
+ ctx.log(`
455
+ (dry-run) \uBCC0\uACBD \uC5C6\uC74C. \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}, \uBC31\uC5C5 \uC608\uC815 ${backedUp.length}`);
456
+ return { profile, placed, removed: toRemove, backedUp, backupDir: null };
457
+ }
458
+ await writeState(ctx.adapter, { profile, shed: ctx.shed, managed: [...newManaged].sort(), appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
459
+ const bdir = backup && backedUp.length ? backupDir : null;
460
+ ctx.log(`
461
+ \uD504\uB85C\uD544 "${profile}" \uC801\uC6A9: \uBC30\uCE58 ${placed.length}, \uC81C\uAC70 ${toRemove.length}${bdir ? `, \uBC31\uC5C5 ${backedUp.length} \u2192 ${bdir}` : ""}`);
462
+ return { profile, placed, removed: toRemove, backedUp, backupDir: bdir };
463
+ }
464
+
465
+ // src/core/diff.ts
466
+ async function diff(ctx) {
467
+ const state = await readState(ctx.adapter);
468
+ 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.");
469
+ const m = await loadManifest(ctx);
470
+ const out = [];
471
+ for (const item of planProfile(ctx, m, state.profile)) {
472
+ const changes = await diffTrees(abs(ctx, item.rel), item.src);
473
+ if (changes.length) out.push({ item, changes });
474
+ }
475
+ return out;
476
+ }
477
+ function formatDiff(diffs) {
478
+ if (!diffs.length) return "\uB85C\uCEEC\uACFC \uCC3D\uACE0\uAC00 \uC77C\uCE58\uD569\uB2C8\uB2E4.";
479
+ const lines = [];
480
+ for (const d of diffs) {
481
+ lines.push(`${d.item.category}/${d.item.id}`);
482
+ for (const c of d.changes) lines.push(` ${c.status} ${c.file || "(file)"}`);
483
+ }
484
+ lines.push("", "A: \uB85C\uCEEC\uC5D0\uB9CC \uC788\uC74C M: \uB0B4\uC6A9 \uB2E4\uB984 D: \uCC3D\uACE0\uC5D0\uB9CC \uC788\uC74C", "\uB85C\uCEEC \uD3B8\uC9D1\uC744 \uCC3D\uACE0\uC5D0 \uBC18\uC601\uD558\uB824\uBA74: lshed save");
485
+ return lines.join("\n");
486
+ }
487
+
488
+ // src/core/status.ts
489
+ async function status(ctx) {
490
+ const state = await readState(ctx.adapter);
491
+ if (!state) return { state: null, drifted: [] };
492
+ const d = await diff(ctx);
493
+ return { state, drifted: d.map((x) => `${x.item.category}/${x.item.id}`) };
494
+ }
495
+ function formatStatus(s, adapterRoot) {
496
+ if (!s.state) return `\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4 (${adapterRoot}).
497
+ lshed init --shed <dir> \uB610\uB294 lshed restore <profile>`;
498
+ const lines = [
499
+ `\uD504\uB85C\uD544 ${s.state.profile}`,
500
+ `\uCC3D\uACE0 ${s.state.shed}`,
501
+ `\uC801\uC6A9 ${s.state.appliedAt}`,
502
+ `\uAD00\uB9AC \uC911 ${s.state.managed.length}\uAC1C \uACBD\uB85C (${adapterRoot})`
503
+ ];
504
+ 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");
505
+ return lines.join("\n");
506
+ }
507
+
508
+ // src/core/save.ts
509
+ async function save(ctx, ids = []) {
510
+ const state = await readState(ctx.adapter);
511
+ 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.");
512
+ const m = await loadManifest(ctx);
513
+ let plan = planProfile(ctx, m, state.profile);
514
+ if (ids.length) {
515
+ plan = ids.map((raw) => {
516
+ const [a, b] = raw.includes("/") ? raw.split("/", 2) : [void 0, raw];
517
+ const hits = plan.filter((p) => p.id === b && (a === void 0 || p.category === a));
518
+ if (!hits.length) throw new Error(`"${raw}" \uB294 \uD604\uC7AC \uD504\uB85C\uD544(${state.profile})\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4`);
519
+ if (hits.length > 1) throw new Error(`"${raw}" \uAC00 \uBAA8\uD638\uD569\uB2C8\uB2E4: ${hits.map((h) => `${h.category}/${h.id}`).join(", ")}`);
520
+ return hits[0];
521
+ });
522
+ }
523
+ const saved = [];
524
+ for (const it of plan) {
525
+ const kind = it.category === INSTRUCTIONS ? "file" : ctx.adapter.categories().find((c) => c.name === it.category).kind;
526
+ const src = effectiveSource(it.category, it.component, kind);
527
+ if (!isSaveable(src)) {
528
+ ctx.log(` ! ${it.category}/${it.id}: \uC6D0\uACA9 \uCD9C\uCC98(${src})\uB294 save \uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4`);
529
+ continue;
530
+ }
531
+ const local = abs(ctx, it.rel);
532
+ if (!await exists(local)) {
533
+ ctx.log(` ! ${it.category}/${it.id}: \uB85C\uCEEC\uC5D0 \uC5C6\uC74C (\uAC74\uB108\uB700)`);
534
+ continue;
535
+ }
536
+ if (await hashTree(local) === await hashTree(it.src)) continue;
537
+ await copyTree(local, it.src);
538
+ saved.push(`${it.category}/${it.id}`);
539
+ ctx.log(` \u2713 ${it.category}/${it.id} \u2192 \uCC3D\uACE0`);
540
+ }
541
+ ctx.log(saved.length ? `
542
+ ${saved.length}\uAC1C \uBC18\uC601. \uCC3D\uACE0\uB97C \uCEE4\uBC0B/\uB3D9\uAE30\uD654\uD558\uC138\uC694: ${ctx.shed}` : "\uBC18\uC601\uD560 \uBCC0\uACBD\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
543
+ return saved;
544
+ }
545
+
546
+ // src/cli.ts
547
+ var { version } = createRequire(import.meta.url)("../package.json");
548
+ 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
+ function adapterFromOpts() {
550
+ const { root } = program.opts();
551
+ return new ClaudeCodeAdapter(root ? path9.resolve(root) : void 0);
552
+ }
553
+ async function ctxFor(cmd) {
554
+ const adapter = adapterFromOpts();
555
+ const { shed: flag } = program.opts();
556
+ let shed = flag ?? process.env.LSHED_HOME;
557
+ if (!shed && cmd === "other") shed = (await readState(adapter))?.shed;
558
+ if (!shed && cmd === "init") shed = path9.join(os2.homedir(), "lshed");
559
+ 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) };
561
+ }
562
+ async function run(fn) {
563
+ try {
564
+ await fn();
565
+ } catch (e) {
566
+ console.error(`\uC624\uB958: ${e.message}`);
567
+ process.exitCode = 1;
568
+ }
569
+ }
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 () => {
571
+ const ctx = await ctxFor("init");
572
+ console.log(`\uC2A4\uCE94: ${ctx.adapter.root} \u2192 \uCC3D\uACE0: ${ctx.shed}`);
573
+ await init(ctx, { profile: o.profile });
574
+ console.log(`
575
+ \uB2E4\uC74C: \uCC3D\uACE0\uB97C git \uC73C\uB85C \uAD00\uB9AC\uD558\uC138\uC694. cd ${ctx.shed} && git init`);
576
+ }));
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 () => {
578
+ const ctx = await ctxFor("other");
579
+ await restore(ctx, profile, { dryRun: o.dryRun, backup: o.backup });
580
+ }));
581
+ program.command("status").description("show the applied profile, managed paths and drift").action(() => run(async () => {
582
+ const adapter = adapterFromOpts();
583
+ const state = await readState(adapter);
584
+ if (!state) {
585
+ console.log(formatStatus({ state: null, drifted: [] }, adapter.root));
586
+ return;
587
+ }
588
+ const ctx = await ctxFor("other");
589
+ console.log(formatStatus(await status(ctx), adapter.root));
590
+ }));
591
+ program.command("diff").description("list files that differ between the local harness and the shed").action(() => run(async () => {
592
+ const ctx = await ctxFor("other");
593
+ console.log(formatDiff(await diff(ctx)));
594
+ }));
595
+ program.command("save [ids...]").description("copy local edits back into the shed (file: sources only)").action((ids) => run(async () => {
596
+ const ctx = await ctxFor("other");
597
+ await save(ctx, ids);
598
+ }));
599
+ program.command("scan").description("(debug) list components found in the agent config root").action(() => run(async () => {
600
+ const adapter = adapterFromOpts();
601
+ const found = await adapter.scan();
602
+ for (const c of found) console.log(`${c.category}/${c.id} ${c.path}`);
603
+ console.error(`${found.length}\uAC1C \uBC1C\uACAC (root: ${adapter.root})`);
604
+ }));
605
+ program.parseAsync();
package/package.json CHANGED
@@ -1,13 +1,59 @@
1
1
  {
2
2
  "name": "lshed",
3
- "version": "0.0.0",
3
+ "version": "0.1.0",
4
4
  "description": "Portable harness environment manager for coding agents",
5
- "main": "index.js",
5
+ "license": "MIT",
6
+ "author": "leesongheon <leesongheon1209@gmail.com>",
7
+ "type": "module",
8
+ "bin": {
9
+ "lshed": "./dist/cli.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "CHANGELOG.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
6
20
  "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
21
+ "build": "tsup",
22
+ "dev": "tsx src/cli.ts",
23
+ "test": "vitest run",
24
+ "typecheck": "tsc --noEmit",
25
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
8
26
  },
9
- "keywords": [],
10
- "author": "",
11
- "license": "MIT",
12
- "type": "commonjs"
27
+ "keywords": [
28
+ "claude-code",
29
+ "harness",
30
+ "dotfiles",
31
+ "skills",
32
+ "mcp",
33
+ "cli"
34
+ ],
35
+ "dependencies": {
36
+ "commander": "^15.0.0",
37
+ "yaml": "^2.9.0",
38
+ "zod": "^4.5.4"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^26.4.1",
42
+ "tsup": "^8.5.1",
43
+ "tsx": "^4.23.13",
44
+ "typescript": "^7.0.2",
45
+ "vitest": "^4.1.11"
46
+ },
47
+ "allowScripts": {
48
+ "esbuild@0.27.7": true,
49
+ "esbuild@0.28.2": true
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/LeeSongHeon-LSH/lshed.git"
54
+ },
55
+ "homepage": "https://github.com/LeeSongHeon-LSH/lshed#readme",
56
+ "bugs": {
57
+ "url": "https://github.com/LeeSongHeon-LSH/lshed/issues"
58
+ }
13
59
  }