lshed 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +247 -72
  3. package/dist/cli.js +462 -205
  4. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
  import { createRequire } from "module";
6
- import os2 from "os";
7
- import path17 from "path";
6
+ import os4 from "os";
7
+ import path19 from "path";
8
8
 
9
9
  // src/adapters/claude-code.ts
10
10
  import { promises as fs4 } from "fs";
@@ -166,14 +166,62 @@ var pluginInstaller = {
166
166
  }
167
167
  };
168
168
 
169
- // src/adapters/claude-mcp.ts
170
- import { promises as fs3 } from "fs";
171
- import path3 from "path";
169
+ // src/adapters/json-entries.ts
170
+ import { promises as fs2 } from "fs";
171
+ import path2 from "path";
172
+ var JsonEntries = class {
173
+ constructor(spec) {
174
+ this.spec = spec;
175
+ this.name = spec.name;
176
+ this.secretKeys = spec.secretKeys;
177
+ this.secretRootIds = spec.secretRootIds;
178
+ this.expandsEnv = spec.expandsEnv;
179
+ }
180
+ spec;
181
+ kind = "entry";
182
+ name;
183
+ secretKeys;
184
+ secretRootIds;
185
+ expandsEnv;
186
+ file() {
187
+ return this.spec.file();
188
+ }
189
+ async load() {
190
+ const p = await this.file();
191
+ try {
192
+ return JSON.parse(await fs2.readFile(p, "utf8"));
193
+ } catch (e) {
194
+ if (e.code === "ENOENT") return {};
195
+ throw new Error(`${p} \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${e.message}`);
196
+ }
197
+ }
198
+ section(all) {
199
+ const s = this.spec.under ? all[this.spec.under] : all;
200
+ return s && typeof s === "object" && !Array.isArray(s) ? s : {};
201
+ }
202
+ async read() {
203
+ const out = { ...this.section(await this.load()) };
204
+ for (const k of this.spec.skip ?? []) delete out[k];
205
+ return out;
206
+ }
207
+ async write(id, value) {
208
+ const p = await this.file();
209
+ const all = await this.load();
210
+ const sect = { ...this.section(all) };
211
+ if (value === null) delete sect[id];
212
+ else sect[id] = value;
213
+ const next = this.spec.under ? { ...all, [this.spec.under]: sect } : sect;
214
+ await fs2.mkdir(path2.dirname(p), { recursive: true });
215
+ const tmp = `${p}.lshed-${process.pid}.tmp`;
216
+ await fs2.writeFile(tmp, JSON.stringify(next, null, 2) + "\n");
217
+ await fs2.rename(tmp, p);
218
+ }
219
+ };
172
220
 
173
221
  // src/fsutil.ts
174
- import { promises as fs2 } from "fs";
222
+ import { promises as fs3 } from "fs";
175
223
  import { createHash } from "crypto";
176
- import path2 from "path";
224
+ import path3 from "path";
177
225
 
178
226
  // src/ignore.ts
179
227
  var DEFAULT_IGNORE = [
@@ -198,7 +246,7 @@ function isIgnored(rel, patterns) {
198
246
  // src/fsutil.ts
199
247
  async function exists(p) {
200
248
  try {
201
- await fs2.access(p);
249
+ await fs3.access(p);
202
250
  return true;
203
251
  } catch {
204
252
  return false;
@@ -206,7 +254,7 @@ async function exists(p) {
206
254
  }
207
255
  async function isDir(p) {
208
256
  try {
209
- return (await fs2.stat(p)).isDirectory();
257
+ return (await fs3.stat(p)).isDirectory();
210
258
  } catch {
211
259
  return false;
212
260
  }
@@ -216,14 +264,14 @@ async function listFiles(root, ignore = DEFAULT_IGNORE) {
216
264
  if (!await isDir(root)) return [""];
217
265
  const out = [];
218
266
  async function walk2(dir, rel) {
219
- const entries = await fs2.readdir(dir, { withFileTypes: true });
267
+ const entries = await fs3.readdir(dir, { withFileTypes: true });
220
268
  for (const e of entries) {
221
269
  const r = rel ? `${rel}/${e.name}` : e.name;
222
270
  if (isIgnored(r, ignore)) continue;
223
- const full = path2.join(dir, e.name);
271
+ const full = path3.join(dir, e.name);
224
272
  let st;
225
273
  try {
226
- st = await fs2.stat(full);
274
+ st = await fs3.stat(full);
227
275
  } catch {
228
276
  continue;
229
277
  }
@@ -235,28 +283,28 @@ async function listFiles(root, ignore = DEFAULT_IGNORE) {
235
283
  return out.sort();
236
284
  }
237
285
  async function hashFile(p) {
238
- return createHash("sha256").update(await fs2.readFile(p)).digest("hex");
286
+ return createHash("sha256").update(await fs3.readFile(p)).digest("hex");
239
287
  }
240
288
  async function hashTree(root, ignore = DEFAULT_IGNORE) {
241
289
  if (!await exists(root)) return null;
242
290
  const h = createHash("sha256");
243
291
  for (const rel of await listFiles(root, ignore)) {
244
- h.update(rel).update("\0").update(await fs2.readFile(rel ? path2.join(root, rel) : root)).update("\0");
292
+ h.update(rel).update("\0").update(await fs3.readFile(rel ? path3.join(root, rel) : root)).update("\0");
245
293
  }
246
294
  return h.digest("hex");
247
295
  }
248
296
  async function copyTree(src, dst, ignore = DEFAULT_IGNORE) {
249
- await fs2.rm(dst, { recursive: true, force: true });
250
- await fs2.mkdir(path2.dirname(dst), { recursive: true });
251
- const srcRoot = path2.resolve(src);
252
- await fs2.cp(src, dst, {
297
+ await fs3.rm(dst, { recursive: true, force: true });
298
+ await fs3.mkdir(path3.dirname(dst), { recursive: true });
299
+ const srcRoot = path3.resolve(src);
300
+ await fs3.cp(src, dst, {
253
301
  recursive: true,
254
302
  dereference: true,
255
303
  filter: async (from) => {
256
- const rel = path2.relative(srcRoot, path2.resolve(from)).split(path2.sep).join("/");
304
+ const rel = path3.relative(srcRoot, path3.resolve(from)).split(path3.sep).join("/");
257
305
  if (isIgnored(rel, ignore)) return false;
258
306
  try {
259
- if ((await fs2.lstat(from)).isSymbolicLink()) await fs2.stat(from);
307
+ if ((await fs3.lstat(from)).isSymbolicLink()) await fs3.stat(from);
260
308
  } catch {
261
309
  return false;
262
310
  }
@@ -265,15 +313,15 @@ async function copyTree(src, dst, ignore = DEFAULT_IGNORE) {
265
313
  });
266
314
  }
267
315
  async function removeTree(p) {
268
- await fs2.rm(p, { recursive: true, force: true });
316
+ await fs3.rm(p, { recursive: true, force: true });
269
317
  }
270
318
  async function diffTrees(local, shed, ignore = DEFAULT_IGNORE) {
271
319
  const l = new Set(await listFiles(local, ignore));
272
320
  const s = new Set(await listFiles(shed, ignore));
273
321
  const out = [];
274
322
  for (const f of [.../* @__PURE__ */ new Set([...l, ...s])].sort()) {
275
- const lp = f ? path2.join(local, f) : local;
276
- const sp = f ? path2.join(shed, f) : shed;
323
+ const lp = f ? path3.join(local, f) : local;
324
+ const sp = f ? path3.join(shed, f) : shed;
277
325
  if (l.has(f) && !s.has(f)) out.push({ status: "A", file: f });
278
326
  else if (!l.has(f) && s.has(f)) out.push({ status: "D", file: f });
279
327
  else if (await hashFile(lp) !== await hashFile(sp)) out.push({ status: "M", file: f });
@@ -281,48 +329,6 @@ async function diffTrees(local, shed, ignore = DEFAULT_IGNORE) {
281
329
  return out;
282
330
  }
283
331
 
284
- // src/adapters/claude-mcp.ts
285
- var ClaudeMcpEntries = class {
286
- constructor(root) {
287
- this.root = root;
288
- }
289
- root;
290
- name = "mcp";
291
- kind = "entry";
292
- secretKeys = ["env", "headers"];
293
- expandsEnv = true;
294
- /** ~/.claude 의 형제 ~/.claude.json. CLAUDE_CONFIG_DIR 처럼 루트 안에 있으면 그것을 쓴다. */
295
- async file() {
296
- const inside = path3.join(this.root, ".claude.json");
297
- return await exists(inside) ? inside : `${this.root}.json`;
298
- }
299
- async load() {
300
- const p = await this.file();
301
- try {
302
- return JSON.parse(await fs3.readFile(p, "utf8"));
303
- } catch (e) {
304
- if (e.code === "ENOENT") return {};
305
- throw new Error(`${p} \uC744 \uC77D\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4: ${e.message}`);
306
- }
307
- }
308
- async read() {
309
- const servers = (await this.load()).mcpServers;
310
- return servers && typeof servers === "object" ? { ...servers } : {};
311
- }
312
- async write(id, value) {
313
- const p = await this.file();
314
- const all = await this.load();
315
- const servers = { ...all.mcpServers ?? {} };
316
- if (value === null) delete servers[id];
317
- else servers[id] = value;
318
- all.mcpServers = servers;
319
- await fs3.mkdir(path3.dirname(p), { recursive: true });
320
- const tmp = `${p}.lshed-${process.pid}.tmp`;
321
- await fs3.writeFile(tmp, JSON.stringify(all, null, 2) + "\n");
322
- await fs3.rename(tmp, p);
323
- }
324
- };
325
-
326
332
  // src/adapters/claude-code.ts
327
333
  var CATEGORIES = [
328
334
  { name: "skills", root: "skills", kind: "dir" },
@@ -333,13 +339,42 @@ var CATEGORIES = [
333
339
  var ClaudeCodeAdapter = class {
334
340
  name = "claude-code";
335
341
  root;
336
- mcp;
342
+ entryCats;
337
343
  constructor(root) {
338
344
  this.root = root ?? process.env.CLAUDE_CONFIG_DIR ?? path4.join(os.homedir(), ".claude");
339
- this.mcp = new ClaudeMcpEntries(this.root);
345
+ const root_ = this.root;
346
+ this.entryCats = [
347
+ /**
348
+ * 사용자 범위 MCP (§7.4): ~/.claude 의 형제 ~/.claude.json 의 mcpServers. CLAUDE_CONFIG_DIR 처럼 루트 안에 있으면 그것.
349
+ * Claude Code 가 ${VAR} 를 모든 범위에서 스스로 확장하므로 자리표시자를 그대로 둔다.
350
+ */
351
+ new JsonEntries({
352
+ name: "mcp",
353
+ file: async () => {
354
+ const inside = path4.join(root_, ".claude.json");
355
+ return await exists(inside) ? inside : `${root_}.json`;
356
+ },
357
+ under: "mcpServers",
358
+ secretKeys: ["env", "headers"],
359
+ expandsEnv: true
360
+ }),
361
+ /**
362
+ * settings.json (§7.6): 최상위 키 하나 = 항목 하나 (hooks, permissions, env, model, ...).
363
+ * 병합하지 않는다. 키를 통째로 소유하고, 로컬 편집은 diff/save 로 되가져온다.
364
+ * enabledPlugins 는 플러그인 설치기가 만드는 상태라 담지 않는다. ${VAR} 는 Claude Code 가 안 채우므로 restore 가 채운다.
365
+ */
366
+ new JsonEntries({
367
+ name: "settings",
368
+ file: async () => path4.join(root_, "settings.json"),
369
+ secretKeys: [],
370
+ secretRootIds: ["env"],
371
+ expandsEnv: false,
372
+ skip: ["enabledPlugins"]
373
+ })
374
+ ];
340
375
  }
341
376
  entries() {
342
- return [this.mcp];
377
+ return this.entryCats;
343
378
  }
344
379
  categories() {
345
380
  return CATEGORIES;
@@ -493,6 +528,8 @@ var ManifestSchema = z.object({
493
528
  agent: z.string().default("claude-code"),
494
529
  /** 창고에 담지 않을 이름들. 기본값(DEFAULT_IGNORE)에 더해진다. */
495
530
  ignore: z.array(z.string()).optional(),
531
+ /** 로컬에 있어도 창고에 넣지 않을 부품 ("id" 또는 "category/id"). init --exclude 가 적고 add/status 가 따른다. */
532
+ exclude: z.array(z.string()).optional(),
496
533
  components: ComponentsSchema.default({}),
497
534
  packages: z.array(PackageSchema).default([]),
498
535
  profiles: z.record(z.string(), ProfileSchema).default({})
@@ -558,12 +595,6 @@ function effectiveSource(category, c, kind = "dir") {
558
595
  const ext = kind === "file" ? ".md" : kind === "entry" ? ".json" : "";
559
596
  return c.source ?? `file:./${category}/${c.id}${ext}`;
560
597
  }
561
- function stringifyManifest(m) {
562
- const out = { ...m };
563
- if (!m.packages.length) delete out.packages;
564
- if (!m.ignore?.length) delete out.ignore;
565
- return YAML.stringify(out, { lineWidth: 0 });
566
- }
567
598
  function packagesOf(m, profile) {
568
599
  const ids = m.profiles[profile]?.[PACKAGES] ?? [];
569
600
  return ids.map((id) => m.packages.find((p) => p.id === id));
@@ -701,7 +732,8 @@ function abs(ctx, rel) {
701
732
 
702
733
  // src/core/init.ts
703
734
  import { promises as fs11 } from "fs";
704
- import path14 from "path";
735
+ import path16 from "path";
736
+ import YAML4 from "yaml";
705
737
 
706
738
  // src/core/instructions.ts
707
739
  import path10 from "path";
@@ -725,10 +757,6 @@ ${f.content.trimEnd()}
725
757
  `).join("\n");
726
758
  }
727
759
 
728
- // src/core/packages.ts
729
- import { promises as fs9 } from "fs";
730
- import path12 from "path";
731
-
732
760
  // src/lock.ts
733
761
  import { promises as fs8 } from "fs";
734
762
  import path11 from "path";
@@ -753,7 +781,12 @@ async function writeLock(shed, lock) {
753
781
  await fs8.writeFile(path11.join(shed, LOCK_FILE), "# generated by lshed \u2014 do not edit; 'lshed update' refreshes it\n" + YAML2.stringify(sorted));
754
782
  }
755
783
 
784
+ // src/core/discover.ts
785
+ import path13 from "path";
786
+
756
787
  // src/core/packages.ts
788
+ import { promises as fs9 } from "fs";
789
+ import path12 from "path";
757
790
  async function detectPackages(ctx, found) {
758
791
  const out = [];
759
792
  for (const inst of installersFor(ctx)) out.push(...await inst.detect(ctx, found));
@@ -868,10 +901,76 @@ async function updatePackages(ctx, pkgs, opts = {}) {
868
901
  return res;
869
902
  }
870
903
 
904
+ // src/core/discover.ts
905
+ var keyOf = (f) => `${f.category}/${f.id}`;
906
+ async function discover(ctx, exclude = []) {
907
+ const isExcluded = (cat, id) => exclude.some((e) => e === id || e === `${cat}/${id}`);
908
+ const excluded = [];
909
+ const items = [];
910
+ const all = await ctx.adapter.scan();
911
+ const pkgs = await detectPackages(ctx, all);
912
+ const kept = pkgs.filter((p) => {
913
+ if (isExcluded("packages", p.id)) {
914
+ excluded.push(`packages/${p.id}`);
915
+ return false;
916
+ }
917
+ return true;
918
+ });
919
+ const generated = await detectGenerated(all, kept);
920
+ for (const p of kept) items.push({ kind: "package", category: "packages", id: p.id, pkg: p });
921
+ for (const cat of ctx.adapter.categories()) {
922
+ for (const f of all.filter((f2) => f2.category === cat.name)) {
923
+ if (pkgs.some((p) => p.path === f.path) || generated.has(keyOf(f))) continue;
924
+ if (isExcluded(f.category, f.id)) {
925
+ excluded.push(keyOf(f));
926
+ continue;
927
+ }
928
+ items.push({ kind: "component", category: cat.name, id: f.id, path: f.path, cat });
929
+ }
930
+ }
931
+ for (const cat of ctx.adapter.entries()) {
932
+ const all2 = await cat.read();
933
+ for (const id of Object.keys(all2).sort()) {
934
+ if (isExcluded(cat.name, id)) {
935
+ excluded.push(`${cat.name}/${id}`);
936
+ continue;
937
+ }
938
+ if (!/^[\w.-]+$/.test(id)) {
939
+ ctx.log(` ! ${cat.name}/${id}: \uC774\uB984\uC5D0 \uC4F8 \uC218 \uC5C6\uB294 \uBB38\uC790\uAC00 \uC788\uC5B4 \uAC74\uB108\uB700`);
940
+ continue;
941
+ }
942
+ const owner = kept.find((p) => p.path && JSON.stringify(all2[id]).includes(p.path));
943
+ const warn = owner ? `\uD328\uD0A4\uC9C0 ${owner.id} \uC548\uC744 \uAC00\uB9AC\uD0B5\uB2C8\uB2E4. \uADF8 \uC124\uCE58\uAC00 \uB9CC\uB4E0 \uAC83\uC774\uBA74 exclude \uD558\uC138\uC694: ${cat.name}/${id}` : void 0;
944
+ items.push({ kind: "entry", category: cat.name, id, value: all2[id], cat, warn });
945
+ }
946
+ }
947
+ return { items, generated, excluded };
948
+ }
949
+ function notInManifest(m, d) {
950
+ return d.items.filter(
951
+ (f) => f.kind === "package" ? !m.packages.some((p) => p.id === f.id) : !(m.components[f.category] ?? []).some((c) => c.id === f.id)
952
+ );
953
+ }
954
+ function inManifestNotInProfile(m, profile, d) {
955
+ const p = m.profiles[profile] ?? {};
956
+ return d.items.filter((f) => !notInManifest(m, d).includes(f)).filter((f) => !(p[f.category] ?? []).includes(f.id)).map(keyOf);
957
+ }
958
+ function shortRev(rev) {
959
+ return /^[0-9a-f]{40}$/.test(rev) ? rev.slice(0, 7) : rev;
960
+ }
961
+
962
+ // src/core/ingest.ts
963
+ import path15 from "path";
964
+ import { isSeq, isMap } from "yaml";
965
+
871
966
  // src/core/entries.ts
872
967
  import { promises as fs10 } from "fs";
873
- import path13 from "path";
874
- var SECRET_KEY_RE = /key|token|secret|pass|auth|credential|cookie|session/i;
968
+ import os2 from "os";
969
+ import path14 from "path";
970
+ var SECRET_WORDS = /* @__PURE__ */ new Set(["key", "apikey", "token", "secret", "password", "passwd", "auth", "authorization", "credential", "credentials", "cookie", "session"]);
971
+ function isSecretKey(k) {
972
+ return k.split(/[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])/).some((w) => SECRET_WORDS.has(w.toLowerCase()));
973
+ }
875
974
  var PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
876
975
  function placeholdersIn(v) {
877
976
  const out = /* @__PURE__ */ new Set();
@@ -888,27 +987,39 @@ function walk(v, onString, keyPath = []) {
888
987
  return v;
889
988
  }
890
989
  var envName = (...parts) => parts.join("_").replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase();
891
- function mask(id, entry, cat) {
990
+ function mask(id, entry, cat, home = os2.homedir()) {
991
+ entry = portable(entry, home);
892
992
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry;
893
- const out = { ...entry };
894
- for (const sk of cat.secretKeys) {
895
- const sect = out[sk];
896
- if (!sect || typeof sect !== "object" || Array.isArray(sect)) continue;
993
+ const maskSection = (sect, envLike) => {
897
994
  const masked = {};
898
995
  for (const [k, v] of Object.entries(sect)) {
899
- if (typeof v !== "string" || !SECRET_KEY_RE.test(k) || PLACEHOLDER_RE.test(v)) {
996
+ if (typeof v !== "string" || !isSecretKey(k) || PLACEHOLDER_RE.test(v)) {
900
997
  masked[k] = v;
901
998
  PLACEHOLDER_RE.lastIndex = 0;
902
999
  continue;
903
1000
  }
904
- const name = sk === "env" ? envName(k) : envName(id, k);
1001
+ const name = envLike ? envName(k) : envName(id, k);
905
1002
  const scheme = /^(\w+) \S+$/.exec(v);
906
1003
  masked[k] = scheme ? `${scheme[1]} \${${name}}` : `\${${name}}`;
907
1004
  }
908
- out[sk] = masked;
1005
+ return masked;
1006
+ };
1007
+ if (cat.secretRootIds?.includes(id)) return maskSection(entry, true);
1008
+ const out = { ...entry };
1009
+ for (const sk of cat.secretKeys) {
1010
+ const sect = out[sk];
1011
+ if (!sect || typeof sect !== "object" || Array.isArray(sect)) continue;
1012
+ out[sk] = maskSection(sect, sk === "env");
909
1013
  }
910
1014
  return out;
911
1015
  }
1016
+ function portable(entry, home = os2.homedir()) {
1017
+ if (!home || home === "/") return entry;
1018
+ return walk(entry, (s) => s === home || s.startsWith(home + "/") ? "${HOME}" + s.slice(home.length) : s);
1019
+ }
1020
+ function envWithHome(env = process.env) {
1021
+ return { HOME: os2.homedir(), ...env };
1022
+ }
912
1023
  function suspiciousStrings(entry) {
913
1024
  const out = [];
914
1025
  walk(entry, (s, kp) => {
@@ -949,7 +1060,7 @@ function matches2(shed, local) {
949
1060
  }
950
1061
  return shed === local;
951
1062
  }
952
- function remask(id, local, shed, cat) {
1063
+ function remask(id, local, shed, cat, home = os2.homedir()) {
953
1064
  const keep = (l, s) => {
954
1065
  if (typeof l === "string" && typeof s === "string" && stringMatches(s, l)) return s;
955
1066
  if (Array.isArray(l) && Array.isArray(s)) return l.map((x2, i) => keep(x2, s[i]));
@@ -958,7 +1069,7 @@ function remask(id, local, shed, cat) {
958
1069
  }
959
1070
  return l;
960
1071
  };
961
- return mask(id, shed === null ? local : keep(local, shed), cat);
1072
+ return mask(id, shed === null ? local : keep(local, portable(shed, home)), cat, home);
962
1073
  }
963
1074
  function diffEntry(shed, local) {
964
1075
  const out = [];
@@ -990,124 +1101,115 @@ async function readEntryFile(p) {
990
1101
  }
991
1102
  }
992
1103
  async function writeEntryFile(p, v) {
993
- await fs10.mkdir(path13.dirname(p), { recursive: true });
1104
+ await fs10.mkdir(path14.dirname(p), { recursive: true });
994
1105
  await fs10.writeFile(p, JSON.stringify(v, null, 2) + "\n");
995
1106
  }
996
1107
 
1108
+ // src/core/ingest.ts
1109
+ function seqAt(doc, p) {
1110
+ let node = doc.getIn(p);
1111
+ if (!isSeq(node)) {
1112
+ node = doc.createNode([]);
1113
+ doc.setIn(p, node);
1114
+ }
1115
+ return node;
1116
+ }
1117
+ function pushUnique(seq, value) {
1118
+ if (!seq.items.some((it) => (isMap(it) ? it.get("id") : String(it)) === value)) seq.add(value);
1119
+ }
1120
+ async function ingest(ctx, doc, profile, items, lock) {
1121
+ const out = { managed: [], copied: 0, packages: [] };
1122
+ for (const f of items) {
1123
+ if (f.kind === "package") {
1124
+ const p = f.pkg;
1125
+ const node = doc.createNode(p.into ? { id: p.id, source: p.source, into: p.into } : { id: p.id, source: p.source });
1126
+ if (p.into) node.comment = " install: ./setup # \u2190 \uBCF5\uC6D0 \uD6C4 \uC2E4\uD589\uD560 \uBA85\uB839\uC774 \uC788\uC73C\uBA74 \uCC44\uC6B0\uC138\uC694 (--yes \uB85C \uC2E4\uD589)";
1127
+ const seq = seqAt(doc, [PACKAGES]);
1128
+ if (!seq.items.some((it) => isMap(it) && it.get("id") === p.id)) seq.add(node);
1129
+ pushUnique(seqAt(doc, ["profiles", profile, PACKAGES]), p.id);
1130
+ lock.packages[p.id] = { source: p.source, rev: p.rev };
1131
+ out.packages.push(p.id);
1132
+ ctx.log(` \u2261 package ${p.id} ${p.source} @${shortRev(p.rev)} (\uCC38\uC870\uB9CC \uAE30\uB85D)`);
1133
+ continue;
1134
+ }
1135
+ if (f.kind === "component") {
1136
+ const dst = path15.join(ctx.shed, f.cat.root, f.cat.kind === "dir" ? f.id : `${f.id}.md`);
1137
+ await copyTree(f.path, dst, ignoreOf(ctx));
1138
+ ctx.log(` + ${f.category}/${f.id}`);
1139
+ } else {
1140
+ const masked = mask(f.id, f.value, f.cat);
1141
+ await writeEntryFile(path15.join(ctx.shed, f.category, `${f.id}.json`), masked);
1142
+ const vars = placeholdersIn(masked).filter((v) => v !== "HOME");
1143
+ ctx.log(` + ${f.category}/${f.id}${vars.length ? ` (\uC2DC\uD06C\uB9BF \u2192 ${vars.map((v) => "${" + v + "}").join(", ")})` : ""}`);
1144
+ for (const where of suspiciousStrings(masked)) ctx.log(` ! ${where} \uAC00 \uC2DC\uD06C\uB9BF\uCC98\uB7FC \uBCF4\uC785\uB2C8\uB2E4. \uCC3D\uACE0\uC758 ${f.category}/${f.id}.json \uC5D0\uC11C \${VAR} \uB85C \uBC14\uAFB8\uC138\uC694`);
1145
+ if (f.warn) ctx.log(` ! ${f.warn}`);
1146
+ }
1147
+ const comps = seqAt(doc, ["components", f.category]);
1148
+ if (!comps.items.some((it) => isMap(it) && it.get("id") === f.id)) comps.add(doc.createNode({ id: f.id }));
1149
+ pushUnique(seqAt(doc, ["profiles", profile, f.category]), f.id);
1150
+ out.managed.push(targetRel(f.cat, f.id));
1151
+ out.copied++;
1152
+ }
1153
+ return out;
1154
+ }
1155
+ function tidy(doc) {
1156
+ const pk = doc.get(PACKAGES);
1157
+ if (isSeq(pk) && !pk.items.length) doc.delete(PACKAGES);
1158
+ }
1159
+
997
1160
  // src/core/init.ts
1161
+ var MANIFEST_HEADER = "# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed\n";
998
1162
  async function init(ctx, opts = {}) {
999
1163
  const profileName = opts.profile ?? "default";
1000
- const exclude = opts.exclude ?? [];
1001
- const skipped = [];
1002
1164
  if (await exists(manifestPath(ctx))) {
1003
1165
  throw new Error(`\uC774\uBBF8 \uCD08\uAE30\uD654\uB41C \uCC3D\uACE0\uC785\uB2C8\uB2E4: ${manifestPath(ctx)}
1004
- \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.`);
1005
- }
1006
- const all = await ctx.adapter.scan();
1007
- const m = { version: 1, agent: ctx.adapter.name, components: {}, packages: [], profiles: { [profileName]: {} } };
1008
- const isExcluded = (cat, id) => exclude.some((e) => e === id || e === `${cat}/${id}`);
1009
- const pkgs = (await detectPackages(ctx, all)).filter((p) => !isExcluded("", p.id));
1010
- const generated = await detectGenerated(all, pkgs);
1011
- const found = all.filter((f) => !pkgs.some((p) => p.path === f.path) && !generated.has(`${f.category}/${f.id}`));
1012
- for (const p of pkgs) {
1013
- m.packages.push(p.into ? { id: p.id, source: p.source, into: p.into } : { id: p.id, source: p.source });
1014
- const rev = /^[0-9a-f]{40}$/.test(p.rev) ? p.rev.slice(0, 7) : p.rev;
1015
- ctx.log(` \u2261 package ${p.id} ${p.source} @${rev} (\uCC38\uC870\uB9CC \uAE30\uB85D)`);
1016
- }
1017
- if (pkgs.length) m.profiles[profileName][PACKAGES] = pkgs.map((p) => p.id);
1018
- for (const [key, by] of generated) ctx.log(` \xB7 ${key} (${by} \uAC00 \uC0DD\uC131\uD55C \uAC83 \u2192 \uAC74\uB108\uB700)`);
1019
- const managed = [];
1020
- let copied = 0;
1021
- for (const cat of ctx.adapter.categories()) {
1022
- const mine = found.filter((f) => f.category === cat.name && !isExcluded(f.category, f.id));
1023
- for (const f of found.filter((f2) => f2.category === cat.name && isExcluded(f2.category, f2.id))) {
1024
- skipped.push(`${f.category}/${f.id}`);
1025
- ctx.log(` - ${f.category}/${f.id} (--exclude)`);
1026
- }
1027
- if (!mine.length) continue;
1028
- m.components[cat.name] = [];
1029
- m.profiles[profileName][cat.name] = [];
1030
- for (const f of mine) {
1031
- const dst = path14.join(ctx.shed, cat.root, cat.kind === "dir" ? f.id : `${f.id}.md`);
1032
- await copyTree(f.path, dst, ignoreOf(ctx));
1033
- m.components[cat.name].push({ id: f.id });
1034
- m.profiles[profileName][cat.name].push(f.id);
1035
- managed.push(targetRel(cat, f.id));
1036
- copied++;
1037
- ctx.log(` + ${cat.name}/${f.id}`);
1038
- }
1039
- }
1040
- for (const cat of ctx.adapter.entries()) {
1041
- const all2 = await cat.read();
1042
- const ids = Object.keys(all2).sort();
1043
- for (const id of ids.filter((id2) => isExcluded(cat.name, id2))) {
1044
- skipped.push(`${cat.name}/${id}`);
1045
- ctx.log(` - ${cat.name}/${id} (--exclude)`);
1046
- }
1047
- const mine = ids.filter((id) => !isExcluded(cat.name, id));
1048
- if (!mine.length) continue;
1049
- m.components[cat.name] = [];
1050
- m.profiles[profileName][cat.name] = [];
1051
- for (const id of mine) {
1052
- if (!/^[\w.-]+$/.test(id)) {
1053
- ctx.log(` ! ${cat.name}/${id}: \uC774\uB984\uC5D0 \uC4F8 \uC218 \uC5C6\uB294 \uBB38\uC790\uAC00 \uC788\uC5B4 \uAC74\uB108\uB700`);
1054
- continue;
1055
- }
1056
- const masked = mask(id, all2[id], cat);
1057
- await writeEntryFile(path14.join(ctx.shed, cat.name, `${id}.json`), masked);
1058
- m.components[cat.name].push({ id });
1059
- m.profiles[profileName][cat.name].push(id);
1060
- managed.push(targetRel(cat, id));
1061
- copied++;
1062
- const vars = placeholdersIn(masked);
1063
- ctx.log(` + ${cat.name}/${id}${vars.length ? ` (\uC2DC\uD06C\uB9BF \u2192 ${vars.map((v) => "${" + v + "}").join(", ")})` : ""}`);
1064
- for (const where of suspiciousStrings(masked)) ctx.log(` ! ${where} \uAC00 \uC2DC\uD06C\uB9BF\uCC98\uB7FC \uBCF4\uC785\uB2C8\uB2E4. \uCC3D\uACE0\uC758 ${cat.name}/${id}.json \uC5D0\uC11C \${VAR} \uB85C \uBC14\uAFB8\uC138\uC694`);
1065
- }
1066
- }
1166
+ \uC774 \uD658\uACBD\uC5D0 \uC0C8\uB85C \uC0DD\uAE34 \uAC83\uC744 \uAE30\uC874 \uCC3D\uACE0\uC5D0 \uB123\uC73C\uB824\uBA74 'lshed add' \uB97C, \uB2E4\uB978 \uD658\uACBD\uC758 \uC124\uC815\uC744 \uAC00\uC838\uC624\uB824\uBA74 'lshed restore' \uD6C4 'lshed save' \uB97C \uC4F0\uC138\uC694.`);
1167
+ }
1168
+ const d = await discover(ctx, opts.exclude);
1169
+ for (const [key, by] of d.generated) ctx.log(` \xB7 ${key} (${by} \uAC00 \uC0DD\uC131\uD55C \uAC83 \u2192 \uAC74\uB108\uB700)`);
1170
+ for (const key of d.excluded) ctx.log(` - ${key} (--exclude)`);
1171
+ const exclude = opts.exclude?.length ? { exclude: [...opts.exclude] } : {};
1172
+ const doc = new YAML4.Document({ version: 1, agent: ctx.adapter.name, ...exclude, components: {}, packages: [], profiles: { [profileName]: {} } });
1173
+ const lock = { version: 1, packages: {} };
1174
+ await fs11.mkdir(ctx.shed, { recursive: true });
1175
+ const res = await ingest(ctx, doc, profileName, d.items, lock);
1176
+ const managed = [...res.managed];
1177
+ let copied = res.copied;
1067
1178
  const instr = instructionsFile(ctx);
1068
1179
  if (await exists(instr)) {
1069
1180
  const text = await fs11.readFile(instr, "utf8");
1070
1181
  if (!isGenerated(text)) {
1071
- const dst = path14.join(ctx.shed, INSTRUCTIONS, "main.md");
1072
- await fs11.mkdir(path14.dirname(dst), { recursive: true });
1182
+ const dst = path16.join(ctx.shed, INSTRUCTIONS, "main.md");
1183
+ await fs11.mkdir(path16.dirname(dst), { recursive: true });
1073
1184
  await fs11.writeFile(dst, text);
1074
1185
  const fragRel = targetRel(INSTRUCTIONS, "main");
1075
1186
  await copyTree(dst, abs(ctx, fragRel), ignoreOf(ctx));
1076
1187
  managed.push(fragRel);
1077
- m.components[INSTRUCTIONS] = [{ id: "main" }];
1078
- m.profiles[profileName][INSTRUCTIONS] = ["main"];
1188
+ doc.setIn(["components", INSTRUCTIONS], [{ id: "main" }]);
1189
+ doc.setIn(["profiles", profileName, INSTRUCTIONS], ["main"]);
1079
1190
  copied++;
1080
- ctx.log(` + ${INSTRUCTIONS}/main (${path14.basename(instr)})`);
1191
+ ctx.log(` + ${INSTRUCTIONS}/main (${path16.basename(instr)})`);
1081
1192
  }
1082
1193
  }
1083
- await fs11.mkdir(ctx.shed, { recursive: true });
1084
- let yamlText = stringifyManifest(m);
1085
- for (const p of pkgs) {
1086
- if (!p.into) continue;
1087
- yamlText = yamlText.replace(` into: ${p.into}
1088
- `, ` into: ${p.into}
1089
- # install: ./setup # \u2190 \uBCF5\uC6D0 \uD6C4 \uC2E4\uD589\uD560 \uBA85\uB839\uC774 \uC788\uC73C\uBA74 \uCC44\uC6B0\uC138\uC694 (--yes \uB85C \uC2E4\uD589)
1090
- `);
1091
- }
1092
- await fs11.writeFile(manifestPath(ctx), `# lshed manifest \u2014 edit freely. Reference: https://github.com/LeeSongHeon-LSH/lshed
1093
- ` + yamlText);
1094
- if (pkgs.length) {
1095
- await writeLock(ctx.shed, { version: 1, packages: Object.fromEntries(pkgs.map((p) => [p.id, { source: p.source, rev: p.rev }])) });
1096
- }
1194
+ tidy(doc);
1195
+ const yamlText = MANIFEST_HEADER + doc.toString({ lineWidth: 0 });
1196
+ await fs11.writeFile(manifestPath(ctx), yamlText);
1197
+ if (res.packages.length) await writeLock(ctx.shed, lock);
1097
1198
  await writeState(ctx.adapter, { profile: profileName, shed: ctx.shed, managed, appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
1098
1199
  const parts = [`\uBD80\uD488 ${copied}\uAC1C`];
1099
- if (pkgs.length) parts.push(`\uD328\uD0A4\uC9C0 ${pkgs.length}\uAC1C`);
1100
- if (generated.size) parts.push(`\uC0DD\uC131\uBB3C ${generated.size}\uAC1C \uAC74\uB108\uB700`);
1101
- if (skipped.length) parts.push(`\uC81C\uC678 ${skipped.length}\uAC1C`);
1200
+ if (res.packages.length) parts.push(`\uD328\uD0A4\uC9C0 ${res.packages.length}\uAC1C`);
1201
+ if (d.generated.size) parts.push(`\uC0DD\uC131\uBB3C ${d.generated.size}\uAC1C \uAC74\uB108\uB700`);
1202
+ if (d.excluded.length) parts.push(`\uC81C\uC678 ${d.excluded.length}\uAC1C`);
1102
1203
  ctx.log(`
1103
1204
  ${MANIFEST_FILE} \uC0DD\uC131: ${manifestPath(ctx)} (${parts.join(", ")}, \uD504\uB85C\uD544 "${profileName}")`);
1104
- 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.`);
1105
- return { manifest: m, copied, skipped, packages: pkgs.map((p) => p.id), generated: [...generated.keys()] };
1205
+ if (d.items.some((f) => f.kind === "package" && f.pkg.into)) ctx.log(`git \uD328\uD0A4\uC9C0\uC758 \uC124\uCE58 \uBA85\uB839(install:)\uC740 lshed.yaml \uC5D0\uC11C \uC9C1\uC811 \uCC44\uC6B0\uC138\uC694.`);
1206
+ const manifest = parseManifest(yamlText);
1207
+ return { manifest, copied, skipped: d.excluded, packages: res.packages, generated: [...d.generated.keys()] };
1106
1208
  }
1107
1209
 
1108
1210
  // src/core/restore.ts
1109
1211
  import { promises as fs12 } from "fs";
1110
- import path15 from "path";
1212
+ import path17 from "path";
1111
1213
  async function restore(ctx, profileArg, opts = {}) {
1112
1214
  const backup = opts.backup ?? true;
1113
1215
  const state = await readState(ctx.adapter);
@@ -1126,7 +1228,7 @@ async function restore(ctx, profileArg, opts = {}) {
1126
1228
  const oldManaged = new Set(state?.managed ?? []);
1127
1229
  const toRemove = [...oldManaged].filter((r) => !newManaged.has(r)).sort();
1128
1230
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1129
- const backupDir = path15.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
1231
+ const backupDir = path17.join(ctx.adapter.root, LSHED_DIR, "backups", stamp);
1130
1232
  const backedUp = [];
1131
1233
  const placed = [];
1132
1234
  const missingEnv = [];
@@ -1142,14 +1244,14 @@ async function restore(ctx, profileArg, opts = {}) {
1142
1244
  if (cur === void 0) return;
1143
1245
  backedUp.push(rel);
1144
1246
  if (opts.dryRun || !backup) return;
1145
- await writeEntryFile(path15.join(backupDir, en.cat.name, `${en.id}.json`), cur);
1247
+ await writeEntryFile(path17.join(backupDir, en.cat.name, `${en.id}.json`), cur);
1146
1248
  return;
1147
1249
  }
1148
1250
  const from = abs(ctx, rel);
1149
1251
  if (!await exists(from)) return;
1150
1252
  backedUp.push(rel);
1151
1253
  if (opts.dryRun || !backup) return;
1152
- await copyTree(from, path15.join(backupDir, ...rel.split("/")), ignoreOf(ctx));
1254
+ await copyTree(from, path17.join(backupDir, ...rel.split("/")), ignoreOf(ctx));
1153
1255
  }
1154
1256
  for (const rel of toRemove) {
1155
1257
  ctx.log(` - ${rel}`);
@@ -1163,8 +1265,8 @@ async function restore(ctx, profileArg, opts = {}) {
1163
1265
  if (it.entry) {
1164
1266
  const shed = await readEntryFile(it.src);
1165
1267
  const local = (await entriesOf(it.entry))[it.id];
1166
- const vars = placeholdersIn(shed);
1167
- const ex = expand(shed);
1268
+ const vars = placeholdersIn(shed).filter((v) => v !== "HOME");
1269
+ const ex = expand(shed, envWithHome());
1168
1270
  if (ex.missing.length) missingEnv.push({ rel: it.rel, vars: ex.missing });
1169
1271
  const value = it.entry.expandsEnv ? shed : ex.value;
1170
1272
  const same2 = local !== void 0 && matches2(shed, local);
@@ -1260,10 +1362,71 @@ function formatDiff(diffs) {
1260
1362
  return lines.join("\n");
1261
1363
  }
1262
1364
 
1365
+ // src/core/add.ts
1366
+ import { promises as fs13 } from "fs";
1367
+ import YAML5 from "yaml";
1368
+ async function candidates(ctx, m, profile, d) {
1369
+ d ??= await discover(ctx, m.exclude);
1370
+ return { fresh: notInManifest(m, d), notInProfile: inManifestNotInProfile(m, profile, d), generated: d.generated };
1371
+ }
1372
+ async function add(ctx, keys = [], opts = {}) {
1373
+ const state = await readState(ctx.adapter);
1374
+ if (!state) throw new Error("\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 'lshed init' \uB610\uB294 'lshed restore <profile>' \uC744 \uC2E4\uD589\uD558\uC138\uC694.");
1375
+ const m = await loadManifest(ctx);
1376
+ const c = await candidates(ctx, m, state.profile);
1377
+ if (!keys.length && !opts.all) {
1378
+ if (!c.fresh.length) ctx.log("\uCC3D\uACE0\uC5D0 \uC5C6\uB294 \uC0C8 \uD56D\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
1379
+ else {
1380
+ ctx.log(`\uCC3D\uACE0\uC5D0 \uC5C6\uB294 \uD56D\uBAA9 ${c.fresh.length}\uAC1C (\uB123\uC73C\uB824\uBA74 lshed add <key...> \uB610\uB294 --all):`);
1381
+ for (const f of c.fresh) ctx.log(` ${f.kind === "package" ? "\u2261" : " "} ${keyOf(f)}${f.kind === "package" ? ` ${f.pkg.source}` : f.kind === "entry" && f.warn ? ` ! ${f.warn}` : ""}`);
1382
+ }
1383
+ hint(ctx, c, state.profile);
1384
+ return [];
1385
+ }
1386
+ let chosen = c.fresh;
1387
+ if (keys.length) {
1388
+ chosen = keys.map((raw) => {
1389
+ const [a, b] = raw.includes("/") ? raw.split("/", 2) : [void 0, raw];
1390
+ const hits = c.fresh.filter((f) => f.id === b && (a === void 0 || f.category === a));
1391
+ if (!hits.length) {
1392
+ const known = (m.components[a ?? ""] ?? []).some((x2) => x2.id === b) || Object.values(m.components).some((cs) => cs.some((x2) => x2.id === b)) || m.packages.some((p) => p.id === b);
1393
+ throw new Error(known ? `"${raw}" \uB294 \uC774\uBBF8 \uCC3D\uACE0\uC5D0 \uC788\uC2B5\uB2C8\uB2E4. \uD504\uB85C\uD544\uC5D0 \uB123\uC73C\uB824\uBA74 lshed.yaml \uC758 profiles \uB97C \uACE0\uCE58\uC138\uC694.` : `"${raw}" \uB294 \uB85C\uCEEC\uC5D0\uC11C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. 'lshed add' \uB85C \uD6C4\uBCF4\uB97C \uBCF4\uC138\uC694.`);
1394
+ }
1395
+ if (hits.length > 1) throw new Error(`"${raw}" \uAC00 \uBAA8\uD638\uD569\uB2C8\uB2E4: ${hits.map(keyOf).join(", ")}`);
1396
+ return hits[0];
1397
+ });
1398
+ }
1399
+ if (!chosen.length) {
1400
+ ctx.log("\uCC3D\uACE0\uC5D0 \uC5C6\uB294 \uC0C8 \uD56D\uBAA9\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
1401
+ hint(ctx, c, state.profile);
1402
+ return [];
1403
+ }
1404
+ const doc = YAML5.parseDocument(await fs13.readFile(manifestPath(ctx), "utf8"));
1405
+ const lock = await readLock(ctx.shed);
1406
+ const res = await ingest(ctx, doc, state.profile, chosen, lock);
1407
+ tidy(doc);
1408
+ await fs13.writeFile(manifestPath(ctx), doc.toString({ lineWidth: 0 }));
1409
+ if (res.packages.length) await writeLock(ctx.shed, lock);
1410
+ const managed = [.../* @__PURE__ */ new Set([...state.managed, ...res.managed])].sort();
1411
+ await writeState(ctx.adapter, { ...state, managed, appliedAt: (/* @__PURE__ */ new Date()).toISOString() });
1412
+ const added = chosen.map(keyOf);
1413
+ ctx.log(`
1414
+ ${added.length}\uAC1C\uB97C \uCC3D\uACE0\uC5D0 \uB123\uACE0 \uD504\uB85C\uD544 "${state.profile}" \uC5D0 \uCD94\uAC00\uD588\uC2B5\uB2C8\uB2E4. \uCC3D\uACE0\uB97C \uCEE4\uBC0B\uD558\uC138\uC694: ${ctx.shed}`);
1415
+ if (res.packages.some((id) => chosen.find((f) => f.id === id && f.kind === "package" && f.pkg.into))) ctx.log(`git \uD328\uD0A4\uC9C0\uC758 \uC124\uCE58 \uBA85\uB839(install:)\uC740 lshed.yaml \uC5D0\uC11C \uC9C1\uC811 \uCC44\uC6B0\uC138\uC694.`);
1416
+ hint(ctx, { ...c, fresh: c.fresh.filter((f) => !chosen.includes(f)) }, state.profile);
1417
+ return added;
1418
+ }
1419
+ function hint(ctx, c, profile) {
1420
+ const byPkg = /* @__PURE__ */ new Map();
1421
+ for (const by of c.generated.values()) byPkg.set(by, (byPkg.get(by) ?? 0) + 1);
1422
+ for (const [by, n] of byPkg) ctx.log(` \xB7 \uD328\uD0A4\uC9C0 ${by} \uAC00 \uC0DD\uC131\uD55C \uAC83 ${n}\uAC1C\uB294 \uB2F4\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4`);
1423
+ if (c.notInProfile.length) ctx.log(`\uCC3D\uACE0\uC5D0\uB294 \uC788\uC9C0\uB9CC \uD504\uB85C\uD544 "${profile}" \uC774 \uC548 \uC4F0\uB294 \uAC83 ${c.notInProfile.length}\uAC1C: ${c.notInProfile.join(", ")} \u2192 lshed.yaml \uC758 profiles \uC5D0 \uCD94\uAC00`);
1424
+ }
1425
+
1263
1426
  // src/core/status.ts
1264
1427
  async function status(ctx) {
1265
1428
  const state = await readState(ctx.adapter);
1266
- if (!state) return { state: null, drifted: [], packages: [], missingEnv: [] };
1429
+ if (!state) return { state: null, drifted: [], packages: [], missingEnv: [], fresh: [] };
1267
1430
  const d = await diff(ctx);
1268
1431
  const m = await loadManifest(ctx);
1269
1432
  const lock = await readLock(ctx.shed);
@@ -1271,10 +1434,11 @@ async function status(ctx) {
1271
1434
  const missingEnv = [];
1272
1435
  for (const it of planProfile(ctx, m, state.profile).filter((p) => p.entry)) {
1273
1436
  const shed = await readEntryFile(it.src);
1274
- const missing = shed === null ? [] : expand(shed).missing;
1437
+ const missing = shed === null ? [] : expand(shed, envWithHome()).missing;
1275
1438
  if (missing.length) missingEnv.push({ rel: it.rel, vars: missing });
1276
1439
  }
1277
- return { state, drifted: d.map((x2) => `${x2.item.category}/${x2.item.id}`), packages, missingEnv };
1440
+ const fresh = (await candidates(ctx, m, state.profile)).fresh.map(keyOf);
1441
+ return { state, drifted: d.map((x2) => `${x2.item.category}/${x2.item.id}`), packages, missingEnv, fresh };
1278
1442
  }
1279
1443
  function formatStatus(s, adapterRoot) {
1280
1444
  if (!s.state) return `\uC801\uC6A9\uB41C \uD504\uB85C\uD544\uC774 \uC5C6\uC2B5\uB2C8\uB2E4 (${adapterRoot}).
@@ -1292,6 +1456,7 @@ function formatStatus(s, adapterRoot) {
1292
1456
  lines.push(`\uD328\uD0A4\uC9C0 ${p.pkg.id} ${where}`);
1293
1457
  }
1294
1458
  for (const m of s.missingEnv) lines.push(`\uD658\uACBD\uBCC0\uC218 ${m.rel}: ${m.vars.join(", ")} \uC5C6\uC74C \u2192 \uC178\uC5D0\uC11C export \uD558\uC138\uC694`);
1459
+ if (s.fresh.length) lines.push(`\uCC3D\uACE0 \uBC16 ${s.fresh.length}\uAC1C: ${s.fresh.join(", ")} \u2192 lshed add`);
1295
1460
  return lines.join("\n");
1296
1461
  }
1297
1462
 
@@ -1371,9 +1536,9 @@ function formatRows(rows, m) {
1371
1536
  }
1372
1537
 
1373
1538
  // src/core/remove.ts
1374
- import { promises as fs13 } from "fs";
1375
- import path16 from "path";
1376
- import YAML3, { isSeq, isMap } from "yaml";
1539
+ import { promises as fs14 } from "fs";
1540
+ import path18 from "path";
1541
+ import YAML6, { isSeq as isSeq2, isMap as isMap2 } from "yaml";
1377
1542
  function resolveKey(m, raw) {
1378
1543
  const rows = listRows(m);
1379
1544
  const [a, b] = raw.includes("/") ? raw.split("/", 2) : [void 0, raw];
@@ -1387,13 +1552,13 @@ async function remove(ctx, raw) {
1387
1552
  const { category, id } = resolveKey(m, raw);
1388
1553
  const users = listRows(m).find((r) => r.category === category && r.id === id).usedBy;
1389
1554
  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.`);
1390
- const text = await fs13.readFile(manifestPath(ctx), "utf8");
1391
- const doc = YAML3.parseDocument(text);
1555
+ const text = await fs14.readFile(manifestPath(ctx), "utf8");
1556
+ const doc = YAML6.parseDocument(text);
1392
1557
  let deleted;
1393
1558
  if (category === PACKAGES) {
1394
1559
  const seq = doc.get(PACKAGES);
1395
- if (!isSeq(seq)) throw new Error("packages \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4");
1396
- const idx = seq.items.findIndex((it) => isMap(it) && it.get("id") === id);
1560
+ if (!isSeq2(seq)) throw new Error("packages \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4");
1561
+ const idx = seq.items.findIndex((it) => isMap2(it) && it.get("id") === id);
1397
1562
  seq.delete(idx);
1398
1563
  if (!seq.items.length) doc.delete(PACKAGES);
1399
1564
  const lock = await readLock(ctx.shed);
@@ -1404,19 +1569,19 @@ async function remove(ctx, raw) {
1404
1569
  ctx.log(` - package ${id} (\uB9E4\uB2C8\uD398\uC2A4\uD2B8\xB7\uB77D\uC5D0\uC11C \uC81C\uAC70. \uB85C\uCEEC clone \uC740 \uADF8\uB300\uB85C)`);
1405
1570
  } else {
1406
1571
  const seq = doc.getIn(["components", category]);
1407
- if (!isSeq(seq)) throw new Error(`components.${category} \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4`);
1408
- const idx = seq.items.findIndex((it) => isMap(it) && it.get("id") === id);
1572
+ if (!isSeq2(seq)) throw new Error(`components.${category} \uAC00 \uBAA9\uB85D\uC774 \uC544\uB2D9\uB2C8\uB2E4`);
1573
+ const idx = seq.items.findIndex((it) => isMap2(it) && it.get("id") === id);
1409
1574
  seq.delete(idx);
1410
1575
  if (!seq.items.length) doc.deleteIn(["components", category]);
1411
1576
  const src = sourcePath(ctx, category, findComponent(m, category, id));
1412
- const inside = !path16.relative(ctx.shed, src).startsWith("..");
1577
+ const inside = !path18.relative(ctx.shed, src).startsWith("..");
1413
1578
  if (inside && await exists(src)) {
1414
1579
  await removeTree(src);
1415
1580
  deleted = src;
1416
1581
  }
1417
1582
  ctx.log(` - ${category}/${id}${deleted ? "" : " (\uCC3D\uACE0 \uBC16 \uACBD\uB85C\uB77C \uD30C\uC77C\uC740 \uB450\uC5C8\uC74C)"}`);
1418
1583
  }
1419
- await fs13.writeFile(manifestPath(ctx), doc.toString());
1584
+ await fs14.writeFile(manifestPath(ctx), doc.toString());
1420
1585
  return { category, id, deleted };
1421
1586
  }
1422
1587
  async function prune(ctx, opts = {}) {
@@ -1441,21 +1606,105 @@ ${removed.length}\uAC1C \uC81C\uAC70. \uCC3D\uACE0\uB97C \uCEE4\uBC0B\uD558\uC13
1441
1606
  return removed;
1442
1607
  }
1443
1608
 
1609
+ // src/core/sync.ts
1610
+ import os3 from "os";
1611
+ async function sync(ctx, opts = {}) {
1612
+ const shed = ctx.shed;
1613
+ const push = opts.push ?? true;
1614
+ if (!await isRepo(shed)) {
1615
+ throw new Error(`\uCC3D\uACE0\uAC00 git \uC800\uC7A5\uC18C\uAC00 \uC544\uB2D9\uB2C8\uB2E4: ${shed}
1616
+ cd ${shed} && git init && git add -A && git commit -m "my harness"
1617
+ \uC6D0\uACA9\uC5D0 \uB450\uB824\uBA74: git remote add origin <url> && git push -u origin HEAD`);
1618
+ }
1619
+ const res = { committed: [], pulled: 0, pushed: false, unsaved: [] };
1620
+ if (await readState(ctx.adapter)) {
1621
+ try {
1622
+ res.unsaved = (await diff(ctx)).map((d) => `${d.item.category}/${d.item.id}`);
1623
+ } catch {
1624
+ }
1625
+ if (res.unsaved.length) ctx.log(` ! \uB85C\uCEEC \uD3B8\uC9D1 ${res.unsaved.length}\uAC1C\uAC00 \uCC3D\uACE0\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4: ${res.unsaved.join(", ")} \u2192 lshed save \uD6C4 \uB2E4\uC2DC sync`);
1626
+ }
1627
+ const dirty = (await git(["status", "--porcelain", "--untracked-files=all"], shed)).split("\n").filter(Boolean).map((l) => l.slice(3).trim());
1628
+ if (dirty.length) {
1629
+ const msg = opts.message ?? defaultMessage(dirty, (await readState(ctx.adapter))?.profile);
1630
+ ctx.log(` ${opts.dryRun ? "(dry-run) " : ""}commit ${dirty.length}\uAC1C: ${dirty.slice(0, 5).join(", ")}${dirty.length > 5 ? ` \uC678 ${dirty.length - 5}` : ""}`);
1631
+ if (!opts.dryRun) {
1632
+ await git(["add", "-A"], shed);
1633
+ await git(["commit", "--quiet", "-m", msg], shed);
1634
+ }
1635
+ res.committed = dirty;
1636
+ } else {
1637
+ ctx.log(" = \uCC3D\uACE0\uC5D0 \uCEE4\uBC0B\uD560 \uBCC0\uACBD \uC5C6\uC74C");
1638
+ }
1639
+ const remote = await git(["remote", "get-url", "origin"], shed).catch(() => null);
1640
+ if (!remote) {
1641
+ ctx.log(" \xB7 origin \uC774 \uC5C6\uC5B4 pull/push \uB294 \uAC74\uB108\uB700 (git remote add origin <url>)");
1642
+ return res;
1643
+ }
1644
+ if (opts.dryRun) {
1645
+ ctx.log(` (dry-run) pull --rebase, push \u2192 ${remote}`);
1646
+ return res;
1647
+ }
1648
+ const before = await git(["rev-parse", "HEAD"], shed);
1649
+ const branch2 = await git(["rev-parse", "--abbrev-ref", "HEAD"], shed);
1650
+ const hasUpstream = await git(["rev-parse", "--abbrev-ref", "@{upstream}"], shed).then(() => true, () => false);
1651
+ if (hasUpstream) {
1652
+ try {
1653
+ await git(["pull", "--rebase", "--quiet"], shed);
1654
+ } catch (e) {
1655
+ await git(["rebase", "--abort"], shed).catch(() => {
1656
+ });
1657
+ throw new Error(`pull \uC911 \uCDA9\uB3CC\uC774 \uB098\uC11C \uB418\uB3CC\uB838\uC2B5\uB2C8\uB2E4. \uCC3D\uACE0\uC5D0\uC11C \uC9C1\uC811 \uD574\uACB0\uD558\uC138\uC694:
1658
+ cd ${shed} && git pull --rebase
1659
+ (${firstLine(e.message)})`);
1660
+ }
1661
+ const after = await git(["rev-parse", "HEAD"], shed);
1662
+ if (after !== before) {
1663
+ const n = Number(await git(["rev-list", "--count", `${before}..${after}`], shed).catch(() => "0"));
1664
+ res.pulled = Math.max(0, n - (res.committed.length ? 1 : 0));
1665
+ if (res.pulled) ctx.log(` \u2193 \uC6D0\uACA9 \uCEE4\uBC0B ${res.pulled}\uAC1C \uBC1B\uC74C`);
1666
+ }
1667
+ } else {
1668
+ ctx.log(` \xB7 \uBE0C\uB79C\uCE58 ${branch2} \uC5D0 upstream \uC774 \uC5C6\uC5B4 pull \uC740 \uAC74\uB108\uB700`);
1669
+ }
1670
+ if (push) {
1671
+ const ahead = hasUpstream ? Number(await git(["rev-list", "--count", "@{upstream}..HEAD"], shed)) : 1;
1672
+ if (ahead > 0) {
1673
+ await git(hasUpstream ? ["push", "--quiet"] : ["push", "--quiet", "-u", "origin", branch2], shed);
1674
+ res.pushed = true;
1675
+ ctx.log(` \u2191 push ${hasUpstream ? `${ahead}\uAC1C \uCEE4\uBC0B` : `(upstream \uC124\uC815: origin/${branch2})`}`);
1676
+ } else {
1677
+ ctx.log(" = \uC6D0\uACA9\uACFC \uAC19\uC74C");
1678
+ }
1679
+ }
1680
+ if (res.pulled) ctx.log(`
1681
+ \uCC3D\uACE0\uAC00 \uBC14\uB00C\uC5C8\uC2B5\uB2C8\uB2E4. \uC774 \uAE30\uAE30\uC5D0 \uC801\uC6A9\uD558\uB824\uBA74: lshed restore`);
1682
+ return res;
1683
+ }
1684
+ function defaultMessage(paths, profile) {
1685
+ const parts = [...new Set(paths.map((p) => p.split("/").slice(0, 2).join("/")))];
1686
+ const head2 = parts.slice(0, 3).join(", ") + (parts.length > 3 ? ` +${parts.length - 3}` : "");
1687
+ return `lshed sync: ${head2}
1688
+
1689
+ ${os3.hostname()}${profile ? ` \xB7 profile ${profile}` : ""}`;
1690
+ }
1691
+ var firstLine = (s) => s.split("\n").find((l) => l.trim() && !l.startsWith("Command failed")) ?? s;
1692
+
1444
1693
  // src/cli.ts
1445
1694
  var { version } = createRequire(import.meta.url)("../package.json");
1446
1695
  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)");
1447
1696
  function adapterFromOpts() {
1448
1697
  const { root } = program.opts();
1449
- return new ClaudeCodeAdapter(root ? path17.resolve(root) : void 0);
1698
+ return new ClaudeCodeAdapter(root ? path19.resolve(root) : void 0);
1450
1699
  }
1451
1700
  async function ctxFor(cmd) {
1452
1701
  const adapter = adapterFromOpts();
1453
1702
  const { shed: flag } = program.opts();
1454
1703
  let shed = flag ?? process.env.LSHED_HOME;
1455
1704
  if (!shed && cmd === "other") shed = (await readState(adapter))?.shed;
1456
- if (!shed && cmd === "init") shed = path17.join(os2.homedir(), "lshed");
1705
+ if (!shed && cmd === "init") shed = path19.join(os4.homedir(), "lshed");
1457
1706
  if (!shed) throw new Error("\uCC3D\uACE0 \uC704\uCE58\uB97C \uBAA8\uB985\uB2C8\uB2E4. --shed <dir> \uB610\uB294 LSHED_HOME \uC744 \uC9C0\uC815\uD558\uC138\uC694.");
1458
- return { adapter, shed: path17.resolve(shed), log: (l) => console.log(l), exec: spawnExec };
1707
+ return { adapter, shed: path19.resolve(shed), log: (l) => console.log(l), exec: spawnExec };
1459
1708
  }
1460
1709
  async function run(fn) {
1461
1710
  try {
@@ -1500,7 +1749,7 @@ program.command("status").description("show the applied profile, managed paths a
1500
1749
  const adapter = adapterFromOpts();
1501
1750
  const state = await readState(adapter);
1502
1751
  if (!state) {
1503
- console.log(formatStatus({ state: null, drifted: [], packages: [], missingEnv: [] }, adapter.root));
1752
+ console.log(formatStatus({ state: null, drifted: [], packages: [], missingEnv: [], fresh: [] }, adapter.root));
1504
1753
  return;
1505
1754
  }
1506
1755
  const ctx = await ctxFor("other");
@@ -1514,6 +1763,14 @@ program.command("save [ids...]").description("copy local edits back into the she
1514
1763
  const ctx = await ctxFor("other");
1515
1764
  await save(ctx, ids);
1516
1765
  }));
1766
+ program.command("add [keys...]").description("put things that appeared locally since init into the shed and the current profile (lists candidates without keys)").option("--all", "add every candidate").action((keys, o) => run(async () => {
1767
+ const ctx = await ctxFor("other");
1768
+ await add(ctx, keys, { all: o.all });
1769
+ }));
1770
+ program.command("sync").description("commit the shed, pull --rebase and push (the shed must be a git repo with origin)").option("-m, --message <msg>", "commit message (default: names the changed parts)").option("--no-push", "commit and pull only").option("--dry-run", "show what would be committed and pushed").action((o) => run(async () => {
1771
+ const ctx = await ctxFor("other");
1772
+ await sync(ctx, { message: o.message, push: o.push, dryRun: o.dryRun });
1773
+ }));
1517
1774
  program.command("list").description("everything in the shed and which profiles use it").option("--unused", "only things no profile uses").action((o) => run(async () => {
1518
1775
  const ctx = await ctxFor("other");
1519
1776
  const m = await loadManifest(ctx);