@haus-tech/haus-workflow 4.0.1 → 4.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/dist/cli.js CHANGED
@@ -14,9 +14,122 @@ var __export = (target, all) => {
14
14
  __defProp(target, name, { get: all[name], enumerable: true });
15
15
  };
16
16
 
17
+ // src/catalog/offline.ts
18
+ function setOfflineFlag(value) {
19
+ flagged = value;
20
+ }
21
+ function isOffline() {
22
+ return flagged || process.env["HAUS_OFFLINE"] === "1";
23
+ }
24
+ var flagged;
25
+ var init_offline = __esm({
26
+ "src/catalog/offline.ts"() {
27
+ "use strict";
28
+ flagged = false;
29
+ }
30
+ });
31
+
32
+ // src/utils/paths.ts
33
+ import { existsSync, readFileSync } from "node:fs";
34
+ import os from "node:os";
35
+ import path from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ function hausPath(root, ...parts) {
38
+ return path.join(root, HAUS_DIR, ...parts);
39
+ }
40
+ function claudePath(root, ...parts) {
41
+ return path.join(root, ".claude", ...parts);
42
+ }
43
+ function claudeConfigDir() {
44
+ const override = process.env.CLAUDE_CONFIG_DIR;
45
+ if (override && override.trim().length > 0) return override;
46
+ return path.join(os.homedir(), ".claude");
47
+ }
48
+ function defaultClaudeConfigDir() {
49
+ return path.join(os.homedir(), ".claude");
50
+ }
51
+ function claudeConfigDirIsRelocated() {
52
+ return path.resolve(claudeConfigDir()) !== path.resolve(defaultClaudeConfigDir());
53
+ }
54
+ function claudeConfigDirLabel() {
55
+ return claudeConfigDirIsRelocated() ? claudeConfigDir().replace(/\\/g, "/") : "~/.claude";
56
+ }
57
+ function displayPath(root, targetPath) {
58
+ const rel = path.relative(root, targetPath).replace(/\\/g, "/");
59
+ if (rel && !rel.startsWith("../") && rel !== "..") {
60
+ return rel.startsWith("./") ? rel : `./${rel}`;
61
+ }
62
+ if (claudeConfigDirIsRelocated()) {
63
+ const configDir = claudeConfigDir();
64
+ const configRel = path.relative(configDir, targetPath).replace(/\\/g, "/");
65
+ if (configRel === "") return "$CLAUDE_CONFIG_DIR";
66
+ if (!configRel.startsWith("../") && configRel !== "..") {
67
+ return `$CLAUDE_CONFIG_DIR/${configRel}`;
68
+ }
69
+ }
70
+ const home = os.homedir();
71
+ const normalized = targetPath.replace(/\\/g, "/");
72
+ if (home && home.trim().length > 0) {
73
+ const homeRel = path.relative(home, targetPath).replace(/\\/g, "/");
74
+ if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
75
+ return `~/${homeRel}`;
76
+ }
77
+ }
78
+ return normalized;
79
+ }
80
+ function packageRoot() {
81
+ let dir = path.dirname(fileURLToPath(import.meta.url));
82
+ for (let i = 0; i < 12; i++) {
83
+ const pkgPath = path.join(dir, "package.json");
84
+ if (existsSync(pkgPath)) {
85
+ try {
86
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
87
+ if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
88
+ } catch {
89
+ }
90
+ }
91
+ const parent = path.dirname(dir);
92
+ if (parent === dir) break;
93
+ dir = parent;
94
+ }
95
+ const file = fileURLToPath(import.meta.url);
96
+ return path.resolve(path.dirname(file), "../..");
97
+ }
98
+ var HAUS_DIR;
99
+ var init_paths = __esm({
100
+ "src/utils/paths.ts"() {
101
+ "use strict";
102
+ HAUS_DIR = ".haus-workflow";
103
+ }
104
+ });
105
+
106
+ // src/catalog/path-safety.ts
107
+ import path2 from "node:path";
108
+ function isSafeCatalogPath(itemPath) {
109
+ if (!itemPath || path2.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
110
+ const normalized = path2.normalize(itemPath);
111
+ return !normalized.startsWith("..") && !normalized.includes("/..");
112
+ }
113
+ function isSafeRelativeFilePath(rel) {
114
+ if (!rel || rel.startsWith("/") || rel.includes("\\") || rel.includes("//")) return false;
115
+ if (path2.isAbsolute(rel)) return false;
116
+ const normalized = path2.posix.normalize(rel.replace(/\\/g, "/"));
117
+ return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
118
+ }
119
+ function safeJoin(base, itemPath) {
120
+ if (!isSafeCatalogPath(itemPath)) return null;
121
+ const resolved = path2.resolve(base, itemPath);
122
+ return resolved.startsWith(base + path2.sep) || resolved === base ? resolved : null;
123
+ }
124
+ var init_path_safety = __esm({
125
+ "src/catalog/path-safety.ts"() {
126
+ "use strict";
127
+ }
128
+ });
129
+
17
130
  // src/utils/fs.ts
18
131
  import crypto from "node:crypto";
19
- import path from "node:path";
132
+ import path5 from "node:path";
20
133
  import fg from "fast-glob";
21
134
  import fs from "fs-extra";
22
135
  async function readJsonDetailed(file) {
@@ -66,9 +179,9 @@ async function writeText(file, value) {
66
179
  await atomicWriteText(file, value);
67
180
  }
68
181
  async function atomicWriteText(file, value) {
69
- const dir = path.dirname(file);
182
+ const dir = path5.dirname(file);
70
183
  await fs.ensureDir(dir);
71
- const tempPath = path.join(
184
+ const tempPath = path5.join(
72
185
  dir,
73
186
  `.tmp-haus-write-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
74
187
  );
@@ -86,7 +199,7 @@ async function findNestedRepoDirs(root) {
86
199
  });
87
200
  const owners = /* @__PURE__ */ new Set();
88
201
  for (const match of matches) {
89
- const owner = path.posix.dirname(match);
202
+ const owner = path5.posix.dirname(match);
90
203
  if (owner === "." || owner === "") continue;
91
204
  owners.add(owner);
92
205
  }
@@ -120,10 +233,10 @@ function hashText(value) {
120
233
  return `sha256-${crypto.createHash("sha256").update(value).digest("hex")}`;
121
234
  }
122
235
  function resolveContainedPath(root, rel) {
123
- const resolvedRoot = path.resolve(root);
124
- const resolved = path.resolve(root, rel);
236
+ const resolvedRoot = path5.resolve(root);
237
+ const resolved = path5.resolve(root, rel);
125
238
  if (resolved === resolvedRoot) return null;
126
- const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
239
+ const prefix = resolvedRoot.endsWith(path5.sep) ? resolvedRoot : resolvedRoot + path5.sep;
127
240
  if (!resolved.startsWith(prefix)) return null;
128
241
  return resolved;
129
242
  }
@@ -171,80 +284,6 @@ var init_logger = __esm({
171
284
  }
172
285
  });
173
286
 
174
- // src/utils/paths.ts
175
- import { existsSync, readFileSync } from "node:fs";
176
- import os from "node:os";
177
- import path2 from "node:path";
178
- import { fileURLToPath } from "node:url";
179
- function hausPath(root, ...parts) {
180
- return path2.join(root, HAUS_DIR, ...parts);
181
- }
182
- function claudePath(root, ...parts) {
183
- return path2.join(root, ".claude", ...parts);
184
- }
185
- function claudeConfigDir() {
186
- const override = process.env.CLAUDE_CONFIG_DIR;
187
- if (override && override.trim().length > 0) return override;
188
- return path2.join(os.homedir(), ".claude");
189
- }
190
- function defaultClaudeConfigDir() {
191
- return path2.join(os.homedir(), ".claude");
192
- }
193
- function claudeConfigDirIsRelocated() {
194
- return path2.resolve(claudeConfigDir()) !== path2.resolve(defaultClaudeConfigDir());
195
- }
196
- function claudeConfigDirLabel() {
197
- return claudeConfigDirIsRelocated() ? claudeConfigDir().replace(/\\/g, "/") : "~/.claude";
198
- }
199
- function displayPath(root, targetPath) {
200
- const rel = path2.relative(root, targetPath).replace(/\\/g, "/");
201
- if (rel && !rel.startsWith("../") && rel !== "..") {
202
- return rel.startsWith("./") ? rel : `./${rel}`;
203
- }
204
- if (claudeConfigDirIsRelocated()) {
205
- const configDir = claudeConfigDir();
206
- const configRel = path2.relative(configDir, targetPath).replace(/\\/g, "/");
207
- if (configRel === "") return "$CLAUDE_CONFIG_DIR";
208
- if (!configRel.startsWith("../") && configRel !== "..") {
209
- return `$CLAUDE_CONFIG_DIR/${configRel}`;
210
- }
211
- }
212
- const home = os.homedir();
213
- const normalized = targetPath.replace(/\\/g, "/");
214
- if (home && home.trim().length > 0) {
215
- const homeRel = path2.relative(home, targetPath).replace(/\\/g, "/");
216
- if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
217
- return `~/${homeRel}`;
218
- }
219
- }
220
- return normalized;
221
- }
222
- function packageRoot() {
223
- let dir = path2.dirname(fileURLToPath(import.meta.url));
224
- for (let i = 0; i < 12; i++) {
225
- const pkgPath = path2.join(dir, "package.json");
226
- if (existsSync(pkgPath)) {
227
- try {
228
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
229
- if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
230
- } catch {
231
- }
232
- }
233
- const parent = path2.dirname(dir);
234
- if (parent === dir) break;
235
- dir = parent;
236
- }
237
- const file = fileURLToPath(import.meta.url);
238
- return path2.resolve(path2.dirname(file), "../..");
239
- }
240
- var HAUS_DIR;
241
- var init_paths = __esm({
242
- "src/utils/paths.ts"() {
243
- "use strict";
244
- HAUS_DIR = ".haus-workflow";
245
- }
246
- });
247
-
248
287
  // src/catalog/manifest-format.ts
249
288
  function checkManifestFormat(value, sourceLabel) {
250
289
  if (value === void 0) return { ok: true, format: 1 };
@@ -297,30 +336,6 @@ var init_constants = __esm({
297
336
  }
298
337
  });
299
338
 
300
- // src/catalog/path-safety.ts
301
- import path3 from "node:path";
302
- function isSafeCatalogPath(itemPath) {
303
- if (!itemPath || path3.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
304
- const normalized = path3.normalize(itemPath);
305
- return !normalized.startsWith("..") && !normalized.includes("/..");
306
- }
307
- function isSafeRelativeFilePath(rel) {
308
- if (!rel || rel.startsWith("/") || rel.includes("\\") || rel.includes("//")) return false;
309
- if (path3.isAbsolute(rel)) return false;
310
- const normalized = path3.posix.normalize(rel.replace(/\\/g, "/"));
311
- return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
312
- }
313
- function safeJoin(base, itemPath) {
314
- if (!isSafeCatalogPath(itemPath)) return null;
315
- const resolved = path3.resolve(base, itemPath);
316
- return resolved.startsWith(base + path3.sep) || resolved === base ? resolved : null;
317
- }
318
- var init_path_safety = __esm({
319
- "src/catalog/path-safety.ts"() {
320
- "use strict";
321
- }
322
- });
323
-
324
339
  // src/utils/exec.ts
325
340
  var exec_exports = {};
326
341
  __export(exec_exports, {
@@ -499,16 +514,16 @@ var init_github_rate_limit = __esm({
499
514
  });
500
515
 
501
516
  // src/catalog/remote-catalog/http.ts
502
- import path4 from "node:path";
503
- import fs2 from "fs-extra";
504
- async function fetchGuarded(url, extract, headers = {}) {
517
+ import path6 from "node:path";
518
+ import fs3 from "fs-extra";
519
+ async function fetchGuarded(url, extract2, headers = {}) {
505
520
  try {
506
521
  const res = await fetch(url, { headers, signal: AbortSignal.timeout(1e4) });
507
522
  if (!res.ok) {
508
523
  warn(`Catalog fetch HTTP ${res.status}: ${url}`);
509
524
  return null;
510
525
  }
511
- return await extract(res);
526
+ return await extract2(res);
512
527
  } catch (e) {
513
528
  warn(`Catalog fetch failed (${e instanceof Error ? e.constructor.name : String(e)}): ${url}`);
514
529
  return null;
@@ -521,14 +536,14 @@ async function fetchBytes(url, headers = {}) {
521
536
  return fetchGuarded(url, async (res) => Buffer.from(await res.arrayBuffer()), headers);
522
537
  }
523
538
  async function writeTextIfChanged(dest, text) {
524
- if (await fs2.pathExists(dest)) {
525
- const local = await fs2.readFile(dest, "utf8");
539
+ if (await fs3.pathExists(dest)) {
540
+ const local = await fs3.readFile(dest, "utf8");
526
541
  if (local === text) return "unchanged";
527
- await fs2.writeFile(dest, text, "utf8");
542
+ await fs3.writeFile(dest, text, "utf8");
528
543
  return "updated";
529
544
  }
530
- await fs2.ensureDir(path4.dirname(dest));
531
- await fs2.writeFile(dest, text, "utf8");
545
+ await fs3.ensureDir(path6.dirname(dest));
546
+ await fs3.writeFile(dest, text, "utf8");
532
547
  return "created";
533
548
  }
534
549
  var init_http = __esm({
@@ -557,7 +572,7 @@ var init_semver_tag = __esm({
557
572
  });
558
573
 
559
574
  // src/catalog/remote-catalog/project-pin.ts
560
- import fs3 from "fs-extra";
575
+ import fs4 from "fs-extra";
561
576
  function isSafeRef(ref) {
562
577
  return SAFE_REF.test(ref) && !ref.split("/").includes("..");
563
578
  }
@@ -568,7 +583,7 @@ function loadProjectCatalogPin(root) {
568
583
  const file = hausPath(root, PROJECT_CONFIG_FILE);
569
584
  let raw;
570
585
  try {
571
- raw = fs3.readFileSync(file, "utf8");
586
+ raw = fs4.readFileSync(file, "utf8");
572
587
  } catch (err) {
573
588
  if (err.code === "ENOENT") return { kind: "none" };
574
589
  return { kind: "invalid", reason: `could not be read (${message(err)})` };
@@ -638,18 +653,18 @@ var init_project_pin = __esm({
638
653
  });
639
654
 
640
655
  // src/catalog/remote-catalog/ref.ts
641
- import path5 from "node:path";
642
- import fs4 from "fs-extra";
656
+ import path7 from "node:path";
657
+ import fs5 from "fs-extra";
643
658
  function isTestMode() {
644
659
  return process.env["HAUS_TEST_MODE"] === "1" || process.env["NODE_ENV"] === "test";
645
660
  }
646
661
  function getCacheDir() {
647
- return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path5.join(claudeConfigDir(), CATALOG_CACHE_SUBDIR);
662
+ return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path7.join(claudeConfigDir(), CATALOG_CACHE_SUBDIR);
648
663
  }
649
664
  function getBundledCatalogRef() {
650
665
  try {
651
- const manifestPath2 = path5.join(packageRoot(), "library/catalog/manifest.json");
652
- const raw = fs4.readFileSync(manifestPath2, "utf8");
666
+ const manifestPath2 = path7.join(packageRoot(), "library/catalog/manifest.json");
667
+ const raw = fs5.readFileSync(manifestPath2, "utf8");
653
668
  const data = JSON.parse(raw);
654
669
  if (typeof data.version === "string" && data.version) {
655
670
  return data.version.startsWith("v") ? data.version : `v${data.version}`;
@@ -794,8 +809,8 @@ var init_ref = __esm({
794
809
  });
795
810
 
796
811
  // src/catalog/remote-catalog/github-tree.ts
797
- import path6 from "node:path";
798
- import fs5 from "fs-extra";
812
+ import path8 from "node:path";
813
+ import fs6 from "fs-extra";
799
814
  function sanitizeRelativeFilePaths(files, label) {
800
815
  const safe = [];
801
816
  for (const rel of files) {
@@ -811,17 +826,17 @@ async function listFilesRecursive(dir, base = dir) {
811
826
  const out = [];
812
827
  let entries;
813
828
  try {
814
- if (!await fs5.pathExists(dir)) return out;
815
- entries = await fs5.readdir(dir, { withFileTypes: true });
829
+ if (!await fs6.pathExists(dir)) return out;
830
+ entries = await fs6.readdir(dir, { withFileTypes: true });
816
831
  } catch {
817
832
  return out;
818
833
  }
819
834
  for (const entry of entries) {
820
- const full = path6.join(dir, entry.name);
835
+ const full = path8.join(dir, entry.name);
821
836
  if (entry.isDirectory()) {
822
837
  out.push(...await listFilesRecursive(full, base));
823
838
  } else if (entry.isFile()) {
824
- out.push(path6.relative(base, full).replace(/\\/g, "/"));
839
+ out.push(path8.relative(base, full).replace(/\\/g, "/"));
825
840
  }
826
841
  }
827
842
  return out.sort();
@@ -1558,8 +1573,8 @@ __export(sync_exports, {
1558
1573
  getCacheManifestAge: () => getCacheManifestAge,
1559
1574
  syncRemoteCatalog: () => syncRemoteCatalog
1560
1575
  });
1561
- import path8 from "node:path";
1562
- import fs7 from "fs-extra";
1576
+ import path10 from "node:path";
1577
+ import fs8 from "fs-extra";
1563
1578
  function isMarkdownPath(rel) {
1564
1579
  return rel.toLowerCase().endsWith(".md");
1565
1580
  }
@@ -1597,7 +1612,7 @@ function validateMarkdownFiles(item, fetched) {
1597
1612
  return true;
1598
1613
  }
1599
1614
  async function directoryMatchesFetched(destDir, fetched) {
1600
- if (!await fs7.pathExists(destDir)) return false;
1615
+ if (!await fs8.pathExists(destDir)) return false;
1601
1616
  const existing = await listFilesRecursive(destDir);
1602
1617
  const relSet = new Set(fetched.map((f) => f.rel));
1603
1618
  if (existing.length !== fetched.length) return false;
@@ -1605,30 +1620,30 @@ async function directoryMatchesFetched(destDir, fetched) {
1605
1620
  if (!relSet.has(rel)) return false;
1606
1621
  }
1607
1622
  for (const file of fetched) {
1608
- const dest = path8.join(destDir, file.rel);
1609
- if (!await fs7.pathExists(dest)) return false;
1623
+ const dest = path10.join(destDir, file.rel);
1624
+ if (!await fs8.pathExists(dest)) return false;
1610
1625
  if (file.kind === "text") {
1611
- const local = await fs7.readFile(dest, "utf8");
1626
+ const local = await fs8.readFile(dest, "utf8");
1612
1627
  if (local !== file.body) return false;
1613
1628
  } else {
1614
- const local = await fs7.readFile(dest);
1629
+ const local = await fs8.readFile(dest);
1615
1630
  if (!local.equals(file.body)) return false;
1616
1631
  }
1617
1632
  }
1618
1633
  return true;
1619
1634
  }
1620
1635
  async function writeFetchedDirectory(destDir, fetched) {
1621
- if (await fs7.pathExists(destDir)) {
1622
- await fs7.remove(destDir);
1636
+ if (await fs8.pathExists(destDir)) {
1637
+ await fs8.remove(destDir);
1623
1638
  }
1624
- await fs7.ensureDir(destDir);
1639
+ await fs8.ensureDir(destDir);
1625
1640
  for (const file of fetched) {
1626
- const dest = path8.join(destDir, file.rel);
1627
- await fs7.ensureDir(path8.dirname(dest));
1641
+ const dest = path10.join(destDir, file.rel);
1642
+ await fs8.ensureDir(path10.dirname(dest));
1628
1643
  if (file.kind === "text") {
1629
- await fs7.writeFile(dest, file.body, "utf8");
1644
+ await fs8.writeFile(dest, file.body, "utf8");
1630
1645
  } else {
1631
- await fs7.writeFile(dest, file.body);
1646
+ await fs8.writeFile(dest, file.body);
1632
1647
  }
1633
1648
  }
1634
1649
  }
@@ -1670,7 +1685,7 @@ async function syncDirectoryFromPrefix(item, catalogPrefix, destDir, base, opts)
1670
1685
  }
1671
1686
  }
1672
1687
  }
1673
- const existed = await fs7.pathExists(destDir);
1688
+ const existed = await fs8.pathExists(destDir);
1674
1689
  if (await directoryMatchesFetched(destDir, fetched)) {
1675
1690
  return "unchanged";
1676
1691
  }
@@ -1792,6 +1807,10 @@ async function syncOneItem(item, base) {
1792
1807
  }
1793
1808
  }
1794
1809
  async function syncRemoteCatalog(projectRoot) {
1810
+ if (isOffline()) {
1811
+ log("Offline \u2014 skipping catalog sync; using cached or bundled content.");
1812
+ return { newItems: [], refreshed: [], unchanged: 0, failed: [], skipped: true };
1813
+ }
1795
1814
  _resetBlobPathCacheForNewSync();
1796
1815
  clearGithubRateLimitHit();
1797
1816
  const fetched = await fetchRemoteManifest(projectRoot);
@@ -1812,9 +1831,9 @@ async function syncRemoteCatalog(projectRoot) {
1812
1831
  const cacheDir = getCacheDir();
1813
1832
  const catalogRef = expectedCatalogRef(projectRoot);
1814
1833
  try {
1815
- await fs7.ensureDir(cacheDir);
1816
- await fs7.writeFile(
1817
- path8.join(cacheDir, "manifest.json"),
1834
+ await fs8.ensureDir(cacheDir);
1835
+ await fs8.writeFile(
1836
+ path10.join(cacheDir, "manifest.json"),
1818
1837
  // globalPlugins must round-trip into the cache: the cache is what `haus update`
1819
1838
  // and `haus rescan` read pins from, so dropping it here would make every
1820
1839
  // catalog pin bump invisible and pin resolution fall back to the bundled default.
@@ -1879,7 +1898,7 @@ async function syncRemoteCatalog(projectRoot) {
1879
1898
  }
1880
1899
  async function getCacheManifestAge() {
1881
1900
  try {
1882
- const stat = await fs7.stat(path8.join(getCacheDir(), "manifest.json"));
1901
+ const stat = await fs8.stat(path10.join(getCacheDir(), "manifest.json"));
1883
1902
  return Date.now() - stat.mtimeMs;
1884
1903
  } catch {
1885
1904
  return null;
@@ -1893,6 +1912,7 @@ var init_sync = __esm({
1893
1912
  init_logger();
1894
1913
  init_constants();
1895
1914
  init_ingest_catalog();
1915
+ init_offline();
1896
1916
  init_path_safety();
1897
1917
  init_github_rate_limit();
1898
1918
  init_github_tree();
@@ -1912,20 +1932,204 @@ var init_sync = __esm({
1912
1932
  });
1913
1933
 
1914
1934
  // src/cli.ts
1935
+ init_offline();
1915
1936
  import { readFileSync as readFileSync5 } from "node:fs";
1916
1937
  import path80 from "node:path";
1917
1938
  import { Command } from "commander";
1918
1939
 
1919
1940
  // src/commands/apply.ts
1920
- import path30 from "node:path";
1921
1941
  import checkbox from "@inquirer/checkbox";
1922
1942
 
1943
+ // src/catalog/former-ids.ts
1944
+ import path4 from "node:path";
1945
+
1946
+ // src/catalog/install-mapping.ts
1947
+ init_paths();
1948
+ init_path_safety();
1949
+ import path3 from "node:path";
1950
+ var FLATTEN_DIR_BY_TYPE = {
1951
+ skill: "skills",
1952
+ agent: "agents",
1953
+ template: "templates",
1954
+ command: "commands"
1955
+ };
1956
+ var MIRRORED_TYPES = /* @__PURE__ */ new Set(["rule", "asset"]);
1957
+ function upstreamRelative(itemPath) {
1958
+ const match = itemPath.replace(/\\/g, "/").match(/^sources\/[^/]+\/(.+)$/);
1959
+ return match ? match[1] : null;
1960
+ }
1961
+ function installTargetFor(item) {
1962
+ const type = item.type;
1963
+ if (type === "config") {
1964
+ return {
1965
+ ok: false,
1966
+ code: "scaffold-only",
1967
+ reason: 'type "config" is installed by `haus configs`, not into .claude/'
1968
+ };
1969
+ }
1970
+ const flattenDir = FLATTEN_DIR_BY_TYPE[type];
1971
+ const mirrored = MIRRORED_TYPES.has(type);
1972
+ if (!flattenDir && !mirrored) {
1973
+ return {
1974
+ ok: false,
1975
+ code: "unknown-type",
1976
+ reason: `type "${type}" is unknown to this haus version \u2014 upgrade the CLI to use it`
1977
+ };
1978
+ }
1979
+ const itemPath = typeof item.path === "string" ? item.path : "";
1980
+ if (!isSafeCatalogPath(itemPath)) {
1981
+ return {
1982
+ ok: false,
1983
+ code: "unsafe-path",
1984
+ reason: `path "${itemPath}" is not a safe relative catalog path`
1985
+ };
1986
+ }
1987
+ if (flattenDir) {
1988
+ return {
1989
+ ok: true,
1990
+ layout: "flatten",
1991
+ relPath: `${flattenDir}/${path3.posix.basename(itemPath)}`
1992
+ };
1993
+ }
1994
+ const rel = upstreamRelative(itemPath);
1995
+ if (rel === null) {
1996
+ return {
1997
+ ok: false,
1998
+ code: "not-mirrorable",
1999
+ reason: `type "${type}" installs by mirroring its upstream path, but "${itemPath}" is not under sources/<source>/`
2000
+ };
2001
+ }
2002
+ if (!isSafeRelativeFilePath(rel)) {
2003
+ return {
2004
+ ok: false,
2005
+ code: "unsafe-path",
2006
+ reason: `path "${itemPath}" escapes its own sources/<source>/ prefix`
2007
+ };
2008
+ }
2009
+ return { ok: true, layout: "mirror", relPath: rel };
2010
+ }
2011
+ function installDestinationFor(root, item) {
2012
+ const target = installTargetFor(item);
2013
+ if (!target.ok) return target;
2014
+ const destination = safeJoin(claudePath(root), target.relPath);
2015
+ if (destination === null) {
2016
+ return {
2017
+ ok: false,
2018
+ code: "unsafe-path",
2019
+ reason: `install path "${target.relPath}" escapes .claude/`
2020
+ };
2021
+ }
2022
+ return { ok: true, layout: target.layout, relPath: target.relPath, destination };
2023
+ }
2024
+
2025
+ // src/catalog/former-ids.ts
2026
+ function normalizeFormerIds(value) {
2027
+ if (value === void 0 || value === null) return [];
2028
+ if (!Array.isArray(value) || value.length === 0) return [];
2029
+ if (!value.every((entry) => typeof entry === "string")) return [];
2030
+ return value.filter((entry) => entry.length > 0);
2031
+ }
2032
+ function assertFormerIdsShape(itemId, formerIds) {
2033
+ if (formerIds === void 0) return;
2034
+ if (Array.isArray(formerIds) && formerIds.length === 0) return;
2035
+ if (!Array.isArray(formerIds) || !formerIds.every((entry) => typeof entry === "string")) {
2036
+ throw new Error(`${itemId}: formerIds must be a string array`);
2037
+ }
2038
+ }
2039
+ function buildFormerIdMap(items) {
2040
+ const map = /* @__PURE__ */ new Map();
2041
+ const currentIds = new Set(items.map((item) => item.id));
2042
+ for (const item of items) {
2043
+ assertFormerIdsShape(item.id, item.formerIds);
2044
+ const formerIds = item.formerIds ?? [];
2045
+ if (!Array.isArray(formerIds) || formerIds.length === 0) continue;
2046
+ for (const formerId of formerIds) {
2047
+ if (currentIds.has(formerId) && formerId !== item.id) {
2048
+ throw new Error(
2049
+ `formerId ${formerId} on ${item.id} conflicts with another item's current id`
2050
+ );
2051
+ }
2052
+ const existingOwner = map.get(formerId);
2053
+ if (existingOwner !== void 0) {
2054
+ throw new Error(
2055
+ `duplicate formerId ${formerId} claimed by both ${existingOwner} and ${item.id}`
2056
+ );
2057
+ }
2058
+ map.set(formerId, item.id);
2059
+ }
2060
+ }
2061
+ return map;
2062
+ }
2063
+ function findFormerIdMigrations(lockItems, manifestItems) {
2064
+ const formerIdMap = buildFormerIdMap(manifestItems);
2065
+ return lockItems.flatMap(({ id }) => {
2066
+ const newId = formerIdMap.get(id);
2067
+ return newId === void 0 ? [] : [{ oldId: id, newId }];
2068
+ });
2069
+ }
2070
+ function buildFormerInstallNames(items) {
2071
+ const names = [];
2072
+ for (const item of items) {
2073
+ const formerIds = normalizeFormerIds(item.formerIds);
2074
+ if (formerIds.length === 0) continue;
2075
+ const target = installTargetFor({ type: item.type, path: item.path });
2076
+ if (!target.ok || target.layout !== "flatten") continue;
2077
+ const dir = path4.posix.dirname(target.relPath);
2078
+ const base = path4.posix.basename(target.relPath);
2079
+ const ext = path4.posix.extname(base);
2080
+ const currentName = ext.length > 0 ? base.slice(0, -ext.length) : base;
2081
+ if (currentName.length === 0 || !item.id.endsWith(currentName)) continue;
2082
+ const prefix = item.id.slice(0, item.id.length - currentName.length);
2083
+ for (const formerId of formerIds) {
2084
+ if (!formerId.startsWith(prefix)) continue;
2085
+ const candidate = formerId.slice(prefix.length);
2086
+ if (candidate.length === 0 || candidate === currentName) continue;
2087
+ names.push({ formerId, currentId: item.id, dir, ext, candidate });
2088
+ }
2089
+ }
2090
+ return names;
2091
+ }
2092
+
2093
+ // src/catalog/installable.ts
2094
+ init_fs();
2095
+ init_paths();
2096
+ init_path_safety();
2097
+ import fs2 from "fs-extra";
2098
+ function installableRecommendedItems(recommended) {
2099
+ return recommended.filter((item) => item.install !== false);
2100
+ }
2101
+ async function countInstallableItems(ctx, ids) {
2102
+ const wanted = ids === void 0 ? ctx.items : ctx.items.filter((item) => ids.includes(item.id));
2103
+ const byId = new Map(ctx.items.map((item) => [item.id, item]));
2104
+ const missing = [];
2105
+ let installable = 0;
2106
+ for (const item of wanted) {
2107
+ const contentPath = typeof item.path === "string" ? safeJoin(ctx.contentRoot, item.path) : null;
2108
+ if (contentPath !== null && await fs2.pathExists(contentPath)) {
2109
+ installable += 1;
2110
+ } else {
2111
+ missing.push(item.id);
2112
+ }
2113
+ }
2114
+ if (ids !== void 0) {
2115
+ for (const id of ids) if (!byId.has(id)) missing.push(id);
2116
+ }
2117
+ return { installable, missing };
2118
+ }
2119
+ async function recountInstalledAgainstLock(root, wantedIds) {
2120
+ const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
2121
+ const installedIds = new Set(lock.map((entry) => entry.id));
2122
+ const installedCount = wantedIds.filter((id) => installedIds.has(id)).length;
2123
+ const missingWantedIds = wantedIds.filter((id) => !installedIds.has(id));
2124
+ return { installedCount, missingWantedIds };
2125
+ }
2126
+
1923
2127
  // src/catalog/load-catalog.ts
1924
2128
  init_fs();
1925
2129
  init_logger();
1926
2130
  init_paths();
1927
2131
  init_manifest_format();
1928
- import path9 from "node:path";
2132
+ import path11 from "node:path";
1929
2133
 
1930
2134
  // src/catalog/remote-catalog.ts
1931
2135
  init_github_tree();
@@ -1935,18 +2139,23 @@ init_project_pin();
1935
2139
  init_manifest();
1936
2140
 
1937
2141
  // src/catalog/remote-catalog/workflow-template.ts
2142
+ init_offline();
1938
2143
  init_http();
1939
2144
  init_ref();
1940
- import path7 from "node:path";
1941
- import fs6 from "fs-extra";
2145
+ import path9 from "node:path";
2146
+ import fs7 from "fs-extra";
1942
2147
  var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
1943
2148
  async function readWorkflowTemplate(opts = {}) {
1944
- const dest = path7.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
2149
+ const dest = path9.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
2150
+ if (isOffline()) {
2151
+ if (await fs7.pathExists(dest)) return fs7.readFile(dest, "utf8");
2152
+ return null;
2153
+ }
1945
2154
  const base = await remoteBase(opts.projectRoot);
1946
2155
  const { url, headers } = await catalogContentRequest(base, WORKFLOW_TEMPLATE_REL);
1947
2156
  const text = await fetchText(url, headers);
1948
2157
  if (text === null) {
1949
- if (await fs6.pathExists(dest)) return fs6.readFile(dest, "utf8");
2158
+ if (await fs7.pathExists(dest)) return fs7.readFile(dest, "utf8");
1950
2159
  return null;
1951
2160
  }
1952
2161
  if (!opts.dryRun) {
@@ -1960,6 +2169,9 @@ init_github_tree();
1960
2169
  init_sync();
1961
2170
 
1962
2171
  // src/catalog/load-catalog.ts
2172
+ function manifestVersion(raw) {
2173
+ return typeof raw === "string" && raw !== "" ? raw : null;
2174
+ }
1963
2175
  function cacheRefMatches(cached, expected) {
1964
2176
  if (expected === null) return true;
1965
2177
  if (typeof cached !== "string" || cached === "") return true;
@@ -1971,14 +2183,15 @@ async function loadCatalogContext(root) {
1971
2183
  const data2 = await readJson(envPath);
1972
2184
  return {
1973
2185
  items: data2?.items ?? [],
1974
- contentRoot: path9.dirname(envPath),
2186
+ contentRoot: path11.dirname(envPath),
1975
2187
  source: "fixture",
1976
2188
  manifestFormat: assertSupportedManifestFormat(data2?.manifestFormat, "The fixture catalog"),
2189
+ version: manifestVersion(data2?.version),
1977
2190
  globalPlugins: data2?.globalPlugins
1978
2191
  };
1979
2192
  }
1980
2193
  const cacheDir = getCacheDir();
1981
- const cacheManifestPath = path9.join(cacheDir, "manifest.json");
2194
+ const cacheManifestPath = path11.join(cacheDir, "manifest.json");
1982
2195
  const cacheData = await readJson(cacheManifestPath);
1983
2196
  if (cacheData?.items?.length) {
1984
2197
  const wantedRef = expectedCatalogRef(root);
@@ -1995,31 +2208,34 @@ async function loadCatalogContext(root) {
1995
2208
  cacheData.manifestFormat,
1996
2209
  "The cached catalog"
1997
2210
  ),
2211
+ version: manifestVersion(cacheData.version),
1998
2212
  globalPlugins: cacheData.globalPlugins
1999
2213
  };
2000
2214
  }
2001
2215
  }
2002
- const localManifest = path9.join(root, "library/catalog/manifest.json");
2216
+ const localManifest = path11.join(root, "library/catalog/manifest.json");
2003
2217
  const localData = await readJson(localManifest);
2004
2218
  if (localData?.items?.length) {
2005
2219
  return {
2006
2220
  items: localData.items,
2007
- contentRoot: path9.dirname(localManifest),
2221
+ contentRoot: path11.dirname(localManifest),
2008
2222
  source: "local",
2009
2223
  manifestFormat: assertSupportedManifestFormat(
2010
2224
  localData.manifestFormat,
2011
2225
  "The project-local catalog"
2012
2226
  ),
2227
+ version: manifestVersion(localData.version),
2013
2228
  globalPlugins: localData.globalPlugins
2014
2229
  };
2015
2230
  }
2016
- const packageManifest = path9.join(packageRoot(), "library/catalog/manifest.json");
2231
+ const packageManifest = path11.join(packageRoot(), "library/catalog/manifest.json");
2017
2232
  const data = await readJson(packageManifest);
2018
2233
  return {
2019
2234
  items: data?.items ?? [],
2020
- contentRoot: path9.dirname(packageManifest),
2235
+ contentRoot: path11.dirname(packageManifest),
2021
2236
  source: "bundled",
2022
2237
  manifestFormat: assertSupportedManifestFormat(data?.manifestFormat, "The bundled catalog"),
2238
+ version: manifestVersion(data?.version),
2023
2239
  globalPlugins: data?.globalPlugins
2024
2240
  };
2025
2241
  }
@@ -2037,9 +2253,12 @@ async function loadCatalog(root) {
2037
2253
  return ctx.items;
2038
2254
  }
2039
2255
  function catalogItemContentPath(contentRoot, item) {
2040
- return path9.join(contentRoot, item.path);
2256
+ return path11.join(contentRoot, item.path);
2041
2257
  }
2042
2258
 
2259
+ // src/commands/apply.ts
2260
+ init_offline();
2261
+
2043
2262
  // src/claude/gitignore-owned-check.ts
2044
2263
  init_exec();
2045
2264
  var HAUS_OWNED_TRACKED_PATHS = [
@@ -2052,180 +2271,28 @@ var HAUS_OWNED_TRACKED_PATHS = [
2052
2271
  async function findGitignoredHausPaths(root) {
2053
2272
  try {
2054
2273
  const result = await runGit(["check-ignore", "--", ...HAUS_OWNED_TRACKED_PATHS], {
2055
- cwd: root,
2056
- timeout: 5e3
2057
- });
2058
- if (result.exitCode !== 0 && result.exitCode !== 1) return [];
2059
- return [
2060
- ...new Set(
2061
- result.stdout.split("\n").map((line2) => line2.trim()).filter(Boolean)
2062
- )
2063
- ];
2064
- } catch {
2065
- return [];
2066
- }
2067
- }
2068
- function summarizeGitignoredHausDirs(paths) {
2069
- const dirs = /* @__PURE__ */ new Set();
2070
- for (const p of paths) dirs.add(p === ".haus-workflow" ? ".haus-workflow/" : ".claude/");
2071
- return [...dirs];
2072
- }
2073
-
2074
- // src/claude/write-claude-files.ts
2075
- import path25 from "node:path";
2076
- import fs22 from "fs-extra";
2077
-
2078
- // src/catalog/former-ids.ts
2079
- import path11 from "node:path";
2080
-
2081
- // src/catalog/install-mapping.ts
2082
- init_paths();
2083
- init_path_safety();
2084
- import path10 from "node:path";
2085
- var FLATTEN_DIR_BY_TYPE = {
2086
- skill: "skills",
2087
- agent: "agents",
2088
- template: "templates",
2089
- command: "commands"
2090
- };
2091
- var MIRRORED_TYPES = /* @__PURE__ */ new Set(["rule", "asset"]);
2092
- function upstreamRelative(itemPath) {
2093
- const match = itemPath.replace(/\\/g, "/").match(/^sources\/[^/]+\/(.+)$/);
2094
- return match ? match[1] : null;
2095
- }
2096
- function installTargetFor(item) {
2097
- const type = item.type;
2098
- if (type === "config") {
2099
- return {
2100
- ok: false,
2101
- code: "scaffold-only",
2102
- reason: 'type "config" is installed by `haus configs`, not into .claude/'
2103
- };
2104
- }
2105
- const flattenDir = FLATTEN_DIR_BY_TYPE[type];
2106
- const mirrored = MIRRORED_TYPES.has(type);
2107
- if (!flattenDir && !mirrored) {
2108
- return {
2109
- ok: false,
2110
- code: "unknown-type",
2111
- reason: `type "${type}" is unknown to this haus version \u2014 upgrade the CLI to use it`
2112
- };
2113
- }
2114
- const itemPath = typeof item.path === "string" ? item.path : "";
2115
- if (!isSafeCatalogPath(itemPath)) {
2116
- return {
2117
- ok: false,
2118
- code: "unsafe-path",
2119
- reason: `path "${itemPath}" is not a safe relative catalog path`
2120
- };
2121
- }
2122
- if (flattenDir) {
2123
- return {
2124
- ok: true,
2125
- layout: "flatten",
2126
- relPath: `${flattenDir}/${path10.posix.basename(itemPath)}`
2127
- };
2128
- }
2129
- const rel = upstreamRelative(itemPath);
2130
- if (rel === null) {
2131
- return {
2132
- ok: false,
2133
- code: "not-mirrorable",
2134
- reason: `type "${type}" installs by mirroring its upstream path, but "${itemPath}" is not under sources/<source>/`
2135
- };
2136
- }
2137
- if (!isSafeRelativeFilePath(rel)) {
2138
- return {
2139
- ok: false,
2140
- code: "unsafe-path",
2141
- reason: `path "${itemPath}" escapes its own sources/<source>/ prefix`
2142
- };
2143
- }
2144
- return { ok: true, layout: "mirror", relPath: rel };
2145
- }
2146
- function installDestinationFor(root, item) {
2147
- const target = installTargetFor(item);
2148
- if (!target.ok) return target;
2149
- const destination = safeJoin(claudePath(root), target.relPath);
2150
- if (destination === null) {
2151
- return {
2152
- ok: false,
2153
- code: "unsafe-path",
2154
- reason: `install path "${target.relPath}" escapes .claude/`
2155
- };
2156
- }
2157
- return { ok: true, layout: target.layout, relPath: target.relPath, destination };
2158
- }
2159
-
2160
- // src/catalog/former-ids.ts
2161
- function normalizeFormerIds(value) {
2162
- if (value === void 0 || value === null) return [];
2163
- if (!Array.isArray(value) || value.length === 0) return [];
2164
- if (!value.every((entry) => typeof entry === "string")) return [];
2165
- return value.filter((entry) => entry.length > 0);
2166
- }
2167
- function assertFormerIdsShape(itemId, formerIds) {
2168
- if (formerIds === void 0) return;
2169
- if (Array.isArray(formerIds) && formerIds.length === 0) return;
2170
- if (!Array.isArray(formerIds) || !formerIds.every((entry) => typeof entry === "string")) {
2171
- throw new Error(`${itemId}: formerIds must be a string array`);
2172
- }
2173
- }
2174
- function buildFormerIdMap(items) {
2175
- const map = /* @__PURE__ */ new Map();
2176
- const currentIds = new Set(items.map((item) => item.id));
2177
- for (const item of items) {
2178
- assertFormerIdsShape(item.id, item.formerIds);
2179
- const formerIds = item.formerIds ?? [];
2180
- if (!Array.isArray(formerIds) || formerIds.length === 0) continue;
2181
- for (const formerId of formerIds) {
2182
- if (currentIds.has(formerId) && formerId !== item.id) {
2183
- throw new Error(
2184
- `formerId ${formerId} on ${item.id} conflicts with another item's current id`
2185
- );
2186
- }
2187
- const existingOwner = map.get(formerId);
2188
- if (existingOwner !== void 0) {
2189
- throw new Error(
2190
- `duplicate formerId ${formerId} claimed by both ${existingOwner} and ${item.id}`
2191
- );
2192
- }
2193
- map.set(formerId, item.id);
2194
- }
2274
+ cwd: root,
2275
+ timeout: 5e3
2276
+ });
2277
+ if (result.exitCode !== 0 && result.exitCode !== 1) return [];
2278
+ return [
2279
+ ...new Set(
2280
+ result.stdout.split("\n").map((line2) => line2.trim()).filter(Boolean)
2281
+ )
2282
+ ];
2283
+ } catch {
2284
+ return [];
2195
2285
  }
2196
- return map;
2197
- }
2198
- function findFormerIdMigrations(lockItems, manifestItems) {
2199
- const formerIdMap = buildFormerIdMap(manifestItems);
2200
- return lockItems.flatMap(({ id }) => {
2201
- const newId = formerIdMap.get(id);
2202
- return newId === void 0 ? [] : [{ oldId: id, newId }];
2203
- });
2204
2286
  }
2205
- function buildFormerInstallNames(items) {
2206
- const names = [];
2207
- for (const item of items) {
2208
- const formerIds = normalizeFormerIds(item.formerIds);
2209
- if (formerIds.length === 0) continue;
2210
- const target = installTargetFor({ type: item.type, path: item.path });
2211
- if (!target.ok || target.layout !== "flatten") continue;
2212
- const dir = path11.posix.dirname(target.relPath);
2213
- const base = path11.posix.basename(target.relPath);
2214
- const ext = path11.posix.extname(base);
2215
- const currentName = ext.length > 0 ? base.slice(0, -ext.length) : base;
2216
- if (currentName.length === 0 || !item.id.endsWith(currentName)) continue;
2217
- const prefix = item.id.slice(0, item.id.length - currentName.length);
2218
- for (const formerId of formerIds) {
2219
- if (!formerId.startsWith(prefix)) continue;
2220
- const candidate = formerId.slice(prefix.length);
2221
- if (candidate.length === 0 || candidate === currentName) continue;
2222
- names.push({ formerId, currentId: item.id, dir, ext, candidate });
2223
- }
2224
- }
2225
- return names;
2287
+ function summarizeGitignoredHausDirs(paths) {
2288
+ const dirs = /* @__PURE__ */ new Set();
2289
+ for (const p of paths) dirs.add(p === ".haus-workflow" ? ".haus-workflow/" : ".claude/");
2290
+ return [...dirs];
2226
2291
  }
2227
2292
 
2228
2293
  // src/claude/write-claude-files.ts
2294
+ import path25 from "node:path";
2295
+ import fs23 from "fs-extra";
2229
2296
  init_ingest_catalog();
2230
2297
 
2231
2298
  // src/recommender/orphaned-items.ts
@@ -2451,7 +2518,7 @@ function isSupersededByPlugin(lockId, activePinIds) {
2451
2518
 
2452
2519
  // src/scanner/detect-package-manager.ts
2453
2520
  import path13 from "node:path";
2454
- import fs8 from "fs-extra";
2521
+ import fs9 from "fs-extra";
2455
2522
 
2456
2523
  // src/utils/versions.ts
2457
2524
  import semver from "semver";
@@ -2499,16 +2566,16 @@ function detectPackageManager(root, packageManagerField) {
2499
2566
  if (satisfiesVersion(version, ">=9")) return "npm";
2500
2567
  return "unknown";
2501
2568
  }
2502
- if (fs8.existsSync(path13.join(root, "yarn.lock"))) return "yarn";
2503
- if (fs8.existsSync(path13.join(root, "pnpm-lock.yaml"))) return "pnpm";
2504
- if (fs8.existsSync(path13.join(root, "package-lock.json"))) return "npm";
2569
+ if (fs9.existsSync(path13.join(root, "yarn.lock"))) return "yarn";
2570
+ if (fs9.existsSync(path13.join(root, "pnpm-lock.yaml"))) return "pnpm";
2571
+ if (fs9.existsSync(path13.join(root, "package-lock.json"))) return "npm";
2505
2572
  return "unknown";
2506
2573
  }
2507
2574
 
2508
2575
  // src/update/hash-installed.ts
2509
2576
  import path15 from "node:path";
2510
2577
  import fg3 from "fast-glob";
2511
- import fs10 from "fs-extra";
2578
+ import fs11 from "fs-extra";
2512
2579
 
2513
2580
  // src/claude/haus-managed-header.ts
2514
2581
  function parseHausManagedAttrs(line2) {
@@ -2551,7 +2618,7 @@ init_logger();
2551
2618
  init_paths();
2552
2619
  import path14 from "node:path";
2553
2620
  import fg2 from "fast-glob";
2554
- import fs9 from "fs-extra";
2621
+ import fs10 from "fs-extra";
2555
2622
  var SUPERPOWERS_ORIGIN_SOURCE_ID = "superpowers-pcvelz";
2556
2623
  var SUPERPOWERS_SHARED_INSTALL_REL = ".claude/skills/shared";
2557
2624
  var PATH_REWRITES = [
@@ -2574,10 +2641,10 @@ async function rewriteMarkdownTree(dir) {
2574
2641
  const files = await fg2("**/*.md", { cwd: dir, onlyFiles: true, dot: true });
2575
2642
  for (const rel of files) {
2576
2643
  const abs = path14.join(dir, rel);
2577
- const text = await fs9.readFile(abs, "utf8");
2644
+ const text = await fs10.readFile(abs, "utf8");
2578
2645
  const rewritten = rewriteSuperpowersMarkdown(text);
2579
2646
  if (rewritten !== text) {
2580
- await fs9.writeFile(abs, rewritten, "utf8");
2647
+ await fs10.writeFile(abs, rewritten, "utf8");
2581
2648
  }
2582
2649
  }
2583
2650
  }
@@ -2591,40 +2658,40 @@ async function extraDestFiles(sourcePath, destination) {
2591
2658
  }
2592
2659
  async function installCatalogSkill(sourcePath, destination, opts) {
2593
2660
  if (opts.dryRun) return;
2594
- await fs9.ensureDir(path14.dirname(destination));
2595
- if (await fs9.pathExists(destination)) {
2661
+ await fs10.ensureDir(path14.dirname(destination));
2662
+ if (await fs10.pathExists(destination)) {
2596
2663
  const extra = await extraDestFiles(sourcePath, destination);
2597
2664
  if (extra.length > 0) {
2598
2665
  warn(
2599
2666
  `${destination}: removing ${extra.length} untracked file(s) not present in the catalog source before reinstall: ${extra.join(", ")}`
2600
2667
  );
2601
2668
  }
2602
- for (const entry of await fs9.readdir(destination)) {
2669
+ for (const entry of await fs10.readdir(destination)) {
2603
2670
  if (SKILL_DEP_ARTIFACT_DIRS.has(entry)) continue;
2604
- await fs9.remove(path14.join(destination, entry));
2671
+ await fs10.remove(path14.join(destination, entry));
2605
2672
  }
2606
2673
  }
2607
- await fs9.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2674
+ await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2608
2675
  if (opts.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID) {
2609
2676
  await rewriteMarkdownTree(destination);
2610
2677
  }
2611
2678
  }
2612
2679
  async function installSuperpowersShared(contentRoot, projectRoot, dryRun) {
2613
2680
  const source = path14.join(contentRoot, SUPERPOWERS_SHARED_CATALOG_REL);
2614
- if (!await fs9.pathExists(source)) return null;
2681
+ if (!await fs10.pathExists(source)) return null;
2615
2682
  const destination = claudePath(projectRoot, "skills", "shared");
2616
2683
  if (dryRun) return path14.relative(projectRoot, destination);
2617
- await fs9.ensureDir(path14.dirname(destination));
2618
- if (await fs9.pathExists(destination)) {
2684
+ await fs10.ensureDir(path14.dirname(destination));
2685
+ if (await fs10.pathExists(destination)) {
2619
2686
  const extra = await extraDestFiles(source, destination);
2620
2687
  if (extra.length > 0) {
2621
2688
  warn(
2622
2689
  `${destination}: removing ${extra.length} untracked file(s) not present in the catalog source before reinstall: ${extra.join(", ")}`
2623
2690
  );
2624
2691
  }
2625
- await fs9.remove(destination);
2692
+ await fs10.remove(destination);
2626
2693
  }
2627
- await fs9.copy(source, destination, { overwrite: true, errorOnExist: false });
2694
+ await fs10.copy(source, destination, { overwrite: true, errorOnExist: false });
2628
2695
  await rewriteMarkdownTree(destination);
2629
2696
  return path14.relative(projectRoot, destination);
2630
2697
  }
@@ -2649,11 +2716,11 @@ async function hashInstalledPaths(root, relPaths, opts = {}) {
2649
2716
  const fileDigests = [];
2650
2717
  for (const rel of normalized) {
2651
2718
  const abs = path15.join(root, rel);
2652
- if (!await fs10.pathExists(abs)) continue;
2653
- const stat = followSymlinks ? await fs10.stat(abs) : await fs10.lstat(abs);
2719
+ if (!await fs11.pathExists(abs)) continue;
2720
+ const stat = followSymlinks ? await fs11.stat(abs) : await fs11.lstat(abs);
2654
2721
  if (!followSymlinks && stat.isSymbolicLink()) continue;
2655
2722
  if (stat.isFile()) {
2656
- const body = await fs10.readFile(abs);
2723
+ const body = await fs11.readFile(abs);
2657
2724
  fileDigests.push({ rel, digest: digestFileContent(body) });
2658
2725
  continue;
2659
2726
  }
@@ -2674,9 +2741,9 @@ async function hashInstalledPaths(root, relPaths, opts = {}) {
2674
2741
  });
2675
2742
  for (const sub of inner.sort()) {
2676
2743
  const absFile = path15.join(abs, sub);
2677
- if (!followSymlinks && (await fs10.lstat(absFile)).isSymbolicLink()) continue;
2744
+ if (!followSymlinks && (await fs11.lstat(absFile)).isSymbolicLink()) continue;
2678
2745
  const relFile = path15.join(rel, sub).replace(/\\/g, "/");
2679
- const body = await fs10.readFile(absFile);
2746
+ const body = await fs11.readFile(absFile);
2680
2747
  fileDigests.push({ rel: relFile, digest: digestFileContent(body) });
2681
2748
  }
2682
2749
  }
@@ -2715,15 +2782,15 @@ init_logger();
2715
2782
  init_paths();
2716
2783
 
2717
2784
  // src/claude/managed-write.ts
2718
- import fs11 from "fs-extra";
2785
+ import fs12 from "fs-extra";
2719
2786
  init_fs();
2720
2787
  init_logger();
2721
2788
  init_paths();
2722
2789
  async function writeManagedText(root, filePath, nextText, dryRun, quiet = false) {
2723
2790
  const say = quiet ? () => {
2724
2791
  } : log;
2725
- const existed = await fs11.pathExists(filePath);
2726
- const prev = existed ? await fs11.readFile(filePath, "utf8") : "";
2792
+ const existed = await fs12.pathExists(filePath);
2793
+ const prev = existed ? await fs12.readFile(filePath, "utf8") : "";
2727
2794
  const printable = displayPath(root, filePath);
2728
2795
  if (dryRun) {
2729
2796
  if (!existed) {
@@ -2759,7 +2826,7 @@ function buildAllowRules() {
2759
2826
  // src/install/settings-merge.ts
2760
2827
  init_fs();
2761
2828
  import path17 from "node:path";
2762
- import fs12 from "fs-extra";
2829
+ import fs13 from "fs-extra";
2763
2830
 
2764
2831
  // src/install/manifest.ts
2765
2832
  init_fs();
@@ -3000,7 +3067,7 @@ function stripHausAsk(settings) {
3000
3067
  async function loadHooksFragment(fragmentPath) {
3001
3068
  let raw;
3002
3069
  try {
3003
- raw = await fs12.readJson(fragmentPath);
3070
+ raw = await fs13.readJson(fragmentPath);
3004
3071
  } catch {
3005
3072
  return [];
3006
3073
  }
@@ -3134,9 +3201,33 @@ async function applyProjectSettingsMerge(root) {
3134
3201
 
3135
3202
  // src/claude/remove-items.ts
3136
3203
  import path18 from "node:path";
3137
- import fs13 from "fs-extra";
3204
+ import fs14 from "fs-extra";
3138
3205
  init_fs();
3139
3206
  init_paths();
3207
+
3208
+ // src/claude/backup-kinds.ts
3209
+ var DIR_BACKUP_PREFIXES = {
3210
+ undo: "undo",
3211
+ prune: "prune",
3212
+ remove: "remove",
3213
+ migrate: "migrate"
3214
+ };
3215
+ var LOCK_BACKUP_RE = /^haus\.lock\.\d+\.json$/;
3216
+ function dirBackupDirName(kind, stamp) {
3217
+ return `${DIR_BACKUP_PREFIXES[kind]}-${stamp}`;
3218
+ }
3219
+ function lockBackupFileName(epochMs) {
3220
+ return `haus.lock.${epochMs}.json`;
3221
+ }
3222
+ function classifyBackupName(name) {
3223
+ if (LOCK_BACKUP_RE.test(name)) return "lock";
3224
+ for (const kind of Object.keys(DIR_BACKUP_PREFIXES)) {
3225
+ if (name.startsWith(`${DIR_BACKUP_PREFIXES[kind]}-`)) return kind;
3226
+ }
3227
+ return void 0;
3228
+ }
3229
+
3230
+ // src/claude/remove-items.ts
3140
3231
  async function resolveExistingTrackedPaths(root, itemId, relPaths, warnFn) {
3141
3232
  const existing = [];
3142
3233
  for (const rel of relPaths) {
@@ -3145,7 +3236,7 @@ async function resolveExistingTrackedPaths(root, itemId, relPaths, warnFn) {
3145
3236
  warnFn(`Refusing to touch ${itemId}'s recorded path outside the project root: ${rel}`);
3146
3237
  continue;
3147
3238
  }
3148
- if (await fs13.pathExists(abs)) existing.push({ rel, abs });
3239
+ if (await fs14.pathExists(abs)) existing.push({ rel, abs });
3149
3240
  }
3150
3241
  return existing;
3151
3242
  }
@@ -3161,13 +3252,13 @@ async function hashGateEntry(root, entry, existing) {
3161
3252
  async function backupTrackedPaths(root, absPaths, say, prefix) {
3162
3253
  if (absPaths.length === 0) return null;
3163
3254
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3164
- const backupRoot = hausPath(root, "backups", `${prefix}-${stamp}`);
3255
+ const backupRoot = hausPath(root, "backups", dirBackupDirName(prefix, stamp));
3165
3256
  for (const abs of absPaths) {
3166
- if (!await fs13.pathExists(abs)) continue;
3257
+ if (!await fs14.pathExists(abs)) continue;
3167
3258
  const rel = path18.relative(root, abs);
3168
3259
  const backupPath = path18.join(backupRoot, rel);
3169
- await fs13.ensureDir(path18.dirname(backupPath));
3170
- await fs13.copy(abs, backupPath);
3260
+ await fs14.ensureDir(path18.dirname(backupPath));
3261
+ await fs14.copy(abs, backupPath);
3171
3262
  }
3172
3263
  say(
3173
3264
  `Backed up ${absPaths.length} path(s) to ${path18.relative(root, backupRoot)} before deleting them.`
@@ -3176,7 +3267,7 @@ async function backupTrackedPaths(root, absPaths, say, prefix) {
3176
3267
  }
3177
3268
  async function deleteTrackedPaths(root, itemId, existing, { verb, reason }, say) {
3178
3269
  for (const { abs } of existing) {
3179
- await fs13.remove(abs);
3270
+ await fs14.remove(abs);
3180
3271
  await pruneEmptyDir(path18.dirname(abs));
3181
3272
  say([verb, reason, displayPath(root, abs)].filter(Boolean).join(" ") + ` (${itemId})`);
3182
3273
  }
@@ -3185,7 +3276,7 @@ async function deleteTrackedPaths(root, itemId, existing, { verb, reason }, say)
3185
3276
  // src/claude/skill-dep-install.ts
3186
3277
  init_exec();
3187
3278
  import path19 from "node:path";
3188
- import fs14 from "fs-extra";
3279
+ import fs15 from "fs-extra";
3189
3280
  function skillDepInstallCommand(manager) {
3190
3281
  switch (manager) {
3191
3282
  case "npm":
@@ -3222,7 +3313,7 @@ function skillDepInstallCommand(manager) {
3222
3313
  }
3223
3314
  async function hasDeclaredDependencies(dir) {
3224
3315
  try {
3225
- const raw = await fs14.readFile(path19.join(dir, "package.json"), "utf8");
3316
+ const raw = await fs15.readFile(path19.join(dir, "package.json"), "utf8");
3226
3317
  const parsed = JSON.parse(raw);
3227
3318
  if (typeof parsed !== "object" || parsed === null) return false;
3228
3319
  const deps = parsed.dependencies;
@@ -3234,7 +3325,7 @@ async function hasDeclaredDependencies(dir) {
3234
3325
  }
3235
3326
  async function realpathOrSelf(target) {
3236
3327
  try {
3237
- return await fs14.realpath(target);
3328
+ return await fs15.realpath(target);
3238
3329
  } catch {
3239
3330
  return path19.resolve(target);
3240
3331
  }
@@ -3279,7 +3370,7 @@ async function installSkillDependencies(opts, deps = {}) {
3279
3370
  default:
3280
3371
  return assertNeverCommandProbe(probe);
3281
3372
  }
3282
- if (!await fs14.pathExists(contained.dir)) {
3373
+ if (!await fs15.pathExists(contained.dir)) {
3283
3374
  return { status: "skipped", reason: `install directory disappeared (${contained.dir})` };
3284
3375
  }
3285
3376
  const run = deps.run ?? defaultRun;
@@ -3293,7 +3384,7 @@ async function installSkillDependencies(opts, deps = {}) {
3293
3384
  // src/claude/verify-hooks-contract.ts
3294
3385
  init_fs();
3295
3386
  init_paths();
3296
- import fs15 from "fs-extra";
3387
+ import fs16 from "fs-extra";
3297
3388
 
3298
3389
  // src/claude/load-hooks.ts
3299
3390
  var CANONICAL_HOOKS = {
@@ -3377,7 +3468,7 @@ async function assertPostApplySettingsHausContract(root) {
3377
3468
  }
3378
3469
  async function verifyProjectSettingsHooksContract(root) {
3379
3470
  const settingsPath = claudePath(root, "settings.json");
3380
- if (!await fs15.pathExists(settingsPath)) {
3471
+ if (!await fs16.pathExists(settingsPath)) {
3381
3472
  return {
3382
3473
  ok: true,
3383
3474
  skipped: true,
@@ -3405,7 +3496,7 @@ async function verifyProjectSettingsHooksContract(root) {
3405
3496
 
3406
3497
  // src/claude/write-decisions-seed.ts
3407
3498
  import path20 from "node:path";
3408
- import fs16 from "fs-extra";
3499
+ import fs17 from "fs-extra";
3409
3500
 
3410
3501
  // library/catalog/decisions-triggers.json
3411
3502
  var decisions_triggers_default = {
@@ -3450,15 +3541,15 @@ Why we chose X \u2014 short, dated, write-once decision log for humans and agent
3450
3541
  async function writeDecisionsSeed(root, dryRun, quiet = false) {
3451
3542
  const decisionsDir = path20.join(root, DECISIONS_TRIGGERS.decisionsDir);
3452
3543
  const readmePath = path20.join(decisionsDir, "README.md");
3453
- if (await fs16.pathExists(readmePath)) return null;
3454
- if (!dryRun) await fs16.ensureDir(decisionsDir);
3544
+ if (await fs17.pathExists(readmePath)) return null;
3545
+ if (!dryRun) await fs17.ensureDir(decisionsDir);
3455
3546
  await writeManagedText(root, readmePath, SEED_README, dryRun, quiet);
3456
3547
  return readmePath;
3457
3548
  }
3458
3549
 
3459
3550
  // src/claude/write-prettierignore.ts
3460
3551
  import path21 from "node:path";
3461
- import fs17 from "fs-extra";
3552
+ import fs18 from "fs-extra";
3462
3553
  var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
3463
3554
  var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
3464
3555
  var IGNORED_PATHS = [".haus-workflow/", ".claude/"];
@@ -3527,7 +3618,7 @@ ${block}
3527
3618
  }
3528
3619
  async function writePrettierIgnore(root, dryRun, quiet = false) {
3529
3620
  const filePath = path21.join(root, ".prettierignore");
3530
- const prev = await fs17.pathExists(filePath) ? await fs17.readFile(filePath, "utf8") : "";
3621
+ const prev = await fs18.pathExists(filePath) ? await fs18.readFile(filePath, "utf8") : "";
3531
3622
  const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
3532
3623
  await writeManagedText(root, filePath, next, dryRun, quiet);
3533
3624
  return filePath;
@@ -3535,7 +3626,7 @@ async function writePrettierIgnore(root, dryRun, quiet = false) {
3535
3626
 
3536
3627
  // src/claude/write-root-claude-md.ts
3537
3628
  import path22 from "node:path";
3538
- import fs18 from "fs-extra";
3629
+ import fs19 from "fs-extra";
3539
3630
  var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
3540
3631
  var BLOCK_END = "<!-- HAUS:END haus-imports -->";
3541
3632
  var WORKSPACE_BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-workspace-imports v=1 -->";
@@ -3614,7 +3705,7 @@ ${block}
3614
3705
  async function writeRootClaudeMd(root, dryRun, quiet = false) {
3615
3706
  const filePath = path22.join(root, "CLAUDE.md");
3616
3707
  const block = buildImportBlock();
3617
- const prev = await fs18.pathExists(filePath) ? await fs18.readFile(filePath, "utf8") : "";
3708
+ const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
3618
3709
  const next = injectHausBlock(prev, block);
3619
3710
  await writeManagedText(root, filePath, next, dryRun, quiet);
3620
3711
  return filePath;
@@ -3625,12 +3716,12 @@ init_fs();
3625
3716
  init_logger();
3626
3717
  init_paths();
3627
3718
  import path24 from "node:path";
3628
- import fs20 from "fs-extra";
3719
+ import fs21 from "fs-extra";
3629
3720
 
3630
3721
  // src/claude/derive-workflow-config.ts
3631
3722
  init_fs();
3632
3723
  import path23 from "node:path";
3633
- import fs19 from "fs-extra";
3724
+ import fs20 from "fs-extra";
3634
3725
  function binCmd(pm, bin, args) {
3635
3726
  const tail = args ? ` ${args}` : "";
3636
3727
  if (pm === "yarn") return `yarn ${bin}${tail}`;
@@ -3649,7 +3740,7 @@ async function deriveWorkflowConfig(root, ctx) {
3649
3740
  return null;
3650
3741
  };
3651
3742
  const hasDep = (name) => deps.has(name);
3652
- const exists = (rel) => fs19.pathExistsSync(path23.join(root, rel));
3743
+ const exists = (rel) => fs20.pathExistsSync(path23.join(root, rel));
3653
3744
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
3654
3745
  const hasCypress = hasDep("cypress");
3655
3746
  const preCommitTool = exists("lefthook.yml") || exists("lefthook.yaml") ? "lefthook" : exists(".husky") || hasDep("husky") || (scripts.prepare ?? "").includes("husky") ? "husky" : exists(".pre-commit-config.yaml") ? "pre-commit (Python framework)" : null;
@@ -3718,7 +3809,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
3718
3809
  } : log;
3719
3810
  const destPath = hausPath(root, "workflow-config.md");
3720
3811
  const printable = displayPath(root, destPath);
3721
- const exists = await fs20.pathExists(destPath);
3812
+ const exists = await fs21.pathExists(destPath);
3722
3813
  if (exists && !opts.refill) {
3723
3814
  if (dryRun) say(printable + ": exists (project-owned, skipping)");
3724
3815
  return null;
@@ -3730,7 +3821,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
3730
3821
  };
3731
3822
  const values = await deriveWorkflowConfig(root, ctx);
3732
3823
  if (exists) {
3733
- const current = await fs20.readFile(destPath, "utf8");
3824
+ const current = await fs21.readFile(destPath, "utf8");
3734
3825
  const refilled = refillContent(current, values);
3735
3826
  if (refilled === current) {
3736
3827
  if (dryRun) say(printable + ": no blank fields to refill");
@@ -3752,7 +3843,8 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
3752
3843
  }
3753
3844
 
3754
3845
  // src/claude/write-workflow.ts
3755
- import fs21 from "fs-extra";
3846
+ init_offline();
3847
+ import fs22 from "fs-extra";
3756
3848
  init_fs();
3757
3849
  init_logger();
3758
3850
  init_paths();
@@ -3784,7 +3876,7 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false, quiet = fa
3784
3876
  const templateContent = await readWorkflowTemplate({ dryRun, projectRoot: root });
3785
3877
  if (templateContent === null) {
3786
3878
  warn(
3787
- `Workflow template could not be fetched from the catalog \u2014 check your network, then re-run \`haus apply --write\` (or \`haus update\`)`
3879
+ isOffline() ? `Workflow template is not in the local catalog cache and this run is offline \u2014 run \`haus catalog bundle import <file>\` (or drop \`--offline\`), then re-run \`haus apply --write\`` : `Workflow template could not be fetched from the catalog \u2014 check your network, then re-run \`haus apply --write\` (or \`haus update\`)`
3788
3880
  );
3789
3881
  return null;
3790
3882
  }
@@ -3794,8 +3886,8 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false, quiet = fa
3794
3886
  ${templateContent}`;
3795
3887
  const destPath = hausPath(root, "WORKFLOW.md");
3796
3888
  const printable = displayPath(root, destPath);
3797
- if (await fs21.pathExists(destPath)) {
3798
- const existing = await fs21.readFile(destPath, "utf8");
3889
+ if (await fs22.pathExists(destPath)) {
3890
+ const existing = await fs22.readFile(destPath, "utf8");
3799
3891
  const firstLine = existing.split("\n")[0] ?? "";
3800
3892
  const parsed = parseHausManagedHeader(firstLine);
3801
3893
  if (!parsed) {
@@ -3839,7 +3931,7 @@ ${existingContent}`;
3839
3931
  }
3840
3932
  }
3841
3933
  if (dryRun) {
3842
- const prev = await fs21.pathExists(destPath) ? await fs21.readFile(destPath, "utf8") : "";
3934
+ const prev = await fs22.pathExists(destPath) ? await fs22.readFile(destPath, "utf8") : "";
3843
3935
  if (!prev) {
3844
3936
  say(createUnifiedDiff(printable, "", next));
3845
3937
  } else {
@@ -3894,14 +3986,14 @@ function decodeIfText(buf) {
3894
3986
  return Buffer.from(asText, "utf8").equals(buf) ? asText : null;
3895
3987
  }
3896
3988
  async function listFilesRecursive2(dir) {
3897
- if (!await fs22.pathExists(dir)) return [];
3989
+ if (!await fs23.pathExists(dir)) return [];
3898
3990
  const files = [];
3899
3991
  const walk = async (current, relPrefix) => {
3900
- const names = await fs22.readdir(current);
3992
+ const names = await fs23.readdir(current);
3901
3993
  for (const name of names) {
3902
3994
  const abs = path25.join(current, name);
3903
3995
  const rel = relPrefix ? `${relPrefix}/${name}` : name;
3904
- const stat = await fs22.lstat(abs);
3996
+ const stat = await fs23.lstat(abs);
3905
3997
  if (stat.isSymbolicLink()) {
3906
3998
  warn(`Skipping symlink in dry-run diff preview: ${rel}`);
3907
3999
  continue;
@@ -3918,14 +4010,14 @@ async function listFilesRecursive2(dir) {
3918
4010
  }
3919
4011
  async function logFileDryRunDiff(root, srcFile, destFile, itemId, say) {
3920
4012
  const printable = displayPath(root, destFile);
3921
- const nextBuf = await fs22.readFile(srcFile);
3922
- if (!await fs22.pathExists(destFile)) {
4013
+ const nextBuf = await fs23.readFile(srcFile);
4014
+ if (!await fs23.pathExists(destFile)) {
3923
4015
  say(`${printable}: would create (${itemId})`);
3924
4016
  const nextText2 = decodeIfText(nextBuf);
3925
4017
  if (nextText2 !== null) say(createUnifiedDiff(printable, "", nextText2));
3926
4018
  return;
3927
4019
  }
3928
- const prevBuf = await fs22.readFile(destFile);
4020
+ const prevBuf = await fs23.readFile(destFile);
3929
4021
  if (prevBuf.equals(nextBuf)) {
3930
4022
  say(`${printable}: unchanged (${itemId})`);
3931
4023
  return;
@@ -3958,15 +4050,15 @@ async function logSkillDryRunDiff(root, sourcePath, destination, itemId, say) {
3958
4050
  }
3959
4051
  async function removeLegacyManagedStub(root, relPathSegments, stub, dryRun, say) {
3960
4052
  const target = claudePath(root, ...relPathSegments);
3961
- if (!await fs22.pathExists(target)) return;
3962
- const content2 = await fs22.readFile(target, "utf8");
4053
+ if (!await fs23.pathExists(target)) return;
4054
+ const content2 = await fs23.readFile(target, "utf8");
3963
4055
  if (content2 !== stub && content2 !== `${stub}
3964
4056
  ` && content2 !== `${stub}\r
3965
4057
  `) return;
3966
4058
  if (dryRun) {
3967
4059
  say(`[dry-run] would remove stale ${displayPath(root, target)}`);
3968
4060
  } else {
3969
- await fs22.remove(target);
4061
+ await fs23.remove(target);
3970
4062
  }
3971
4063
  }
3972
4064
  async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
@@ -4072,11 +4164,11 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4072
4164
  ];
4073
4165
  for (const rel of LEGACY_PRUNED_ARTIFACTS) {
4074
4166
  const legacyPath = hausPath(root, rel);
4075
- if (await fs22.pathExists(legacyPath)) {
4167
+ if (await fs23.pathExists(legacyPath)) {
4076
4168
  if (dryRun) {
4077
4169
  say(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
4078
4170
  } else {
4079
- await fs22.remove(legacyPath);
4171
+ await fs23.remove(legacyPath);
4080
4172
  }
4081
4173
  }
4082
4174
  }
@@ -4194,7 +4286,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4194
4286
  }
4195
4287
  const sourcePath = catalogItemContentPath(contentRoot, manifestItem);
4196
4288
  const destination = placement.destination;
4197
- if (await fs22.pathExists(sourcePath)) {
4289
+ if (await fs23.pathExists(sourcePath)) {
4198
4290
  if (dryRun) {
4199
4291
  if (item.type === "skill") {
4200
4292
  await logSkillDryRunDiff(root, sourcePath, destination, item.id, say);
@@ -4221,13 +4313,13 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4221
4313
  )) {
4222
4314
  continue;
4223
4315
  } else if (item.type === "skill") {
4224
- const skillFiles = (await fs22.readdir(sourcePath, { recursive: true })).filter(
4316
+ const skillFiles = (await fs23.readdir(sourcePath, { recursive: true })).filter(
4225
4317
  (f) => typeof f === "string" && f.endsWith(".md")
4226
4318
  );
4227
4319
  let skillValid = true;
4228
4320
  for (const relFile of skillFiles) {
4229
4321
  const mdPath = path25.join(sourcePath, relFile);
4230
- const mdContent = await fs22.readFile(mdPath, "utf8");
4322
+ const mdContent = await fs23.readFile(mdPath, "utf8");
4231
4323
  const mdValidation = validateCatalogItem(
4232
4324
  {
4233
4325
  id: manifestItem.id,
@@ -4260,7 +4352,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4260
4352
  say
4261
4353
  );
4262
4354
  } else {
4263
- const fileContent = await fs22.readFile(sourcePath, "utf8");
4355
+ const fileContent = await fs23.readFile(sourcePath, "utf8");
4264
4356
  const validation = validateCatalogItem(
4265
4357
  {
4266
4358
  id: manifestItem.id,
@@ -4274,8 +4366,8 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4274
4366
  warn(`Skipping ${item.id}: pre-copy validation failed \u2014 ${validation.reason}`);
4275
4367
  continue;
4276
4368
  }
4277
- await fs22.ensureDir(path25.dirname(destination));
4278
- await fs22.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
4369
+ await fs23.ensureDir(path25.dirname(destination));
4370
+ await fs23.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
4279
4371
  }
4280
4372
  files.push(destination);
4281
4373
  const relPaths = [path25.relative(root, destination)];
@@ -4407,7 +4499,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
4407
4499
  async function hasProtectedUnsafePath(root, relPaths) {
4408
4500
  for (const rel of relPaths) {
4409
4501
  if (resolveContainedPath(root, rel) !== null) continue;
4410
- if (await fs22.pathExists(path25.resolve(root, rel))) return true;
4502
+ if (await fs23.pathExists(path25.resolve(root, rel))) return true;
4411
4503
  }
4412
4504
  return false;
4413
4505
  }
@@ -4471,7 +4563,7 @@ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, q
4471
4563
  void await backupTrackedPaths(root, allAbsPaths, say, "migrate");
4472
4564
  for (const { existing } of toRemove) {
4473
4565
  for (const { abs } of existing) {
4474
- await fs22.remove(abs);
4566
+ await fs23.remove(abs);
4475
4567
  await pruneEmptyDir(path25.dirname(abs));
4476
4568
  }
4477
4569
  }
@@ -4550,7 +4642,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet, activ
4550
4642
  say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
4551
4643
  continue;
4552
4644
  }
4553
- await fs22.remove(abs);
4645
+ await fs23.remove(abs);
4554
4646
  await pruneEmptyDir(path25.dirname(abs));
4555
4647
  say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
4556
4648
  }
@@ -4605,7 +4697,7 @@ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun,
4605
4697
  // src/claude/write-gitignore.ts
4606
4698
  init_exec();
4607
4699
  import path26 from "node:path";
4608
- import fs23 from "fs-extra";
4700
+ import fs24 from "fs-extra";
4609
4701
  var GITIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
4610
4702
  var GITIGNORE_END = "# HAUS:END haus-managed";
4611
4703
  var GITIGNORED_ARTIFACT_PATHS = [
@@ -4721,14 +4813,14 @@ function injectLinkContextGitignoreBlock(existing) {
4721
4813
  }
4722
4814
  async function writeGitignore(root, dryRun, quiet = false) {
4723
4815
  const filePath = path26.join(root, ".gitignore");
4724
- const prev = await fs23.pathExists(filePath) ? await fs23.readFile(filePath, "utf8") : "";
4816
+ const prev = await fs24.pathExists(filePath) ? await fs24.readFile(filePath, "utf8") : "";
4725
4817
  const next = injectGitignoreBlock(prev);
4726
4818
  await writeManagedText(root, filePath, next, dryRun, quiet);
4727
4819
  return filePath;
4728
4820
  }
4729
4821
  async function writeLinkContextGitignore(root, dryRun, quiet = false) {
4730
4822
  const filePath = path26.join(root, ".gitignore");
4731
- const prev = await fs23.pathExists(filePath) ? await fs23.readFile(filePath, "utf8") : "";
4823
+ const prev = await fs24.pathExists(filePath) ? await fs24.readFile(filePath, "utf8") : "";
4732
4824
  const next = injectLinkContextGitignoreBlock(prev);
4733
4825
  await writeManagedText(root, filePath, next, dryRun, quiet);
4734
4826
  return filePath;
@@ -4770,6 +4862,7 @@ async function untrackMachineLocalArtifacts(root, dryRun, notify) {
4770
4862
 
4771
4863
  // src/install/ensure-global-plugins.ts
4772
4864
  import path27 from "node:path";
4865
+ init_offline();
4773
4866
  init_exec();
4774
4867
  init_fs();
4775
4868
  init_logger();
@@ -4950,6 +5043,9 @@ var EXEC_ENV = { GIT_TERMINAL_PROMPT: "0" };
4950
5043
  function globalPluginsDisabled() {
4951
5044
  return process.env.HAUS_NO_GLOBAL_PLUGINS === "1";
4952
5045
  }
5046
+ function globalPluginMutationsDisabled() {
5047
+ return globalPluginsDisabled() || isOffline();
5048
+ }
4953
5049
  var realExec = (args) => runCommand("claude", args, {
4954
5050
  timeout: EXEC_TIMEOUT_MS,
4955
5051
  env: EXEC_ENV,
@@ -5093,7 +5189,7 @@ async function ensureGlobalPlugins(pins, deps = {}) {
5093
5189
  const log2 = deps.log ?? log;
5094
5190
  const warn2 = deps.warn ?? warn;
5095
5191
  const dryRun = deps.dryRun ?? false;
5096
- if (globalPluginsDisabled() && deps.exec === void 0) {
5192
+ if (globalPluginMutationsDisabled() && deps.exec === void 0) {
5097
5193
  return { actions: [], ensured: [], failed: [], unavailable: true, restartRequired: false };
5098
5194
  }
5099
5195
  const state = await probePluginEnv(deps);
@@ -5162,7 +5258,7 @@ async function removeGlobalPlugins(plugins, deps = {}) {
5162
5258
  const log2 = deps.log ?? log;
5163
5259
  const result = { removed: [], failed: [], unavailable: false };
5164
5260
  if (plugins.length === 0) return result;
5165
- if (globalPluginsDisabled() && deps.exec === void 0) {
5261
+ if (globalPluginMutationsDisabled() && deps.exec === void 0) {
5166
5262
  return { ...result, unavailable: true };
5167
5263
  }
5168
5264
  const state = await probePluginEnv(deps);
@@ -5211,25 +5307,25 @@ async function removeGlobalPlugins(plugins, deps = {}) {
5211
5307
 
5212
5308
  // src/refs/fetch-refs.ts
5213
5309
  import path29 from "node:path";
5214
- import fs25 from "fs-extra";
5310
+ import fs26 from "fs-extra";
5215
5311
 
5216
5312
  // src/refs/cache-meta.ts
5217
5313
  import path28 from "node:path";
5218
- import fs24 from "fs-extra";
5314
+ import fs25 from "fs-extra";
5219
5315
  var META_FILENAME = "cache-meta.json";
5220
5316
  async function readCacheMeta(cacheDir) {
5221
5317
  const metaPath = path28.join(cacheDir, META_FILENAME);
5222
5318
  try {
5223
- const raw = await fs24.readFile(metaPath, "utf8");
5319
+ const raw = await fs25.readFile(metaPath, "utf8");
5224
5320
  return JSON.parse(raw);
5225
5321
  } catch {
5226
5322
  return {};
5227
5323
  }
5228
5324
  }
5229
5325
  async function writeCacheMeta(cacheDir, meta) {
5230
- await fs24.ensureDir(cacheDir);
5326
+ await fs25.ensureDir(cacheDir);
5231
5327
  const metaPath = path28.join(cacheDir, META_FILENAME);
5232
- await fs24.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
5328
+ await fs25.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
5233
5329
  }
5234
5330
 
5235
5331
  // src/refs/fetch-refs.ts
@@ -5250,7 +5346,7 @@ async function fetchSingleRef(url, cacheDir, meta) {
5250
5346
  const existing = meta[url];
5251
5347
  const headers = {};
5252
5348
  const safeFile = existing?.file && existing.file === path29.basename(existing.file);
5253
- const cachedFileExists = safeFile && await fs25.pathExists(path29.join(cacheDir, existing.file));
5349
+ const cachedFileExists = safeFile && await fs26.pathExists(path29.join(cacheDir, existing.file));
5254
5350
  if (cachedFileExists) {
5255
5351
  if (existing?.etag) headers["If-None-Match"] = existing.etag;
5256
5352
  if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
@@ -5261,8 +5357,8 @@ async function fetchSingleRef(url, cacheDir, meta) {
5261
5357
  if (!res.ok) return { result: "failed" };
5262
5358
  const text = await res.text();
5263
5359
  const file = `${urlToSlug(url)}.md`;
5264
- await fs25.ensureDir(cacheDir);
5265
- await fs25.writeFile(path29.join(cacheDir, file), text, "utf8");
5360
+ await fs26.ensureDir(cacheDir);
5361
+ await fs26.writeFile(path29.join(cacheDir, file), text, "utf8");
5266
5362
  meta[url] = {
5267
5363
  url,
5268
5364
  etag: res.headers.get("etag") ?? void 0,
@@ -5286,7 +5382,7 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
5286
5382
  delete meta[url];
5287
5383
  removed++;
5288
5384
  const safeFile = typeof entry.file === "string" && entry.file === path29.basename(entry.file);
5289
- if (safeFile) await fs25.remove(path29.join(cacheDir, entry.file)).catch(() => {
5385
+ if (safeFile) await fs26.remove(path29.join(cacheDir, entry.file)).catch(() => {
5290
5386
  });
5291
5387
  }
5292
5388
  if (removed > 0) await writeCacheMeta(cacheDir, meta);
@@ -5360,13 +5456,6 @@ function dryRunPrefix(dryRun, text) {
5360
5456
 
5361
5457
  // src/commands/apply.ts
5362
5458
  var STALE_CACHE_THRESHOLD_DAYS = 7;
5363
- async function cacheHasItems() {
5364
- const data = await readJson(path30.join(getCacheDir(), "manifest.json"));
5365
- return Array.isArray(data?.items) && data.items.length > 0;
5366
- }
5367
- function installableRecommendedItems(recommended) {
5368
- return recommended.filter((item) => item.install !== false);
5369
- }
5370
5459
  async function runApply(options) {
5371
5460
  const mode = resolveWriteMode(options, "explicit-choice");
5372
5461
  if (mode.kind === "unspecified") {
@@ -5383,11 +5472,11 @@ async function runApply(options) {
5383
5472
  process.exitCode = 1;
5384
5473
  return;
5385
5474
  }
5386
- const rec = await readJson(hausPath(root, "recommendation.json"));
5387
- const recommended = rec?.recommended ?? [];
5475
+ const rec2 = await readJson(hausPath(root, "recommendation.json"));
5476
+ const recommended = rec2?.recommended ?? [];
5388
5477
  const installableIds = new Set(installableRecommendedItems(recommended).map((i) => i.id));
5389
5478
  const configIds = new Set(recommended.filter((i) => i.install === false).map((i) => i.id));
5390
- const skippedIds = new Set((rec?.skipped ?? []).map((s) => s.id));
5479
+ const skippedIds = new Set((rec2?.skipped ?? []).map((s) => s.id));
5391
5480
  for (const id of explicitIds.filter((id2) => !installableIds.has(id2))) {
5392
5481
  if (configIds.has(id)) {
5393
5482
  warn(`--ids: "${id}" is a config item \u2014 install it with \`haus configs ${id}\`. Ignoring.`);
@@ -5410,12 +5499,12 @@ async function runApply(options) {
5410
5499
  process.exitCode = 1;
5411
5500
  return;
5412
5501
  }
5413
- const rec = await readJson(hausPath(root, "recommendation.json"));
5414
- if (!rec) {
5502
+ const rec2 = await readJson(hausPath(root, "recommendation.json"));
5503
+ if (!rec2) {
5415
5504
  log("No recommendation.json found \u2014 run `haus recommend` first. Writing core files only.");
5416
5505
  selectedIds = [];
5417
5506
  } else {
5418
- const items = installableRecommendedItems(rec.recommended);
5507
+ const items = installableRecommendedItems(rec2.recommended);
5419
5508
  if (items.length === 0) {
5420
5509
  log("Recommendation contains no catalog items. Writing core files only.");
5421
5510
  selectedIds = [];
@@ -5435,14 +5524,31 @@ async function runApply(options) {
5435
5524
  }
5436
5525
  }
5437
5526
  }
5438
- if (!process.env["HAUS_FIXTURE_CATALOG"]) {
5439
- const rec = await readJson(hausPath(root, "recommendation.json"));
5440
- const installableRecommended = installableRecommendedItems(rec?.recommended ?? []);
5441
- const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : installableRecommended.length;
5442
- if (catalogItemCount > 0) {
5443
- const hasItems = await cacheHasItems();
5444
- if (!hasItems && !options.allowEmptyCache) {
5445
- const message2 = "No catalog content found. Run `haus update` first.";
5527
+ const formatRefusal = await catalogFormatRefusal(root);
5528
+ if (formatRefusal) {
5529
+ error(formatRefusal);
5530
+ process.exitCode = 1;
5531
+ return;
5532
+ }
5533
+ const rec = await readJson(hausPath(root, "recommendation.json"));
5534
+ const installableRecommended = installableRecommendedItems(rec?.recommended ?? []);
5535
+ const wantedIds = selectedIds ?? installableRecommended.map((item) => item.id);
5536
+ let catalogCtx = null;
5537
+ let resolvedWantedIds = wantedIds;
5538
+ if (wantedIds.length > 0) {
5539
+ catalogCtx = await loadCatalogContext(root);
5540
+ const prevLock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
5541
+ const migrations = findFormerIdMigrations(
5542
+ prevLock.filter((entry) => Boolean(entry.id)),
5543
+ catalogCtx.items
5544
+ );
5545
+ const migrationByOldId = new Map(migrations.map((m) => [m.oldId, m.newId]));
5546
+ resolvedWantedIds = wantedIds.map((id) => migrationByOldId.get(id) ?? id);
5547
+ const shortfall = await countInstallableItems(catalogCtx, resolvedWantedIds);
5548
+ const tier = `${catalogCtx.source}${catalogCtx.version ? ` (v${catalogCtx.version})` : ""}`;
5549
+ if (shortfall.installable === 0) {
5550
+ const message2 = "No catalog content found. Run `haus update` first.";
5551
+ if (!options.allowEmptyCache) {
5446
5552
  if (isDryRun) {
5447
5553
  warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message2}`);
5448
5554
  } else {
@@ -5450,25 +5556,32 @@ async function runApply(options) {
5450
5556
  process.exitCode = 1;
5451
5557
  return;
5452
5558
  }
5453
- } else if (hasItems) {
5454
- const cacheAgeMs = await getCacheManifestAge();
5455
- if (cacheAgeMs !== null) {
5456
- const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
5457
- if (cacheAgeDays >= STALE_CACHE_THRESHOLD_DAYS) {
5458
- warn(
5459
- `Catalog cache is ${cacheAgeDays} days old \u2014 run \`haus update\` for the latest content.`
5460
- );
5461
- }
5559
+ } else {
5560
+ warn(
5561
+ `Serving catalog metadata from the ${tier} tier, which has no installable content. Core files will be written; all ${wantedIds.length} catalog item(s) will be skipped.`
5562
+ );
5563
+ }
5564
+ } else if (shortfall.missing.length > 0) {
5565
+ warn(
5566
+ `The ${tier} catalog tier is missing content for ${shortfall.missing.length} item(s): ${shortfall.missing.join(", ")}. They will be skipped.`
5567
+ );
5568
+ }
5569
+ if (catalogCtx.source === "cache") {
5570
+ const cacheAgeMs = await getCacheManifestAge();
5571
+ if (cacheAgeMs !== null) {
5572
+ const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
5573
+ if (cacheAgeDays >= STALE_CACHE_THRESHOLD_DAYS) {
5574
+ warn(
5575
+ `Catalog cache is ${cacheAgeDays} days old \u2014 run \`haus update\` for the latest content.`
5576
+ );
5462
5577
  }
5463
5578
  }
5579
+ } else if (catalogCtx.source === "bundled") {
5580
+ warn(
5581
+ `Catalog is being served from the snapshot bundled with this CLI${catalogCtx.version ? ` (v${catalogCtx.version})` : ""}, frozen at publish time. Run \`haus update\` when you have network access.`
5582
+ );
5464
5583
  }
5465
5584
  }
5466
- const formatRefusal = await catalogFormatRefusal(root);
5467
- if (formatRefusal) {
5468
- error(formatRefusal);
5469
- process.exitCode = 1;
5470
- return;
5471
- }
5472
5585
  const files = await writeClaudeFiles(root, isDryRun, selectedIds, {
5473
5586
  refillConfig: options.refillConfig,
5474
5587
  force: options.force,
@@ -5485,13 +5598,19 @@ async function runApply(options) {
5485
5598
  const installedIds = new Set(lock.map((entry) => entry.id));
5486
5599
  const allItems = await loadCatalog(root);
5487
5600
  const installedItems = allItems.filter((item) => installedIds.has(item.id));
5488
- const summary = await fetchRefsForItems(installedItems, cacheDir);
5489
- if (summary.fetched > 0)
5490
- log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
5491
- if (summary.failed > 0)
5492
- warn(
5493
- `Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
5494
- );
5601
+ if (isOffline()) {
5602
+ const refCount = collectLlmsTxtUrls(installedItems).length;
5603
+ if (refCount > 0)
5604
+ log(`Offline \u2014 skipping ${refCount} llms.txt reference fetch(es); using the cache as-is.`);
5605
+ } else {
5606
+ const summary = await fetchRefsForItems(installedItems, cacheDir);
5607
+ if (summary.fetched > 0)
5608
+ log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
5609
+ if (summary.failed > 0)
5610
+ warn(
5611
+ `Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
5612
+ );
5613
+ }
5495
5614
  const pruned = await pruneOrphanedRefs(cacheDir, new Set(collectLlmsTxtUrls(installedItems)));
5496
5615
  if (pruned > 0)
5497
5616
  log(
@@ -5514,17 +5633,32 @@ async function runApply(options) {
5514
5633
  }
5515
5634
  if (isDryRun) {
5516
5635
  log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
5517
- } else {
5518
- log("Applied files:");
5519
- files.forEach((f) => log(`- ${displayPath(root, f)}`));
5636
+ return;
5637
+ }
5638
+ log("Applied files:");
5639
+ files.forEach((f) => log(`- ${displayPath(root, f)}`));
5640
+ if (resolvedWantedIds.length > 0) {
5641
+ const { installedCount: installedWantedCount, missingWantedIds } = await recountInstalledAgainstLock(root, resolvedWantedIds);
5642
+ if (missingWantedIds.length > 0) {
5643
+ const tier = catalogCtx ? `${catalogCtx.source}${catalogCtx.version ? ` (v${catalogCtx.version})` : ""}` : "unknown";
5644
+ log(
5645
+ `${installedWantedCount} of ${resolvedWantedIds.length} catalog items installed (${tier} tier).`
5646
+ );
5647
+ if (installedWantedCount === 0) {
5648
+ error(
5649
+ "No catalog items were installed \u2014 only core files were written. Run `haus update` with network access, or import a catalog bundle."
5650
+ );
5651
+ process.exitCode = 1;
5652
+ }
5653
+ }
5520
5654
  }
5521
5655
  }
5522
5656
 
5523
5657
  // src/commands/backups.ts
5658
+ import path30 from "node:path";
5659
+ import fs27 from "fs-extra";
5524
5660
  init_logger();
5525
5661
  init_paths();
5526
- import path31 from "node:path";
5527
- import fs26 from "fs-extra";
5528
5662
 
5529
5663
  // src/utils/prompts.ts
5530
5664
  import { stdin, stdout } from "node:process";
@@ -5587,25 +5721,16 @@ Enter a number (1-${choices.length})`, io);
5587
5721
  }
5588
5722
 
5589
5723
  // src/commands/backups.ts
5590
- var LOCK_BACKUP_RE = /^haus\.lock\.\d+\.json$/;
5591
- function classify(name) {
5592
- if (LOCK_BACKUP_RE.test(name)) return "lock";
5593
- if (name.startsWith("undo-")) return "undo";
5594
- if (name.startsWith("prune-")) return "prune";
5595
- if (name.startsWith("remove-")) return "remove";
5596
- if (name.startsWith("migrate-")) return "migrate";
5597
- return void 0;
5598
- }
5599
5724
  async function listBackups(root) {
5600
5725
  const dir = hausPath(root, "backups");
5601
- if (!await fs26.pathExists(dir)) return [];
5602
- const names = await fs26.readdir(dir);
5726
+ if (!await fs27.pathExists(dir)) return [];
5727
+ const names = await fs27.readdir(dir);
5603
5728
  const entries = [];
5604
5729
  for (const name of names) {
5605
- const kind = classify(name);
5730
+ const kind = classifyBackupName(name);
5606
5731
  if (!kind) continue;
5607
- const absPath = path31.join(dir, name);
5608
- const stat = await fs26.lstat(absPath);
5732
+ const absPath = path30.join(dir, name);
5733
+ const stat = await fs27.lstat(absPath);
5609
5734
  if (stat.isSymbolicLink()) continue;
5610
5735
  const expectDirectory = kind !== "lock";
5611
5736
  if (stat.isDirectory() !== expectDirectory) continue;
@@ -5644,13 +5769,13 @@ async function runList(root, json) {
5644
5769
  }
5645
5770
  }
5646
5771
  async function isUnsafeWriteTarget(root, target) {
5647
- const segments = path31.relative(root, target).split(path31.sep).filter(Boolean);
5772
+ const segments = path30.relative(root, target).split(path30.sep).filter(Boolean);
5648
5773
  let current = root;
5649
5774
  for (let i = 0; i < segments.length; i++) {
5650
- current = path31.join(current, segments[i]);
5775
+ current = path30.join(current, segments[i]);
5651
5776
  const isLastSegment = i === segments.length - 1;
5652
5777
  try {
5653
- const stat = await fs26.lstat(current);
5778
+ const stat = await fs27.lstat(current);
5654
5779
  if (isLastSegment) {
5655
5780
  if (!stat.isFile()) return true;
5656
5781
  } else if (!stat.isDirectory()) {
@@ -5665,7 +5790,7 @@ async function isUnsafeWriteTarget(root, target) {
5665
5790
  }
5666
5791
  async function lstatIfRegularFile(abs) {
5667
5792
  try {
5668
- const stat = await fs26.lstat(abs);
5793
+ const stat = await fs27.lstat(abs);
5669
5794
  return stat.isFile() ? stat : null;
5670
5795
  } catch (err) {
5671
5796
  if (err.code === "ENOENT") return null;
@@ -5680,8 +5805,8 @@ async function restoreLockBackup(root, entry, yes, json, dryRun) {
5680
5805
  );
5681
5806
  return { ok: false, restored: 0 };
5682
5807
  }
5683
- if (await fs26.pathExists(target)) {
5684
- const targetStat = await fs26.stat(target);
5808
+ if (await fs27.pathExists(target)) {
5809
+ const targetStat = await fs27.stat(target);
5685
5810
  if (targetStat.mtimeMs > entry.mtimeMs) {
5686
5811
  warn(
5687
5812
  `Current haus.lock.json (${new Date(targetStat.mtimeMs).toISOString()}) is newer than backup ${entry.id} (${new Date(entry.mtimeMs).toISOString()}) \u2014 it will be overwritten.`
@@ -5715,7 +5840,7 @@ async function restoreLockBackup(root, entry, yes, json, dryRun) {
5715
5840
  error(`Refusing to restore: ${entry.id} or ${target} changed since being checked.`);
5716
5841
  return { ok: false, restored: 0 };
5717
5842
  }
5718
- await fs26.copy(entry.absPath, target, { overwrite: true });
5843
+ await fs27.copy(entry.absPath, target, { overwrite: true });
5719
5844
  if (!json) log(`Restored .haus-workflow/haus.lock.json from ${entry.id}.`);
5720
5845
  return { ok: true, restored: 1 };
5721
5846
  }
@@ -5723,10 +5848,10 @@ async function collectFilesRecursive(dir) {
5723
5848
  const files = [];
5724
5849
  const skippedLinks = [];
5725
5850
  const skippedOther = [];
5726
- const names = await fs26.readdir(dir);
5851
+ const names = await fs27.readdir(dir);
5727
5852
  for (const name of names) {
5728
- const abs = path31.join(dir, name);
5729
- const stat = await fs26.lstat(abs);
5853
+ const abs = path30.join(dir, name);
5854
+ const stat = await fs27.lstat(abs);
5730
5855
  if (stat.isSymbolicLink()) {
5731
5856
  skippedLinks.push(abs);
5732
5857
  continue;
@@ -5748,12 +5873,12 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5748
5873
  const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
5749
5874
  if (skippedLinks.length > 0) {
5750
5875
  warn(
5751
- `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path31.relative(entry.absPath, abs)).join(", ")}`
5876
+ `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path30.relative(entry.absPath, abs)).join(", ")}`
5752
5877
  );
5753
5878
  }
5754
5879
  if (skippedOther.length > 0) {
5755
5880
  warn(
5756
- `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path31.relative(entry.absPath, abs)).join(", ")}`
5881
+ `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path30.relative(entry.absPath, abs)).join(", ")}`
5757
5882
  );
5758
5883
  }
5759
5884
  if (files.length === 0) {
@@ -5763,8 +5888,8 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5763
5888
  const safeFiles = [];
5764
5889
  const unsafeTargets = [];
5765
5890
  for (const abs of files) {
5766
- const rel = path31.relative(entry.absPath, abs);
5767
- const target = path31.join(root, rel);
5891
+ const rel = path30.relative(entry.absPath, abs);
5892
+ const target = path30.join(root, rel);
5768
5893
  if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
5769
5894
  else safeFiles.push(abs);
5770
5895
  }
@@ -5781,16 +5906,16 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5781
5906
  const vanished = [];
5782
5907
  const toRestore = [];
5783
5908
  for (const abs of safeFiles) {
5784
- const rel = path31.relative(entry.absPath, abs);
5909
+ const rel = path30.relative(entry.absPath, abs);
5785
5910
  const backupFileStat = await lstatIfRegularFile(abs);
5786
5911
  if (!backupFileStat) {
5787
5912
  vanished.push(rel);
5788
5913
  continue;
5789
5914
  }
5790
5915
  toRestore.push(abs);
5791
- const target = path31.join(root, rel);
5792
- if (await fs26.pathExists(target)) {
5793
- const targetStat = await fs26.stat(target);
5916
+ const target = path30.join(root, rel);
5917
+ if (await fs27.pathExists(target)) {
5918
+ const targetStat = await fs27.stat(target);
5794
5919
  if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
5795
5920
  stale.push({
5796
5921
  rel,
@@ -5818,7 +5943,7 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5818
5943
  );
5819
5944
  }
5820
5945
  if (dryRun) {
5821
- const rels = toRestore.map((abs) => path31.relative(entry.absPath, abs));
5946
+ const rels = toRestore.map((abs) => path30.relative(entry.absPath, abs));
5822
5947
  if (!json) {
5823
5948
  log(`[dry-run] Would restore ${rels.length} file(s) from ${entry.id}:`);
5824
5949
  for (const rel of rels) log(` ${rel}`);
@@ -5844,14 +5969,14 @@ async function restoreDirBackup(root, entry, yes, json, dryRun) {
5844
5969
  let restoredCount = 0;
5845
5970
  const skippedAtWrite = [];
5846
5971
  for (const abs of toRestore) {
5847
- const rel = path31.relative(entry.absPath, abs);
5848
- const target = path31.join(root, rel);
5972
+ const rel = path30.relative(entry.absPath, abs);
5973
+ const target = path30.join(root, rel);
5849
5974
  if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
5850
5975
  skippedAtWrite.push(rel);
5851
5976
  continue;
5852
5977
  }
5853
- await fs26.ensureDir(path31.dirname(target));
5854
- await fs26.copy(abs, target, { overwrite: true });
5978
+ await fs27.ensureDir(path30.dirname(target));
5979
+ await fs27.copy(abs, target, { overwrite: true });
5855
5980
  restoredCount++;
5856
5981
  }
5857
5982
  if (skippedAtWrite.length > 0) {
@@ -5932,7 +6057,7 @@ Keeping ${keepCount}.`
5932
6057
  const failed = [];
5933
6058
  for (const entry of removeList) {
5934
6059
  try {
5935
- await fs26.remove(entry.absPath);
6060
+ await fs27.remove(entry.absPath);
5936
6061
  removed.push(entry.id);
5937
6062
  } catch (err) {
5938
6063
  failed.push({ id: entry.id, message: err instanceof Error ? err.message : String(err) });
@@ -5977,75 +6102,321 @@ async function runBackups(action, opts = {}) {
5977
6102
  process.exitCode = 1;
5978
6103
  return;
5979
6104
  }
5980
- let olderThanDays;
5981
- if (opts.olderThan !== void 0) {
5982
- const n = Number(opts.olderThan);
5983
- if (!Number.isFinite(n) || n < 0) {
5984
- const message2 = `Invalid --older-than value "${opts.olderThan}" \u2014 expected a non-negative number of days.`;
5985
- if (json) log(JSON.stringify({ ok: false, error: message2 }));
5986
- error(message2);
5987
- process.exitCode = 1;
5988
- return;
6105
+ let olderThanDays;
6106
+ if (opts.olderThan !== void 0) {
6107
+ const n = Number(opts.olderThan);
6108
+ if (!Number.isFinite(n) || n < 0) {
6109
+ const message2 = `Invalid --older-than value "${opts.olderThan}" \u2014 expected a non-negative number of days.`;
6110
+ if (json) log(JSON.stringify({ ok: false, error: message2 }));
6111
+ error(message2);
6112
+ process.exitCode = 1;
6113
+ return;
6114
+ }
6115
+ olderThanDays = n;
6116
+ }
6117
+ let keep;
6118
+ if (opts.keep !== void 0) {
6119
+ const n = Number(opts.keep);
6120
+ if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {
6121
+ const message2 = `Invalid --keep value "${opts.keep}" \u2014 expected a non-negative integer.`;
6122
+ if (json) log(JSON.stringify({ ok: false, error: message2 }));
6123
+ error(message2);
6124
+ process.exitCode = 1;
6125
+ return;
6126
+ }
6127
+ keep = n;
6128
+ }
6129
+ const result = await runPrune(root, {
6130
+ olderThanDays,
6131
+ keep,
6132
+ yes: Boolean(opts.yes),
6133
+ json,
6134
+ dryRun: Boolean(opts.dryRun)
6135
+ });
6136
+ if (json && !result.refused) log(JSON.stringify(result, null, 2));
6137
+ if (!result.ok) process.exitCode = 1;
6138
+ }
6139
+
6140
+ // src/commands/catalog-audit.ts
6141
+ init_validation_rules2();
6142
+ init_logger();
6143
+ function auditForbiddenTags(items) {
6144
+ const failures = [];
6145
+ const forbidden = new Set(FORBIDDEN_TAGS.map((w) => w.toLowerCase()));
6146
+ for (const item of items) {
6147
+ for (const tag of Array.isArray(item.tags) ? item.tags : []) {
6148
+ if (forbidden.has(tag.toLowerCase())) failures.push(`${item.id} has unsupported tag ${tag}`);
6149
+ }
6150
+ for (const token of item.id.toLowerCase().split(/[^a-z0-9+]+/)) {
6151
+ if (forbidden.has(token)) failures.push(`${item.id} has unsupported tag ${token}`);
6152
+ }
6153
+ }
6154
+ return failures;
6155
+ }
6156
+ async function runCatalogAudit() {
6157
+ const items = await loadCatalog(process.cwd());
6158
+ const failures = auditForbiddenTags(items);
6159
+ if (failures.length) {
6160
+ failures.forEach((f) => error(f));
6161
+ process.exitCode = 1;
6162
+ return;
6163
+ }
6164
+ log("Catalog audit passed.");
6165
+ }
6166
+
6167
+ // src/commands/catalog-bundle.ts
6168
+ init_constants();
6169
+ init_ingest_catalog();
6170
+ init_manifest_format();
6171
+ init_path_safety();
6172
+ import os2 from "node:os";
6173
+ import path31 from "node:path";
6174
+ import fs28 from "fs-extra";
6175
+ import * as tar from "tar";
6176
+ init_fs();
6177
+ init_logger();
6178
+ var BUNDLE_META_FILE = "bundle.json";
6179
+ var MANIFEST_FILE = "manifest.json";
6180
+ var SUPERPOWERS_SHARED_ITEM = {
6181
+ id: "haus.superpowers-shared",
6182
+ type: "skill",
6183
+ path: SUPERPOWERS_SHARED_CATALOG_REL
6184
+ };
6185
+ async function runBundleExport(options) {
6186
+ const out = options.out;
6187
+ if (!out) {
6188
+ error("Specify a destination with --out <file.tgz>");
6189
+ process.exitCode = 1;
6190
+ return;
6191
+ }
6192
+ const cacheDir = getCacheDir();
6193
+ const manifest = await readJson(path31.join(cacheDir, MANIFEST_FILE));
6194
+ if (!manifest?.items?.length) {
6195
+ error("Catalog cache is empty \u2014 run `haus update` on a connected machine first.");
6196
+ process.exitCode = 1;
6197
+ return;
6198
+ }
6199
+ const meta = {
6200
+ version: typeof manifest.version === "string" ? manifest.version : null,
6201
+ catalogRef: typeof manifest.catalogRef === "string" ? manifest.catalogRef : null,
6202
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
6203
+ };
6204
+ await writeBundleAsTar(cacheDir, meta, path31.resolve(out));
6205
+ log(
6206
+ `Exported ${manifest.items.length} catalog item(s) to ${out}${meta.catalogRef ? ` (${meta.catalogRef})` : ""}.`
6207
+ );
6208
+ log("Move it to the disconnected machine and run `haus catalog bundle import <file>`.");
6209
+ }
6210
+ async function writeBundleAsTar(cacheDir, meta, dest) {
6211
+ const bundleMetaPath = path31.join(cacheDir, BUNDLE_META_FILE);
6212
+ const previousBundleMeta = await fs28.pathExists(bundleMetaPath) ? await fs28.readFile(bundleMetaPath, "utf8") : null;
6213
+ await writeJson(bundleMetaPath, meta);
6214
+ try {
6215
+ await fs28.ensureDir(path31.dirname(dest));
6216
+ await tar.create({ gzip: true, cwd: cacheDir, file: dest }, ["."]);
6217
+ } finally {
6218
+ if (previousBundleMeta === null) await fs28.remove(bundleMetaPath);
6219
+ else await fs28.writeFile(bundleMetaPath, previousBundleMeta, "utf8");
6220
+ }
6221
+ }
6222
+ function isMarkdownFile(relOrAbsPath) {
6223
+ return relOrAbsPath.toLowerCase().endsWith(".md");
6224
+ }
6225
+ async function listAllFilesUnfiltered(root, current = root) {
6226
+ const entries = await fs28.readdir(current, { withFileTypes: true });
6227
+ const out = [];
6228
+ for (const entry of entries) {
6229
+ if (entry.isSymbolicLink()) continue;
6230
+ const abs = path31.join(current, entry.name);
6231
+ if (entry.isDirectory()) {
6232
+ out.push(...await listAllFilesUnfiltered(root, abs));
6233
+ } else if (entry.isFile()) {
6234
+ out.push(path31.relative(root, abs).split(path31.sep).join("/"));
6235
+ }
6236
+ }
6237
+ return out;
6238
+ }
6239
+ async function validateStagedItem(item, absPath) {
6240
+ const stat = await fs28.stat(absPath).catch(() => null);
6241
+ if (!stat) return { ok: true };
6242
+ if (stat.isFile()) {
6243
+ const content2 = await fs28.readFile(absPath, "utf8");
6244
+ return validateCatalogItem(item, content2);
6245
+ }
6246
+ const files = await listAllFilesUnfiltered(absPath);
6247
+ for (const rel of files) {
6248
+ if (!isMarkdownFile(rel)) continue;
6249
+ const content2 = await fs28.readFile(path31.join(absPath, rel), "utf8");
6250
+ const verdict = validateCatalogItem(item, content2);
6251
+ if (!verdict.ok) return verdict;
6252
+ }
6253
+ return { ok: true };
6254
+ }
6255
+ async function validateStagedItems(staging, items) {
6256
+ let rejected = 0;
6257
+ const accepted = [];
6258
+ const contentlessIds = /* @__PURE__ */ new Set();
6259
+ const coveredPaths = [];
6260
+ for (const item of [...items, SUPERPOWERS_SHARED_ITEM]) {
6261
+ const isSynthetic = item === SUPERPOWERS_SHARED_ITEM;
6262
+ const contentPath = safeJoin(staging, item.path);
6263
+ if (!contentPath) {
6264
+ if (!isSynthetic) {
6265
+ warn(`Refusing item with an unsafe path: ${item.id} (${item.path})`);
6266
+ rejected += 1;
6267
+ }
6268
+ continue;
6269
+ }
6270
+ coveredPaths.push(item.path);
6271
+ if (!await fs28.pathExists(contentPath)) {
6272
+ if (!isSynthetic) {
6273
+ accepted.push(item.id);
6274
+ contentlessIds.add(item.id);
6275
+ }
6276
+ continue;
6277
+ }
6278
+ const verdict = await validateStagedItem(item, contentPath);
6279
+ if (!verdict.ok) {
6280
+ warn(`Rejected ${item.id}: ${verdict.reason}`);
6281
+ await fs28.remove(contentPath);
6282
+ if (!isSynthetic) rejected += 1;
6283
+ continue;
6284
+ }
6285
+ if (!isSynthetic) accepted.push(item.id);
6286
+ }
6287
+ const stagedFiles = await listAllFilesUnfiltered(staging);
6288
+ for (const rel of stagedFiles) {
6289
+ if (rel === MANIFEST_FILE || rel === BUNDLE_META_FILE) continue;
6290
+ const covered = coveredPaths.some((p) => rel === p || rel.startsWith(`${p}/`));
6291
+ if (!covered) {
6292
+ warn(`Refusing staged file outside any catalog item's path: ${rel}`);
6293
+ await fs28.remove(path31.join(staging, rel));
5989
6294
  }
5990
- olderThanDays = n;
5991
6295
  }
5992
- let keep;
5993
- if (opts.keep !== void 0) {
5994
- const n = Number(opts.keep);
5995
- if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {
5996
- const message2 = `Invalid --keep value "${opts.keep}" \u2014 expected a non-negative integer.`;
5997
- if (json) log(JSON.stringify({ ok: false, error: message2 }));
5998
- error(message2);
5999
- process.exitCode = 1;
6000
- return;
6296
+ await pruneEmptyDirs(staging);
6297
+ return { accepted, rejected, contentlessIds };
6298
+ }
6299
+ async function pruneEmptyDirs(root, dir = root) {
6300
+ const entries = await fs28.readdir(dir, { withFileTypes: true });
6301
+ for (const entry of entries) {
6302
+ if (entry.isDirectory()) {
6303
+ await pruneEmptyDirs(root, path31.join(dir, entry.name));
6001
6304
  }
6002
- keep = n;
6003
6305
  }
6004
- const result = await runPrune(root, {
6005
- olderThanDays,
6006
- keep,
6007
- yes: Boolean(opts.yes),
6008
- json,
6009
- dryRun: Boolean(opts.dryRun)
6010
- });
6011
- if (json && !result.refused) log(JSON.stringify(result, null, 2));
6012
- if (!result.ok) process.exitCode = 1;
6306
+ if (dir !== root && (await fs28.readdir(dir)).length === 0) {
6307
+ await fs28.remove(dir);
6308
+ }
6013
6309
  }
6014
-
6015
- // src/commands/catalog-audit.ts
6016
- init_validation_rules2();
6017
- init_logger();
6018
- function auditForbiddenTags(items) {
6019
- const failures = [];
6020
- const forbidden = new Set(FORBIDDEN_TAGS.map((w) => w.toLowerCase()));
6021
- for (const item of items) {
6022
- for (const tag of Array.isArray(item.tags) ? item.tags : []) {
6023
- if (forbidden.has(tag.toLowerCase())) failures.push(`${item.id} has unsupported tag ${tag}`);
6310
+ async function stripSymlinks(dir) {
6311
+ const removed = [];
6312
+ const entries = await fs28.readdir(dir, { withFileTypes: true });
6313
+ for (const entry of entries) {
6314
+ const abs = path31.join(dir, entry.name);
6315
+ if (entry.isSymbolicLink()) {
6316
+ await fs28.remove(abs);
6317
+ removed.push(abs);
6318
+ continue;
6024
6319
  }
6025
- for (const token of item.id.toLowerCase().split(/[^a-z0-9+]+/)) {
6026
- if (forbidden.has(token)) failures.push(`${item.id} has unsupported tag ${token}`);
6320
+ if (entry.isDirectory()) {
6321
+ removed.push(...await stripSymlinks(abs));
6027
6322
  }
6028
6323
  }
6029
- return failures;
6324
+ return removed;
6030
6325
  }
6031
- async function runCatalogAudit() {
6032
- const items = await loadCatalog(process.cwd());
6033
- const failures = auditForbiddenTags(items);
6034
- if (failures.length) {
6035
- failures.forEach((f) => error(f));
6326
+ async function runBundleImport(file, options = {}) {
6327
+ const dryRun = Boolean(options.dryRun);
6328
+ const archive = path31.resolve(file);
6329
+ if (!await fs28.pathExists(archive)) {
6330
+ error(`No such bundle: ${file}`);
6036
6331
  process.exitCode = 1;
6037
6332
  return;
6038
6333
  }
6039
- log("Catalog audit passed.");
6334
+ const staging = await fs28.mkdtemp(path31.join(os2.tmpdir(), "haus-catalog-bundle-import-"));
6335
+ try {
6336
+ await tar.extract({
6337
+ file: archive,
6338
+ cwd: staging,
6339
+ // Refuse anything that would land outside the staging dir. node-tar's own
6340
+ // extraction already refuses `..`, absolute paths, and symlink escapes by
6341
+ // default (preservePaths is left unset) — this filter is a second, independent
6342
+ // check using the same path-safety module the network sync uses. Both this
6343
+ // filter's refusals and node-tar's own built-in ones are logged — a tampered
6344
+ // bundle's dropped entries must be visible, not silently absorbed.
6345
+ filter: (entryPath) => {
6346
+ const safe = isSafeCatalogPath(entryPath);
6347
+ if (!safe) warn(`Refusing unsafe tar entry path: ${entryPath}`);
6348
+ return safe;
6349
+ },
6350
+ onwarn: (code, message2) => warn(`tar: ${code}: ${message2}`)
6351
+ });
6352
+ const strippedSymlinks = await stripSymlinks(staging);
6353
+ for (const abs of strippedSymlinks) {
6354
+ warn(`Refusing symlink in bundle: ${path31.relative(staging, abs)}`);
6355
+ }
6356
+ const manifest = await readJson(path31.join(staging, MANIFEST_FILE));
6357
+ if (!manifest?.items?.length) {
6358
+ error("Bundle contains no catalog manifest \u2014 refusing to import.");
6359
+ process.exitCode = 1;
6360
+ return;
6361
+ }
6362
+ assertSupportedManifestFormat(manifest.manifestFormat, "The imported catalog bundle");
6363
+ const { accepted, rejected, contentlessIds } = await validateStagedItems(
6364
+ staging,
6365
+ manifest.items
6366
+ );
6367
+ const meta = await readJson(path31.join(staging, BUNDLE_META_FILE));
6368
+ const label = meta?.catalogRef ?? (meta?.version ? `v${meta.version}` : "unknown ref");
6369
+ const installedCount = accepted.length;
6370
+ if (dryRun) {
6371
+ log(
6372
+ dryRunPrefix(
6373
+ true,
6374
+ `would import ${installedCount} catalog item(s) from ${label} into ${getCacheDir()}`
6375
+ )
6376
+ );
6377
+ accepted.forEach(
6378
+ (id) => log(
6379
+ dryRunPrefix(
6380
+ true,
6381
+ contentlessIds.has(id) ? ` ${id} (declared only, no content in bundle)` : ` ${id}`
6382
+ )
6383
+ )
6384
+ );
6385
+ if (rejected > 0) {
6386
+ log(dryRunPrefix(true, `${rejected} item(s) would be rejected by validation (see above)`));
6387
+ }
6388
+ return;
6389
+ }
6390
+ await fs28.ensureDir(getCacheDir());
6391
+ for (const entry of await fs28.readdir(staging)) {
6392
+ await fs28.copy(path31.join(staging, entry), path31.join(getCacheDir(), entry), {
6393
+ overwrite: true,
6394
+ errorOnExist: false
6395
+ });
6396
+ }
6397
+ log(
6398
+ `Imported ${installedCount} catalog item(s) from ${label}.` + (rejected > 0 ? ` ${rejected} rejected by validation.` : "")
6399
+ );
6400
+ log("Run `haus apply --write` to install them.");
6401
+ } catch (err) {
6402
+ if (err instanceof UnsupportedManifestFormatError) {
6403
+ error(err.message);
6404
+ process.exitCode = 1;
6405
+ return;
6406
+ }
6407
+ throw err;
6408
+ } finally {
6409
+ await fs28.remove(staging);
6410
+ }
6040
6411
  }
6041
6412
 
6042
6413
  // src/commands/doctor.ts
6043
6414
  import path45 from "node:path";
6044
- import fs35 from "fs-extra";
6415
+ import fs37 from "fs-extra";
6045
6416
  init_manifest_format();
6046
6417
 
6047
6418
  // src/claude/former-install-orphans.ts
6048
- import fs27 from "fs-extra";
6419
+ import fs29 from "fs-extra";
6049
6420
  init_path_safety();
6050
6421
  init_paths();
6051
6422
  var toPosix = (value) => value.replace(/\\/g, "/");
@@ -6069,7 +6440,7 @@ async function findOrphanedFormerInstalls(root, items, lockRows) {
6069
6440
  const projectRel = `.claude/${relPath}`;
6070
6441
  if (trackedPaths.has(projectRel) || reported.has(projectRel)) continue;
6071
6442
  const abs = safeJoin(claudePath(root), relPath);
6072
- if (abs === null || !await fs27.pathExists(abs)) continue;
6443
+ if (abs === null || !await fs29.pathExists(abs)) continue;
6073
6444
  reported.add(projectRel);
6074
6445
  orphans.push({ formerId: entry.formerId, currentId: entry.currentId, relPath: projectRel });
6075
6446
  }
@@ -6080,7 +6451,7 @@ async function findOrphanedFormerInstalls(root, items, lockRows) {
6080
6451
  init_paths();
6081
6452
  import path32 from "node:path";
6082
6453
  import fg4 from "fast-glob";
6083
- import fs28 from "fs-extra";
6454
+ import fs30 from "fs-extra";
6084
6455
  var REFERENCE_PATTERN = /\b([a-z][a-z0-9]*(?:-[a-z0-9]+)+):([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\b(?!:\/\/)/g;
6085
6456
  function stripFencedCodeBlocks(text) {
6086
6457
  return text.replace(/```[\s\S]*?```/g, (block) => block.replace(/[^\n]/g, " "));
@@ -6088,15 +6459,15 @@ function stripFencedCodeBlocks(text) {
6088
6459
  async function findUnresolvedSkillReferences(root, options = {}) {
6089
6460
  const resolvableNamespaces = new Set(options.pluginNamespaces ?? []);
6090
6461
  const skillsRoot = claudePath(root, "skills");
6091
- if (!await fs28.pathExists(skillsRoot)) return [];
6092
- const skillDirs = (await fs28.readdir(skillsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && entry.name !== "shared").map((entry) => entry.name);
6462
+ if (!await fs30.pathExists(skillsRoot)) return [];
6463
+ const skillDirs = (await fs30.readdir(skillsRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory() && entry.name !== "shared").map((entry) => entry.name);
6093
6464
  const installedSkillNames = new Set(skillDirs);
6094
6465
  const files = await fg4("*/**/*.md", { cwd: skillsRoot, onlyFiles: true, dot: true });
6095
6466
  const issues = [];
6096
6467
  for (const rel of files) {
6097
6468
  if (rel.startsWith("shared/")) continue;
6098
6469
  const abs = path32.join(skillsRoot, rel);
6099
- const raw = await fs28.readFile(abs, "utf8");
6470
+ const raw = await fs30.readFile(abs, "utf8");
6100
6471
  const lines = stripFencedCodeBlocks(raw).split("\n");
6101
6472
  lines.forEach((line2, idx) => {
6102
6473
  for (const match of line2.matchAll(REFERENCE_PATTERN)) {
@@ -6119,13 +6490,13 @@ async function findUnresolvedSkillReferences(root, options = {}) {
6119
6490
  // src/decisions/doctor.ts
6120
6491
  init_fs();
6121
6492
  import path37 from "node:path";
6122
- import fs32 from "fs-extra";
6493
+ import fs34 from "fs-extra";
6123
6494
 
6124
6495
  // src/decisions/check.ts
6125
6496
  init_fs();
6126
6497
  init_paths();
6127
6498
  import path36 from "node:path";
6128
- import fs31 from "fs-extra";
6499
+ import fs33 from "fs-extra";
6129
6500
 
6130
6501
  // src/decisions/dep-bump.ts
6131
6502
  var DEP_SECTIONS = [
@@ -6318,12 +6689,12 @@ function firstMatchingRegex(filePath, patterns) {
6318
6689
 
6319
6690
  // src/decisions/paths.ts
6320
6691
  import path34 from "node:path";
6321
- import fs29 from "fs-extra";
6692
+ import fs31 from "fs-extra";
6322
6693
  async function resolveDecisionsDir(root) {
6323
6694
  const primary = path34.join(root, DECISIONS_TRIGGERS.decisionsDir);
6324
- if (await fs29.pathExists(primary)) return primary;
6695
+ if (await fs31.pathExists(primary)) return primary;
6325
6696
  const legacy = path34.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
6326
- if (await fs29.pathExists(legacy)) return legacy;
6697
+ if (await fs31.pathExists(legacy)) return legacy;
6327
6698
  return primary;
6328
6699
  }
6329
6700
  function relativeDecisionPath(decisionsDir, root) {
@@ -6333,7 +6704,7 @@ function relativeDecisionPath(decisionsDir, root) {
6333
6704
  // src/decisions/validate.ts
6334
6705
  init_fs();
6335
6706
  import path35 from "node:path";
6336
- import fs30 from "fs-extra";
6707
+ import fs32 from "fs-extra";
6337
6708
  function validateDecisionContent(content2) {
6338
6709
  const errors = [];
6339
6710
  for (const section of DECISIONS_TRIGGERS.requiredSections) {
@@ -6363,7 +6734,7 @@ function decisionNumberFromFile(name) {
6363
6734
  }
6364
6735
  async function validateIndexSync(decisionsDir, decisionFiles) {
6365
6736
  const readmePath = path35.join(decisionsDir, "README.md");
6366
- if (!await fs30.pathExists(readmePath)) {
6737
+ if (!await fs32.pathExists(readmePath)) {
6367
6738
  return ["docs/decisions/README.md is missing"];
6368
6739
  }
6369
6740
  const readme = await readText(readmePath) ?? "";
@@ -6535,8 +6906,8 @@ ${opts.commitMessages ?? ""}`;
6535
6906
  changedFiles: stats.files
6536
6907
  });
6537
6908
  if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
6538
- const legacyExists = await fs31.pathExists(path36.join(root, legacyRel));
6539
- if (legacyExists && !await fs31.pathExists(decisionsDir)) {
6909
+ const legacyExists = await fs33.pathExists(path36.join(root, legacyRel));
6910
+ if (legacyExists && !await fs33.pathExists(decisionsDir)) {
6540
6911
  satisfactionErrors = await checkSatisfaction({
6541
6912
  root,
6542
6913
  decisionsRel: legacyRel,
@@ -6558,8 +6929,8 @@ ${opts.commitMessages ?? ""}`;
6558
6929
  return { triggered: true, satisfied: false, reasons, securityTriggered };
6559
6930
  }
6560
6931
  async function listDecisionNumbers(decisionsDir) {
6561
- if (!await fs31.pathExists(decisionsDir)) return [];
6562
- const entries = await fs31.readdir(decisionsDir);
6932
+ if (!await fs33.pathExists(decisionsDir)) return [];
6933
+ const entries = await fs33.readdir(decisionsDir);
6563
6934
  return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
6564
6935
  }
6565
6936
 
@@ -6568,8 +6939,8 @@ async function auditDecisionsLayout(root) {
6568
6939
  const findings = [];
6569
6940
  const primary = path37.join(root, DECISIONS_TRIGGERS.decisionsDir);
6570
6941
  const legacy = path37.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
6571
- const hasPrimary = await fs32.pathExists(primary);
6572
- const hasLegacy = await fs32.pathExists(legacy);
6942
+ const hasPrimary = await fs34.pathExists(primary);
6943
+ const hasLegacy = await fs34.pathExists(legacy);
6573
6944
  if (!hasPrimary && hasLegacy) {
6574
6945
  findings.push({
6575
6946
  level: "advisory",
@@ -6583,7 +6954,7 @@ async function auditDecisionsLayout(root) {
6583
6954
  const decisionsDir = await resolveDecisionsDir(root);
6584
6955
  const rel = relativeDecisionPath(decisionsDir, root);
6585
6956
  const readmePath = path37.join(decisionsDir, "README.md");
6586
- if (!await fs32.pathExists(readmePath)) {
6957
+ if (!await fs34.pathExists(readmePath)) {
6587
6958
  findings.push({
6588
6959
  level: "advisory",
6589
6960
  line: `- DECISIONS: missing ${rel}/README.md index`,
@@ -6593,7 +6964,7 @@ async function auditDecisionsLayout(root) {
6593
6964
  return findings;
6594
6965
  }
6595
6966
  const readme = await readText(readmePath);
6596
- const entries = (await fs32.readdir(decisionsDir)).filter(
6967
+ const entries = (await fs34.readdir(decisionsDir)).filter(
6597
6968
  (f) => f.endsWith(".md") && f !== "README.md"
6598
6969
  );
6599
6970
  for (const file of entries) {
@@ -6642,7 +7013,7 @@ init_fs();
6642
7013
  init_logger();
6643
7014
  init_paths();
6644
7015
  import path38 from "node:path";
6645
- import fs33 from "fs-extra";
7016
+ import fs35 from "fs-extra";
6646
7017
  function manifestPathIn(configDir) {
6647
7018
  return path38.join(configDir, "haus", "install-manifest.json");
6648
7019
  }
@@ -6672,9 +7043,9 @@ async function pruneVacatedDirs(root, vacatedFiles) {
6672
7043
  async function destinationWritable(dir) {
6673
7044
  let candidate = path38.resolve(dir);
6674
7045
  for (; ; ) {
6675
- if (await fs33.pathExists(candidate)) {
7046
+ if (await fs35.pathExists(candidate)) {
6676
7047
  try {
6677
- await fs33.access(candidate, fs33.constants.W_OK);
7048
+ await fs35.access(candidate, fs35.constants.W_OK);
6678
7049
  return true;
6679
7050
  } catch (err) {
6680
7051
  return err instanceof Error ? err.message : String(err);
@@ -6706,7 +7077,7 @@ async function migrateGlobalConfigDir(options = {}) {
6706
7077
  if (!legacy || !Array.isArray(legacy.files)) {
6707
7078
  return { ...base, reason: "no install manifest under ~/.claude" };
6708
7079
  }
6709
- if (await fs33.pathExists(manifestPathIn(to))) {
7080
+ if (await fs35.pathExists(manifestPathIn(to))) {
6710
7081
  return { ...base, reason: "an install manifest already exists in the resolved config dir" };
6711
7082
  }
6712
7083
  const writable = await destinationWritable(to);
@@ -6729,14 +7100,14 @@ async function migrateGlobalConfigDir(options = {}) {
6729
7100
  continue;
6730
7101
  }
6731
7102
  const newDest = path38.join(to, rel);
6732
- if (!await fs33.pathExists(entry.destPath)) {
7103
+ if (!await fs35.pathExists(entry.destPath)) {
6733
7104
  rewritten.push({ ...entry, destPath: newDest });
6734
7105
  continue;
6735
7106
  }
6736
- if (await fs33.pathExists(newDest)) {
7107
+ if (await fs35.pathExists(newDest)) {
6737
7108
  if (await sameContent(entry.destPath, newDest)) {
6738
7109
  if (!dryRun) {
6739
- await fs33.remove(entry.destPath);
7110
+ await fs35.remove(entry.destPath);
6740
7111
  vacated.push(entry.destPath);
6741
7112
  }
6742
7113
  moved.push(newDest);
@@ -6753,7 +7124,7 @@ async function migrateGlobalConfigDir(options = {}) {
6753
7124
  continue;
6754
7125
  }
6755
7126
  try {
6756
- await fs33.move(entry.destPath, newDest);
7127
+ await fs35.move(entry.destPath, newDest);
6757
7128
  vacated.push(entry.destPath);
6758
7129
  moved.push(newDest);
6759
7130
  rewritten.push({ ...entry, destPath: newDest });
@@ -6778,7 +7149,7 @@ async function migrateGlobalConfigDir(options = {}) {
6778
7149
  }
6779
7150
  try {
6780
7151
  await writeJson(manifestPathIn(to), { ...legacy, files: rewritten });
6781
- await fs33.remove(legacyManifestPath);
7152
+ await fs35.remove(legacyManifestPath);
6782
7153
  vacated.push(legacyManifestPath);
6783
7154
  } catch (err) {
6784
7155
  return {
@@ -6824,7 +7195,7 @@ function printConfigDirMigration(result) {
6824
7195
 
6825
7196
  // src/scanner/read-context.ts
6826
7197
  import path44 from "node:path";
6827
- import fs34 from "fs-extra";
7198
+ import fs36 from "fs-extra";
6828
7199
 
6829
7200
  // src/commands/workspace/config.ts
6830
7201
  init_fs();
@@ -7408,10 +7779,10 @@ async function isCacheFresh(root, cachePath) {
7408
7779
  path44.join(root, WORKSPACE_FILE),
7409
7780
  path44.join(root, REPOS_MANIFEST_FILE)
7410
7781
  ];
7411
- const cacheStat = await fs34.stat(cachePath);
7782
+ const cacheStat = await fs36.stat(cachePath);
7412
7783
  for (const markerPath of markerPaths) {
7413
- if (!await fs34.pathExists(markerPath)) continue;
7414
- const markerStat = await fs34.stat(markerPath);
7784
+ if (!await fs36.pathExists(markerPath)) continue;
7785
+ const markerStat = await fs36.stat(markerPath);
7415
7786
  if (markerStat.mtimeMs > cacheStat.mtimeMs) return false;
7416
7787
  }
7417
7788
  return true;
@@ -7425,11 +7796,15 @@ async function readContextOrScan(root) {
7425
7796
  }
7426
7797
 
7427
7798
  // src/update/npm-version.ts
7799
+ init_offline();
7428
7800
  var NPM_PACKAGE_NAME = "@haus-tech/haus-workflow";
7429
7801
  function isTestMode2() {
7430
7802
  return process.env["HAUS_TEST_MODE"] === "1" || process.env["NODE_ENV"] === "test";
7431
7803
  }
7432
7804
  async function fetchNpmVersionStatus(currentVersion) {
7805
+ if (isOffline()) {
7806
+ return { current: currentVersion, latest: null, updateAvailable: false };
7807
+ }
7433
7808
  if (isTestMode2() && process.env["HAUS_SKIP_NPM_CHECK"]) {
7434
7809
  return { current: currentVersion, latest: null, updateAvailable: false };
7435
7810
  }
@@ -7568,7 +7943,7 @@ async function runDoctor(options) {
7568
7943
  const resolved = path45.resolve(root, target);
7569
7944
  const relFromRoot = path45.relative(root, resolved);
7570
7945
  const escapesRoot = relFromRoot === ".." || relFromRoot.startsWith(`..${path45.sep}`) || path45.isAbsolute(relFromRoot);
7571
- if (escapesRoot || !await fs35.pathExists(resolved)) {
7946
+ if (escapesRoot || !await fs37.pathExists(resolved)) {
7572
7947
  flag(
7573
7948
  `- CLAUDE.md import: @${target} does not resolve (run \`haus apply --write\`)`,
7574
7949
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -7586,7 +7961,7 @@ async function runDoctor(options) {
7586
7961
  );
7587
7962
  }
7588
7963
  const workflowPath = hausPath(root, "WORKFLOW.md");
7589
- const workflowExists = await fs35.pathExists(workflowPath);
7964
+ const workflowExists = await fs37.pathExists(workflowPath);
7590
7965
  if (!workflowExists) {
7591
7966
  flag(
7592
7967
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -7609,7 +7984,7 @@ async function runDoctor(options) {
7609
7984
  "templates",
7610
7985
  "agentic-workflow-standard.md"
7611
7986
  );
7612
- const templatePath = await fs35.pathExists(cachePath) ? cachePath : bundledPath;
7987
+ const templatePath = await fs37.pathExists(cachePath) ? cachePath : bundledPath;
7613
7988
  const templateContent = await readText(templatePath);
7614
7989
  const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
7615
7990
  if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
@@ -7637,7 +8012,7 @@ async function runDoctor(options) {
7637
8012
  }
7638
8013
  }
7639
8014
  const workflowConfigPath = hausPath(root, "workflow-config.md");
7640
- const workflowConfigExists = await fs35.pathExists(workflowConfigPath);
8015
+ const workflowConfigExists = await fs37.pathExists(workflowConfigPath);
7641
8016
  if (!workflowConfigExists) {
7642
8017
  flag(
7643
8018
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -7645,7 +8020,7 @@ async function runDoctor(options) {
7645
8020
  "haus apply --write"
7646
8021
  );
7647
8022
  } else {
7648
- const cfg = await fs35.readFile(workflowConfigPath, "utf8");
8023
+ const cfg = await fs37.readFile(workflowConfigPath, "utf8");
7649
8024
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
7650
8025
  if (unfilled > 0) {
7651
8026
  suggest(
@@ -7699,8 +8074,10 @@ async function runDoctor(options) {
7699
8074
  else suggest(finding.line, finding.sentence, finding.fix);
7700
8075
  }
7701
8076
  let catalogItems = null;
8077
+ let catalogCtx = null;
7702
8078
  try {
7703
- catalogItems = await loadCatalog(root);
8079
+ catalogCtx = await loadCatalogContext(root);
8080
+ catalogItems = catalogCtx.items;
7704
8081
  } catch (err) {
7705
8082
  if (!(err instanceof UnsupportedManifestFormatError)) throw err;
7706
8083
  flag(
@@ -7793,6 +8170,31 @@ async function runDoctor(options) {
7793
8170
  );
7794
8171
  }
7795
8172
  }
8173
+ let catalogTier;
8174
+ let catalogVersion;
8175
+ let installableItems;
8176
+ if (catalogCtx !== null) {
8177
+ const { installable } = await countInstallableItems(catalogCtx);
8178
+ catalogTier = catalogCtx.source;
8179
+ catalogVersion = catalogCtx.version;
8180
+ installableItems = installable;
8181
+ const tierLabel = `${catalogTier}${catalogVersion ? ` (v${catalogVersion})` : ""}`;
8182
+ if (catalogTier === "bundled") {
8183
+ suggest(
8184
+ `- CATALOG TIER: ${tierLabel} \u2014 frozen at publish time, metadata only (${installableItems} installable item(s))`,
8185
+ `Catalog is being served from the snapshot bundled with this CLI (${tierLabel}), frozen at publish time and carrying metadata only \u2014 ${installableItems} of its items have installable content, so \`haus apply\` will write core files only`,
8186
+ "haus update (with network access), or `haus catalog bundle import` on a disconnected machine"
8187
+ );
8188
+ } else if (installableItems === 0) {
8189
+ suggest(
8190
+ `- CATALOG TIER: ${tierLabel} \u2014 0 installable items (\`haus apply\` will write core files only)`,
8191
+ `The ${catalogTier} catalog tier has 0 installable items`,
8192
+ "haus update (or `haus catalog bundle import` on a disconnected machine)"
8193
+ );
8194
+ } else {
8195
+ ok(`- CATALOG TIER: ${tierLabel} \u2014 ${installableItems} installable item(s)`);
8196
+ }
8197
+ }
7796
8198
  const cacheAgeMs = await getCacheManifestAge();
7797
8199
  if (cacheAgeMs === null) {
7798
8200
  suggest(
@@ -7886,7 +8288,7 @@ async function runDoctor(options) {
7886
8288
  let onDisk = false;
7887
8289
  for (const rel of row.paths ?? []) {
7888
8290
  const abs = resolveContainedPath(root, rel);
7889
- if (abs !== null && await fs35.pathExists(abs)) {
8291
+ if (abs !== null && await fs37.pathExists(abs)) {
7890
8292
  onDisk = true;
7891
8293
  break;
7892
8294
  }
@@ -7918,7 +8320,21 @@ async function runDoctor(options) {
7918
8320
  }
7919
8321
  if (attention.length > 0) process.exitCode = 1;
7920
8322
  if (options?.json) {
7921
- log(JSON.stringify({ ok: attention.length === 0, attention, suggestions, detail }, null, 2));
8323
+ log(
8324
+ JSON.stringify(
8325
+ {
8326
+ ok: attention.length === 0,
8327
+ attention,
8328
+ suggestions,
8329
+ detail,
8330
+ catalogTier,
8331
+ catalogVersion,
8332
+ installableItems
8333
+ },
8334
+ null,
8335
+ 2
8336
+ )
8337
+ );
7922
8338
  return;
7923
8339
  }
7924
8340
  if (attention.length === 0) {
@@ -7943,11 +8359,11 @@ import path48 from "node:path";
7943
8359
  init_github_rate_limit();
7944
8360
 
7945
8361
  // src/claude/refresh-project.ts
7946
- import fs36 from "fs-extra";
8362
+ import fs38 from "fs-extra";
7947
8363
  init_fs();
7948
8364
  init_paths();
7949
8365
  async function isHausProject(root) {
7950
- if (await fs36.pathExists(hausPath(root, "recommendation.json"))) return true;
8366
+ if (await fs38.pathExists(hausPath(root, "recommendation.json"))) return true;
7951
8367
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
7952
8368
  if (result.status === "ok") return result.value._haus != null;
7953
8369
  return false;
@@ -7964,7 +8380,7 @@ async function refreshProjectApply(root) {
7964
8380
  // src/install/apply.ts
7965
8381
  import crypto2 from "node:crypto";
7966
8382
  import path46 from "node:path";
7967
- import fs37 from "fs-extra";
8383
+ import fs39 from "fs-extra";
7968
8384
  init_fs();
7969
8385
  init_logger();
7970
8386
  init_paths();
@@ -8053,7 +8469,7 @@ function hashContent(content2) {
8053
8469
  }
8054
8470
  function destFileMissing(destPath) {
8055
8471
  try {
8056
- return !fs37.statSync(destPath).isFile();
8472
+ return !fs39.statSync(destPath).isFile();
8057
8473
  } catch {
8058
8474
  return true;
8059
8475
  }
@@ -8061,7 +8477,7 @@ function destFileMissing(destPath) {
8061
8477
  function sourceVersion() {
8062
8478
  try {
8063
8479
  const pkgPath = path46.join(packageRoot(), "package.json");
8064
- const pkg = JSON.parse(fs37.readFileSync(pkgPath, "utf8"));
8480
+ const pkg = JSON.parse(fs39.readFileSync(pkgPath, "utf8"));
8065
8481
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
8066
8482
  } catch {
8067
8483
  return "haus@0.0.0";
@@ -8073,10 +8489,10 @@ function globalSrcDir() {
8073
8489
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
8074
8490
  const skillDir = path46.join(skillsDir, skillName);
8075
8491
  const entries = [];
8076
- for (const rel of fs37.readdirSync(skillDir, { recursive: true })) {
8492
+ for (const rel of fs39.readdirSync(skillDir, { recursive: true })) {
8077
8493
  if (rel === "SKILL.md") continue;
8078
8494
  const abs = path46.join(skillDir, rel);
8079
- if (!fs37.statSync(abs).isFile()) continue;
8495
+ if (!fs39.statSync(abs).isFile()) continue;
8080
8496
  const posixRel = rel.split(path46.sep).join("/");
8081
8497
  entries.push({
8082
8498
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
@@ -8089,10 +8505,10 @@ function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
8089
8505
  function collectSourceFiles(srcDir, claudeDir) {
8090
8506
  const entries = [];
8091
8507
  const skillsDir = path46.join(srcDir, "skills");
8092
- if (fs37.pathExistsSync(skillsDir)) {
8093
- for (const skillName of fs37.readdirSync(skillsDir)) {
8508
+ if (fs39.pathExistsSync(skillsDir)) {
8509
+ for (const skillName of fs39.readdirSync(skillsDir)) {
8094
8510
  const skillFile = path46.join(skillsDir, skillName, "SKILL.md");
8095
- if (fs37.pathExistsSync(skillFile)) {
8511
+ if (fs39.pathExistsSync(skillFile)) {
8096
8512
  entries.push({
8097
8513
  stableId: `skill.${skillName}`,
8098
8514
  srcRelPath: path46.join("library", "global", "skills", skillName, "SKILL.md"),
@@ -8103,8 +8519,8 @@ function collectSourceFiles(srcDir, claudeDir) {
8103
8519
  }
8104
8520
  }
8105
8521
  const commandsDir = path46.join(srcDir, "commands");
8106
- if (fs37.pathExistsSync(commandsDir)) {
8107
- for (const fileName of fs37.readdirSync(commandsDir)) {
8522
+ if (fs39.pathExistsSync(commandsDir)) {
8523
+ for (const fileName of fs39.readdirSync(commandsDir)) {
8108
8524
  if (!fileName.endsWith(".md")) continue;
8109
8525
  const commandName = fileName.slice(0, -".md".length);
8110
8526
  entries.push({
@@ -8163,7 +8579,7 @@ async function applyInstall(options = {}) {
8163
8579
  }
8164
8580
  continue;
8165
8581
  }
8166
- const destExists = fs37.pathExistsSync(entry.destPath);
8582
+ const destExists = fs39.pathExistsSync(entry.destPath);
8167
8583
  if (destExists) {
8168
8584
  const currentContent = await readText(entry.destPath);
8169
8585
  if (currentContent !== void 0) {
@@ -8222,13 +8638,13 @@ async function applyInstall(options = {}) {
8222
8638
  manifestFiles.push(entry);
8223
8639
  continue;
8224
8640
  }
8225
- if (!fs37.pathExistsSync(entry.destPath)) continue;
8641
+ if (!fs39.pathExistsSync(entry.destPath)) continue;
8226
8642
  const content2 = await readText(entry.destPath);
8227
8643
  if (!content2) continue;
8228
8644
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
8229
8645
  const currentHash = hashContent(content2);
8230
8646
  if (hasHeader && currentHash === entry.hash) {
8231
- if (!dryRun) await fs37.remove(entry.destPath);
8647
+ if (!dryRun) await fs39.remove(entry.destPath);
8232
8648
  result.deleted.push(entry.destPath);
8233
8649
  } else {
8234
8650
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -8342,7 +8758,7 @@ async function backupLockfile(root) {
8342
8758
  await mkdir(backupDir, { recursive: true });
8343
8759
  let stamp = Date.now();
8344
8760
  for (; ; ) {
8345
- const dest = path47.join(backupDir, `haus.lock.${stamp}.json`);
8761
+ const dest = path47.join(backupDir, lockBackupFileName(stamp));
8346
8762
  try {
8347
8763
  await copyFile(lockPath, dest, fsConstants.COPYFILE_EXCL);
8348
8764
  return;
@@ -8823,7 +9239,7 @@ async function runClone(url, opts = {}) {
8823
9239
  // src/install/scaffold.ts
8824
9240
  init_logger();
8825
9241
  import path50 from "node:path";
8826
- import fs38 from "fs-extra";
9242
+ import fs40 from "fs-extra";
8827
9243
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8828
9244
  const scaffolded = [];
8829
9245
  const skipped = [];
@@ -8836,7 +9252,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8836
9252
  skipped.push(name);
8837
9253
  if (outcome === "skipped-exists") skippedExisting.push(name);
8838
9254
  };
8839
- const realRoot = await fs38.realpath(catalogRoot).catch(() => null);
9255
+ const realRoot = await fs40.realpath(catalogRoot).catch(() => null);
8840
9256
  for (const item of items) {
8841
9257
  if (item.type !== "config") continue;
8842
9258
  const sourcePath = path50.resolve(catalogRoot, item.path);
@@ -8846,7 +9262,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8846
9262
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
8847
9263
  continue;
8848
9264
  }
8849
- const realSource = await fs38.realpath(sourcePath).catch(() => null);
9265
+ const realSource = await fs40.realpath(sourcePath).catch(() => null);
8850
9266
  if (!realRoot || !realSource) {
8851
9267
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
8852
9268
  continue;
@@ -8856,7 +9272,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8856
9272
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
8857
9273
  continue;
8858
9274
  }
8859
- const stat = await fs38.lstat(sourcePath).catch(() => null);
9275
+ const stat = await fs40.lstat(sourcePath).catch(() => null);
8860
9276
  if (!stat) {
8861
9277
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
8862
9278
  continue;
@@ -8870,7 +9286,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8870
9286
  const dest = path50.join(projectRoot, filename);
8871
9287
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
8872
9288
  } else if (stat.isDirectory()) {
8873
- const entries = await fs38.readdir(sourcePath);
9289
+ const entries = await fs40.readdir(sourcePath);
8874
9290
  for (const entry of entries) {
8875
9291
  const src = path50.join(sourcePath, entry);
8876
9292
  const dest = path50.join(projectRoot, entry);
@@ -8882,7 +9298,7 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
8882
9298
  }
8883
9299
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
8884
9300
  const destDisplay = path50.relative(projectRoot, dest) || path50.basename(dest);
8885
- const srcStat = await fs38.lstat(src).catch(() => null);
9301
+ const srcStat = await fs40.lstat(src).catch(() => null);
8886
9302
  if (!srcStat) {
8887
9303
  warn(`Skipping ${path50.basename(src)}: source not found`);
8888
9304
  return "skipped-refused";
@@ -8891,7 +9307,7 @@ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
8891
9307
  warn(`Skipping ${path50.basename(src)}: source is a symlink`);
8892
9308
  return "skipped-refused";
8893
9309
  }
8894
- const exists = await fs38.pathExists(dest);
9310
+ const exists = await fs40.pathExists(dest);
8895
9311
  if (exists && !opts.force) {
8896
9312
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
8897
9313
  return "skipped-exists";
@@ -8901,16 +9317,16 @@ async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
8901
9317
  return "scaffolded";
8902
9318
  }
8903
9319
  if (exists && opts.force) {
8904
- await fs38.remove(dest);
9320
+ await fs40.remove(dest);
8905
9321
  }
8906
- await fs38.ensureDir(path50.dirname(dest));
8907
- await fs38.copy(src, dest, {
9322
+ await fs40.ensureDir(path50.dirname(dest));
9323
+ await fs40.copy(src, dest, {
8908
9324
  overwrite: true,
8909
9325
  dereference: false,
8910
9326
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
8911
9327
  filter: (s) => {
8912
9328
  try {
8913
- return !fs38.lstatSync(s).isSymbolicLink();
9329
+ return !fs40.lstatSync(s).isSymbolicLink();
8914
9330
  } catch {
8915
9331
  return false;
8916
9332
  }
@@ -9432,6 +9848,61 @@ async function runGuard(kind, _options) {
9432
9848
  // src/claude/setup-core.ts
9433
9849
  init_github_rate_limit();
9434
9850
 
9851
+ // src/claude/project-exclusions.ts
9852
+ import fs41 from "fs-extra";
9853
+ init_project_pin();
9854
+ init_fs();
9855
+ init_logger();
9856
+ init_paths();
9857
+ function normalizeExcludedIds(value) {
9858
+ if (!Array.isArray(value)) return [];
9859
+ return [
9860
+ ...new Set(
9861
+ value.filter((entry) => typeof entry === "string" && entry.length > 0)
9862
+ )
9863
+ ];
9864
+ }
9865
+ async function readProjectExcludedIds(root) {
9866
+ try {
9867
+ const data = await readJson(hausPath(root, PROJECT_CONFIG_FILE));
9868
+ if (!data) return [];
9869
+ return normalizeExcludedIds(data.excludedIds);
9870
+ } catch (err) {
9871
+ if (err.code === "ENOENT") return [];
9872
+ warn(
9873
+ `${PROJECT_CONFIG_REL} could not be read for excludedIds (${err instanceof Error ? err.message : String(err)}) \u2014 treating as none`
9874
+ );
9875
+ return [];
9876
+ }
9877
+ }
9878
+ function isItemExcluded(item, excluded) {
9879
+ if (excluded.has(item.id)) return true;
9880
+ return normalizeFormerIds(item.formerIds).some((formerId) => excluded.has(formerId));
9881
+ }
9882
+ async function addProjectExcludedIds(root, ids) {
9883
+ const toAdd = [...new Set(ids.filter((id) => id.length > 0))];
9884
+ if (toAdd.length === 0) return;
9885
+ const file = hausPath(root, PROJECT_CONFIG_FILE);
9886
+ let existing = {};
9887
+ if (await fs41.pathExists(file)) {
9888
+ try {
9889
+ const parsed = JSON.parse(await fs41.readFile(file, "utf8"));
9890
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
9891
+ existing = { ...parsed };
9892
+ } else {
9893
+ warn(`${PROJECT_CONFIG_REL} is not a JSON object \u2014 rewriting with excludedIds only`);
9894
+ }
9895
+ } catch (err) {
9896
+ warn(
9897
+ `${PROJECT_CONFIG_REL} could not be parsed (${err instanceof Error ? err.message : String(err)}) \u2014 rewriting with excludedIds only`
9898
+ );
9899
+ }
9900
+ }
9901
+ const merged = normalizeExcludedIds(existing.excludedIds);
9902
+ const next = [.../* @__PURE__ */ new Set([...merged, ...toAdd])].sort();
9903
+ await writeJson(file, { ...existing, excludedIds: next });
9904
+ }
9905
+
9435
9906
  // src/recommender/recommend.ts
9436
9907
  init_fs();
9437
9908
  init_paths();
@@ -9478,6 +9949,7 @@ async function readChangedFiles(root) {
9478
9949
  async function recommend(root, context, opts = {}) {
9479
9950
  const activePluginIds = opts.activePluginIds ?? [];
9480
9951
  const items = await loadCatalog(root);
9952
+ const excludedIds = await readProjectExcludedIds(root);
9481
9953
  const sources = buildSourcesReport(items);
9482
9954
  const deep = await readJson(hausPath(root, "deep-context.json")) ?? {};
9483
9955
  const scannerStacks = buildStackSet(context);
@@ -9687,6 +10159,7 @@ async function recommend(root, context, opts = {}) {
9687
10159
  }
9688
10160
  }
9689
10161
  applyCoInstallSuppression(recommended, skipped);
10162
+ applyManualExcludes(excludedIds, items, recommended, skipped);
9690
10163
  const includeWarnings = applyManualIncludes(opts.include ?? [], items, recommended, skipped);
9691
10164
  const optInEligible = buildOptInEligible(items, skipped);
9692
10165
  recommended.sort((a, b) => a.id.localeCompare(b.id));
@@ -9715,6 +10188,29 @@ function buildStackSet(context) {
9715
10188
  )
9716
10189
  );
9717
10190
  }
10191
+ function applyManualExcludes(excludedIds, items, recommended, skipped) {
10192
+ if (excludedIds.length === 0) return;
10193
+ const excluded = new Set(excludedIds);
10194
+ const byId = new Map(items.map((item) => [item.id, item]));
10195
+ for (let i = recommended.length - 1; i >= 0; i--) {
10196
+ const entry = recommended[i];
10197
+ const item = byId.get(entry.id);
10198
+ if (!item || !isItemExcluded(item, excluded)) continue;
10199
+ recommended.splice(i, 1);
10200
+ skipped.push({
10201
+ id: entry.id,
10202
+ reason: "manually excluded",
10203
+ skipReasons: [
10204
+ {
10205
+ code: "manual-exclude",
10206
+ message: "manually excluded via haus remove",
10207
+ signal: "selection:manual-exclude"
10208
+ }
10209
+ ],
10210
+ ...entry.gates ? { gates: entry.gates } : {}
10211
+ });
10212
+ }
10213
+ }
9718
10214
  function applyCoInstallSuppression(recommended, skipped) {
9719
10215
  const recommendedIds = new Set(recommended.map((r) => r.id));
9720
10216
  const rules = [
@@ -10087,7 +10583,10 @@ async function runSetupCore(root, opts) {
10087
10583
  recommendedCount: 0,
10088
10584
  warnings,
10089
10585
  hooksOk: false,
10090
- written: []
10586
+ written: [],
10587
+ // Pre-existing zero-signal guard, not the #325 catalog-install honesty check —
10588
+ // deliberately left out of scope for `catalogInstallOk` (see its doc comment).
10589
+ catalogInstallOk: true
10091
10590
  };
10092
10591
  }
10093
10592
  await writeJson(hausPath(root, "recommendation.json"), recommendation);
@@ -10116,7 +10615,8 @@ async function runSetupCore(root, opts) {
10116
10615
  recommendedCount: recommendation.recommended.length,
10117
10616
  warnings: warningLines,
10118
10617
  hooksOk,
10119
- written: []
10618
+ written: [],
10619
+ catalogInstallOk: true
10120
10620
  };
10121
10621
  const envelope = (hooksResult) => ({
10122
10622
  scan: scanResult,
@@ -10156,12 +10656,66 @@ async function runSetupCore(root, opts) {
10156
10656
  warn(formatGithubRateLimitMessage(sync.rateLimit));
10157
10657
  }
10158
10658
  }
10659
+ const isPreview = dryRun ?? false;
10660
+ const wantedIds = installableRecommendedItems(recommendation.recommended).map((item) => item.id);
10661
+ let catalogCtx = null;
10662
+ let resolvedWantedIds = wantedIds;
10663
+ if (wantedIds.length > 0) {
10664
+ catalogCtx = await loadCatalogContext(root);
10665
+ const prevLock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
10666
+ const migrations = findFormerIdMigrations(
10667
+ prevLock.filter((entry) => Boolean(entry.id)),
10668
+ catalogCtx.items
10669
+ );
10670
+ const migrationByOldId = new Map(migrations.map((m) => [m.oldId, m.newId]));
10671
+ resolvedWantedIds = wantedIds.map((id) => migrationByOldId.get(id) ?? id);
10672
+ const shortfall = await countInstallableItems(catalogCtx, resolvedWantedIds);
10673
+ const tier = `${catalogCtx.source}${catalogCtx.version ? ` (v${catalogCtx.version})` : ""}`;
10674
+ if (shortfall.installable === 0) {
10675
+ const message2 = "No catalog content found. Run `haus update` first.";
10676
+ if (!opts.allowEmptyCache) {
10677
+ if (isPreview) {
10678
+ warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message2}`);
10679
+ } else {
10680
+ error(message2);
10681
+ process.exitCode = 1;
10682
+ emitJsonEnvelope(json, quiet, envelope(hooks));
10683
+ return { ...baseResult, catalogInstallOk: false };
10684
+ }
10685
+ } else {
10686
+ warn(
10687
+ `Serving catalog metadata from the ${tier} tier, which has no installable content. Core files will be written; all ${wantedIds.length} catalog item(s) will be skipped.`
10688
+ );
10689
+ }
10690
+ } else if (shortfall.missing.length > 0) {
10691
+ warn(
10692
+ `The ${tier} catalog tier is missing content for ${shortfall.missing.length} item(s): ${shortfall.missing.join(", ")}. They will be skipped.`
10693
+ );
10694
+ }
10695
+ }
10159
10696
  const files = await writeClaudeFiles(root, dryRun ?? false, void 0, {
10160
10697
  quiet: opts.quiet,
10161
10698
  activePluginIds
10162
10699
  });
10163
10700
  say("Applied files:");
10164
10701
  files.forEach((f) => say(`- ${displayPath(root, f)}`));
10702
+ let catalogInstallOk = true;
10703
+ if (!isPreview && resolvedWantedIds.length > 0) {
10704
+ const { installedCount: installedWantedCount, missingWantedIds } = await recountInstalledAgainstLock(root, resolvedWantedIds);
10705
+ if (missingWantedIds.length > 0) {
10706
+ const tier = catalogCtx ? `${catalogCtx.source}${catalogCtx.version ? ` (v${catalogCtx.version})` : ""}` : "unknown";
10707
+ say(
10708
+ `${installedWantedCount} of ${resolvedWantedIds.length} catalog items installed (${tier} tier).`
10709
+ );
10710
+ if (installedWantedCount === 0) {
10711
+ error(
10712
+ "No catalog items were installed \u2014 only core files were written. Run `haus update` with network access, or import a catalog bundle."
10713
+ );
10714
+ process.exitCode = 1;
10715
+ catalogInstallOk = false;
10716
+ }
10717
+ }
10718
+ }
10165
10719
  const hooksAfter = await verifyProjectSettingsHooksContract(root);
10166
10720
  const hooksOkAfter = hooksAfter.ok;
10167
10721
  if (hooksAfter.skipped) {
@@ -10173,7 +10727,7 @@ async function runSetupCore(root, opts) {
10173
10727
  say(`- HOOKS OK: ${hooksAfter.message}`);
10174
10728
  }
10175
10729
  emitJsonEnvelope(json, quiet, envelope(hooksAfter));
10176
- return { ...baseResult, hooksOk: hooksOkAfter, written: files };
10730
+ return { ...baseResult, hooksOk: hooksOkAfter, written: files, catalogInstallOk };
10177
10731
  }
10178
10732
 
10179
10733
  // src/commands/init.ts
@@ -10193,6 +10747,7 @@ async function runInit(options) {
10193
10747
  apply: !dryRun,
10194
10748
  dryRun: false,
10195
10749
  force: options.force,
10750
+ allowEmptyCache: options.allowEmptyCache,
10196
10751
  confirm: () => confirm("Approve and write Claude files now?")
10197
10752
  });
10198
10753
  }
@@ -10323,7 +10878,7 @@ async function runInstall(options, deps = {}) {
10323
10878
 
10324
10879
  // src/new-project/create-project.ts
10325
10880
  import path53 from "node:path";
10326
- import fs39 from "fs-extra";
10881
+ import fs42 from "fs-extra";
10327
10882
  init_exec();
10328
10883
  init_logger();
10329
10884
 
@@ -10477,7 +11032,7 @@ async function createProject(opts, deps = defaultDeps) {
10477
11032
  `Invalid project name "${opts.name}" (lowercase letters, digits, . _ - only).`
10478
11033
  );
10479
11034
  }
10480
- if (await fs39.pathExists(projectPath)) {
11035
+ if (await fs42.pathExists(projectPath)) {
10481
11036
  return fail("preflight", `Target ${projectPath} already exists \u2014 refusing to write into it.`);
10482
11037
  }
10483
11038
  const scaffoldArgs = stack.scaffold.args.map(
@@ -10555,15 +11110,15 @@ async function createProject(opts, deps = defaultDeps) {
10555
11110
  steps.push({ step: "scaffold", status: "done" });
10556
11111
  phase = "git";
10557
11112
  if (!opts.json) log("\u25B6 git: init/branch/commit");
10558
- if (!await fs39.pathExists(path53.join(projectPath, ".git"))) {
11113
+ if (!await fs42.pathExists(path53.join(projectPath, ".git"))) {
10559
11114
  const init = await deps.git(["init"], { cwd: projectPath });
10560
11115
  if (init.exitCode !== 0) return fail("git", `git init failed: ${init.stderr}`);
10561
11116
  }
10562
11117
  const branch = await deps.git(["branch", "-M", "main"], { cwd: projectPath });
10563
11118
  if (branch.exitCode !== 0) return fail("git", `git branch -M main failed: ${branch.stderr}`);
10564
11119
  const lockfile = opts.pm === "yarn" ? "yarn.lock" : opts.pm === "pnpm" ? "pnpm-lock.yaml" : "package-lock.json";
10565
- if (!await fs39.pathExists(path53.join(projectPath, lockfile))) {
10566
- await fs39.writeFile(path53.join(projectPath, lockfile), "");
11120
+ if (!await fs42.pathExists(path53.join(projectPath, lockfile))) {
11121
+ await fs42.writeFile(path53.join(projectPath, lockfile), "");
10567
11122
  }
10568
11123
  const add = await deps.git(["add", "-A"], { cwd: projectPath });
10569
11124
  if (add.exitCode !== 0) return fail("git", `git add failed: ${add.stderr}`);
@@ -10931,6 +11486,10 @@ async function runRemove(ids, options = {}) {
10931
11486
  root,
10932
11487
  cleared.map((c) => c.id)
10933
11488
  );
11489
+ await addProjectExcludedIds(
11490
+ root,
11491
+ cleared.map((c) => c.id)
11492
+ );
10934
11493
  try {
10935
11494
  const remaining = await readJson(hausPath(root, "haus.lock.json")) ?? [];
10936
11495
  const remainingIds = new Set(remaining.map((e) => e.id));
@@ -10946,7 +11505,6 @@ async function runRemove(ids, options = {}) {
10946
11505
  }
10947
11506
  if (!options.json) {
10948
11507
  log(`Removed ${report.removed.length} item(s). Restore with \`haus backups list\`.`);
10949
- log("Note: a later `haus apply --write` reinstalls any of these still recommended.");
10950
11508
  }
10951
11509
  emitReport(report, options.json);
10952
11510
  }
@@ -11009,7 +11567,7 @@ async function runScan(options) {
11009
11567
 
11010
11568
  // src/commands/undo.ts
11011
11569
  import path55 from "node:path";
11012
- import fs40 from "fs-extra";
11570
+ import fs43 from "fs-extra";
11013
11571
 
11014
11572
  // src/claude/managed-paths.ts
11015
11573
  init_paths();
@@ -11028,7 +11586,7 @@ init_paths();
11028
11586
  async function collectCoreManagedPaths(root) {
11029
11587
  const existing = [];
11030
11588
  for (const abs of coreManagedAbsolutePaths(root)) {
11031
- if (await fs40.pathExists(abs)) existing.push(abs);
11589
+ if (await fs43.pathExists(abs)) existing.push(abs);
11032
11590
  }
11033
11591
  return existing;
11034
11592
  }
@@ -11037,41 +11595,35 @@ async function collectLockTrackedPaths(root) {
11037
11595
  const removable = [];
11038
11596
  const preserved = [];
11039
11597
  for (const row of lock ?? []) {
11040
- const relPaths = row.paths ?? [];
11041
- const existingRel = [];
11042
- for (const rel of relPaths) {
11043
- if (await fs40.pathExists(path55.resolve(root, rel))) existingRel.push(rel);
11044
- }
11045
- if (existingRel.length === 0) continue;
11046
- if (row.hash === void 0) {
11047
- removable.push(...existingRel.map((rel) => path55.resolve(root, rel)));
11598
+ const id = row.id ?? "(unknown item)";
11599
+ const existing = await resolveExistingTrackedPaths(root, id, row.paths ?? [], log);
11600
+ if (existing.length === 0) continue;
11601
+ const gate = await hashGateEntry(root, { hash: row.hash }, existing);
11602
+ if (!gate.ok) {
11603
+ preserved.push(...existing.map((e) => e.abs));
11604
+ const detail = gate.reason === "no-hash" ? "has no lock hash" : "was modified locally";
11605
+ log(`Preserving ${id} (${detail}): ${existing.map((e) => e.rel).join(", ")}`);
11048
11606
  continue;
11049
11607
  }
11050
- const currentHash = await hashInstalledPaths(root, existingRel);
11051
- if (currentHash === row.hash) {
11052
- removable.push(...existingRel.map((rel) => path55.resolve(root, rel)));
11053
- } else {
11054
- preserved.push(...existingRel.map((rel) => path55.resolve(root, rel)));
11055
- log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
11056
- }
11608
+ removable.push({ id, existing });
11057
11609
  }
11058
11610
  return { removable, preserved };
11059
11611
  }
11060
11612
  async function settingsHasHausContent(root) {
11061
11613
  const settingsPath = claudePath(root, "settings.json");
11062
- if (!await fs40.pathExists(settingsPath)) return false;
11614
+ if (!await fs43.pathExists(settingsPath)) return false;
11063
11615
  const settings = await readProjectSettings(root);
11064
11616
  return settings._haus != null;
11065
11617
  }
11066
11618
  async function claudeMdHasHausBlock(root) {
11067
11619
  const filePath = path55.join(root, "CLAUDE.md");
11068
- if (!await fs40.pathExists(filePath)) return false;
11069
- const text = await fs40.readFile(filePath, "utf8");
11620
+ if (!await fs43.pathExists(filePath)) return false;
11621
+ const text = await fs43.readFile(filePath, "utf8");
11070
11622
  return text.includes(BLOCK_BEGIN);
11071
11623
  }
11072
11624
  async function stripProjectSettings(root) {
11073
11625
  const settingsPath = claudePath(root, "settings.json");
11074
- if (!await fs40.pathExists(settingsPath)) return false;
11626
+ if (!await fs43.pathExists(settingsPath)) return false;
11075
11627
  let settings = await readProjectSettings(root);
11076
11628
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
11077
11629
  const hasContent = Object.keys(settings).length > 0;
@@ -11080,43 +11632,43 @@ async function stripProjectSettings(root) {
11080
11632
  log(`Stripped haus rules from ${path55.relative(root, settingsPath)} (user settings preserved).`);
11081
11633
  return true;
11082
11634
  }
11083
- await fs40.remove(settingsPath);
11635
+ await fs43.remove(settingsPath);
11084
11636
  log(`Removed ${path55.relative(root, settingsPath)} (no user-owned settings remained).`);
11085
11637
  return true;
11086
11638
  }
11087
11639
  async function stripRootClaudeMd(root) {
11088
11640
  const filePath = path55.join(root, "CLAUDE.md");
11089
- if (!await fs40.pathExists(filePath)) return false;
11090
- const prev = await fs40.readFile(filePath, "utf8");
11641
+ if (!await fs43.pathExists(filePath)) return false;
11642
+ const prev = await fs43.readFile(filePath, "utf8");
11091
11643
  if (!prev.includes(BLOCK_BEGIN)) return false;
11092
11644
  const next = stripHausBlock(prev);
11093
11645
  if (next.length === 0) {
11094
- await fs40.remove(filePath);
11646
+ await fs43.remove(filePath);
11095
11647
  log("Removed CLAUDE.md (only contained haus import block).");
11096
11648
  } else {
11097
- await fs40.writeFile(filePath, next, "utf8");
11649
+ await fs43.writeFile(filePath, next, "utf8");
11098
11650
  log("Removed haus import block from CLAUDE.md (user content preserved).");
11099
11651
  }
11100
11652
  return true;
11101
11653
  }
11102
11654
  async function pruneDirIfEmpty(dir) {
11103
- if (!await fs40.pathExists(dir)) return;
11655
+ if (!await fs43.pathExists(dir)) return;
11104
11656
  try {
11105
- const entries = await fs40.readdir(dir);
11106
- if (entries.length === 0) await fs40.remove(dir);
11657
+ const entries = await fs43.readdir(dir);
11658
+ if (entries.length === 0) await fs43.remove(dir);
11107
11659
  } catch {
11108
11660
  }
11109
11661
  }
11110
11662
  async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
11111
11663
  if (managedAbsPaths.length === 0) return;
11112
11664
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
11113
- const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
11665
+ const backupRoot = hausPath(root, "backups", dirBackupDirName("undo", stamp));
11114
11666
  for (const abs of managedAbsPaths) {
11115
- if (!await fs40.pathExists(abs)) continue;
11667
+ if (!await fs43.pathExists(abs)) continue;
11116
11668
  const rel = path55.relative(root, abs);
11117
11669
  const backupPath = path55.join(backupRoot, rel);
11118
- await fs40.ensureDir(path55.dirname(backupPath));
11119
- await fs40.copy(abs, backupPath);
11670
+ await fs43.ensureDir(path55.dirname(backupPath));
11671
+ await fs43.copy(abs, backupPath);
11120
11672
  }
11121
11673
  log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path55.relative(root, backupRoot)}.`);
11122
11674
  }
@@ -11124,7 +11676,8 @@ async function runUndo(options) {
11124
11676
  const root = options.root ?? process.cwd();
11125
11677
  const coreManaged = await collectCoreManagedPaths(root);
11126
11678
  const { removable: lockRemovable, preserved: lockPreserved } = await collectLockTrackedPaths(root);
11127
- const managed = [.../* @__PURE__ */ new Set([...coreManaged, ...lockRemovable])];
11679
+ const lockAbs = lockRemovable.flatMap(({ existing }) => existing.map((e) => e.abs));
11680
+ const managed = [.../* @__PURE__ */ new Set([...coreManaged, ...lockAbs])];
11128
11681
  const stripSettings = await settingsHasHausContent(root);
11129
11682
  const stripClaudeMd = await claudeMdHasHausBlock(root);
11130
11683
  if (managed.length === 0 && !stripSettings && !stripClaudeMd) {
@@ -11136,9 +11689,7 @@ async function runUndo(options) {
11136
11689
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
11137
11690
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
11138
11691
  if (lockPreserved.length > 0) {
11139
- summaryParts.push(
11140
- `(preserving ${lockPreserved.length} locally-modified catalog item file(s) \u2014 not shown above)`
11141
- );
11692
+ summaryParts.push(`(preserving ${lockPreserved.length} catalog item file(s) \u2014 not shown above)`);
11142
11693
  }
11143
11694
  if (options.dryRun) {
11144
11695
  log(`[dry-run] Would remove:
@@ -11158,11 +11709,14 @@ User-owned .claude/ files will be preserved.`
11158
11709
  }
11159
11710
  }
11160
11711
  await backupManagedFilesBeforeUndo(root, managed);
11161
- for (const abs of managed) {
11162
- if (!await fs40.pathExists(abs)) continue;
11163
- await fs40.remove(abs);
11712
+ for (const abs of coreManaged) {
11713
+ if (!await fs43.pathExists(abs)) continue;
11714
+ await fs43.remove(abs);
11164
11715
  log(`Removed ${path55.relative(root, abs)}`);
11165
11716
  }
11717
+ for (const { id, existing } of lockRemovable) {
11718
+ await deleteTrackedPaths(root, id, existing, { verb: "Removed" }, log);
11719
+ }
11166
11720
  if (stripSettings) await stripProjectSettings(root);
11167
11721
  if (stripClaudeMd) await stripRootClaudeMd(root);
11168
11722
  await pruneDirIfEmpty(claudePath(root));
@@ -11176,7 +11730,7 @@ init_logger();
11176
11730
  init_paths();
11177
11731
  import crypto3 from "node:crypto";
11178
11732
  import path56 from "node:path";
11179
- import fs41 from "fs-extra";
11733
+ import fs44 from "fs-extra";
11180
11734
  async function runUninstall(options = {}) {
11181
11735
  const { force = false, removePlugins = removeGlobalPlugins } = options;
11182
11736
  const manifest = await readManifest();
@@ -11213,7 +11767,7 @@ async function runUninstall(options = {}) {
11213
11767
  );
11214
11768
  }
11215
11769
  for (const entry of manifest.files) {
11216
- const exists = fs41.pathExistsSync(entry.destPath);
11770
+ const exists = fs44.pathExistsSync(entry.destPath);
11217
11771
  if (!exists) continue;
11218
11772
  const content2 = await readText(entry.destPath);
11219
11773
  if (content2 === void 0) continue;
@@ -11231,7 +11785,7 @@ async function runUninstall(options = {}) {
11231
11785
  result.skipped.push(entry.destPath);
11232
11786
  continue;
11233
11787
  }
11234
- await fs41.remove(entry.destPath);
11788
+ await fs44.remove(entry.destPath);
11235
11789
  await pruneEmptyDir(path56.dirname(entry.destPath));
11236
11790
  result.deleted.push(entry.destPath);
11237
11791
  }
@@ -11241,13 +11795,13 @@ async function runUninstall(options = {}) {
11241
11795
  result.hooksStripped = true;
11242
11796
  const hausDir = path56.join(globalClaudeDir(), "haus");
11243
11797
  const manifestPath2 = hausManifestPath();
11244
- if (fs41.pathExistsSync(manifestPath2)) {
11245
- await fs41.remove(manifestPath2);
11798
+ if (fs44.pathExistsSync(manifestPath2)) {
11799
+ await fs44.remove(manifestPath2);
11246
11800
  }
11247
- if (fs41.pathExistsSync(hausDir)) {
11801
+ if (fs44.pathExistsSync(hausDir)) {
11248
11802
  try {
11249
- const remaining = await fs41.readdir(hausDir);
11250
- if (remaining.length === 0) await fs41.remove(hausDir);
11803
+ const remaining = await fs44.readdir(hausDir);
11804
+ if (remaining.length === 0) await fs44.remove(hausDir);
11251
11805
  } catch {
11252
11806
  }
11253
11807
  }
@@ -11323,7 +11877,7 @@ init_forbidden_content();
11323
11877
  init_manifest_item_fields();
11324
11878
  init_path_safety();
11325
11879
  init_validation_rules2();
11326
- import fs42 from "node:fs";
11880
+ import fs45 from "node:fs";
11327
11881
  import path57 from "node:path";
11328
11882
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
11329
11883
  var ITEM_ID_PATTERN = /^haus\./;
@@ -11401,11 +11955,11 @@ function auditOptInMetadata(items) {
11401
11955
  }
11402
11956
  return failures;
11403
11957
  }
11404
- function auditManifestStructure(manifestVersion, items) {
11958
+ function auditManifestStructure(manifestVersion2, items) {
11405
11959
  const failures = [];
11406
11960
  const seenIds = /* @__PURE__ */ new Map();
11407
11961
  const seenPaths = /* @__PURE__ */ new Map();
11408
- if (typeof manifestVersion !== "string" || !ITEM_SEMVER_RE.test(manifestVersion)) {
11962
+ if (typeof manifestVersion2 !== "string" || !ITEM_SEMVER_RE.test(manifestVersion2)) {
11409
11963
  failures.push('manifest.json: top-level "version" is not valid semver (expected X.Y.Z)');
11410
11964
  }
11411
11965
  for (let i = 0; i < items.length; i++) {
@@ -11468,7 +12022,7 @@ function checkRequiredFrontmatter(text, label) {
11468
12022
  }
11469
12023
  function readForAudit(absPath, label, failures) {
11470
12024
  try {
11471
- return fs42.readFileSync(absPath, "utf8");
12025
+ return fs45.readFileSync(absPath, "utf8");
11472
12026
  } catch (err) {
11473
12027
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
11474
12028
  return null;
@@ -11500,7 +12054,7 @@ function auditShippedFiles(manifestDir, items) {
11500
12054
  const absPath = path57.join(manifestDir, item.path);
11501
12055
  if (item.type === "skill") {
11502
12056
  const skillMd = path57.join(absPath, "SKILL.md");
11503
- if (!fs42.existsSync(skillMd)) {
12057
+ if (!fs45.existsSync(skillMd)) {
11504
12058
  failures.push(`${item.id}: missing ${path57.relative(manifestDir, skillMd)}`);
11505
12059
  continue;
11506
12060
  }
@@ -11511,7 +12065,7 @@ function auditShippedFiles(manifestDir, items) {
11511
12065
  ...auditForbiddenTagsInText(text, `${item.id}: ${path57.relative(manifestDir, skillMd)}`)
11512
12066
  );
11513
12067
  } else if (item.type === "agent") {
11514
- if (!fs42.existsSync(absPath)) {
12068
+ if (!fs45.existsSync(absPath)) {
11515
12069
  failures.push(`${item.id}: missing agent file ${item.path}`);
11516
12070
  continue;
11517
12071
  }
@@ -11521,13 +12075,13 @@ function auditShippedFiles(manifestDir, items) {
11521
12075
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
11522
12076
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
11523
12077
  } else if (item.type === "template") {
11524
- if (!fs42.existsSync(absPath)) {
12078
+ if (!fs45.existsSync(absPath)) {
11525
12079
  failures.push(`${item.id}: missing template file ${item.path}`);
11526
12080
  continue;
11527
12081
  }
11528
12082
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
11529
12083
  } else if (item.type === "command") {
11530
- if (!fs42.existsSync(absPath)) {
12084
+ if (!fs45.existsSync(absPath)) {
11531
12085
  failures.push(`${item.id}: missing command file ${item.path}`);
11532
12086
  continue;
11533
12087
  }
@@ -11537,15 +12091,15 @@ function auditShippedFiles(manifestDir, items) {
11537
12091
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
11538
12092
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
11539
12093
  } else if (item.type === "config") {
11540
- if (!fs42.existsSync(absPath)) {
12094
+ if (!fs45.existsSync(absPath)) {
11541
12095
  failures.push(`${item.id}: missing config path ${item.path}`);
11542
12096
  }
11543
12097
  } else if (item.type === "rule") {
11544
- if (!fs42.existsSync(absPath)) {
12098
+ if (!fs45.existsSync(absPath)) {
11545
12099
  failures.push(`${item.id}: missing rule file ${item.path}`);
11546
12100
  }
11547
12101
  } else if (item.type === "asset") {
11548
- if (!fs42.existsSync(absPath)) {
12102
+ if (!fs45.existsSync(absPath)) {
11549
12103
  failures.push(`${item.id}: missing asset file ${item.path}`);
11550
12104
  }
11551
12105
  }
@@ -11580,7 +12134,7 @@ function resolveItemType(relPath, pathTypeMap) {
11580
12134
  function walkAllFiles(dir, fn, failures) {
11581
12135
  let entries;
11582
12136
  try {
11583
- entries = fs42.readdirSync(dir, { withFileTypes: true });
12137
+ entries = fs45.readdirSync(dir, { withFileTypes: true });
11584
12138
  } catch (err) {
11585
12139
  failures.push(
11586
12140
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -11614,7 +12168,7 @@ function auditDiskOrphans(manifestDir, items) {
11614
12168
  const claimedPaths = items.filter((item) => item.path && isSafeCatalogPath(item.path)).map((item) => item.path.replace(/\\/g, "/"));
11615
12169
  for (const dir of CONTENT_ROOTS) {
11616
12170
  const abs = path57.join(manifestDir, dir);
11617
- if (!fs42.existsSync(abs)) continue;
12171
+ if (!fs45.existsSync(abs)) continue;
11618
12172
  walkAllFiles(
11619
12173
  abs,
11620
12174
  (file) => {
@@ -11631,7 +12185,7 @@ function auditDiskOrphans(manifestDir, items) {
11631
12185
  function walkMd(dir, fn, failures) {
11632
12186
  let entries;
11633
12187
  try {
11634
- entries = fs42.readdirSync(dir, { withFileTypes: true });
12188
+ entries = fs45.readdirSync(dir, { withFileTypes: true });
11635
12189
  } catch (err) {
11636
12190
  failures.push(
11637
12191
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -11650,7 +12204,7 @@ function auditMarkdownContent(manifestDir, items) {
11650
12204
  const pathTypeMap = buildItemPathTypeMap(items);
11651
12205
  for (const dir of ["skills", "agents", "templates", "commands", "sources"]) {
11652
12206
  const abs = path57.join(manifestDir, dir);
11653
- if (!fs42.existsSync(abs)) continue;
12207
+ if (!fs45.existsSync(abs)) continue;
11654
12208
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
11655
12209
  walkMd(
11656
12210
  abs,
@@ -11678,9 +12232,9 @@ function auditMarkdownContent(manifestDir, items) {
11678
12232
  }
11679
12233
  return failures;
11680
12234
  }
11681
- function validateCatalogData(manifestDir, manifestVersion, items) {
12235
+ function validateCatalogData(manifestDir, manifestVersion2, items) {
11682
12236
  const failures = [
11683
- ...auditManifestStructure(manifestVersion, items),
12237
+ ...auditManifestStructure(manifestVersion2, items),
11684
12238
  ...validateFormerIds(items),
11685
12239
  ...auditOptInMetadata(items),
11686
12240
  ...auditForbiddenStacks(items),
@@ -11726,7 +12280,7 @@ init_logger();
11726
12280
  // src/workspace/worktree/add.ts
11727
12281
  init_fs();
11728
12282
  import path65 from "node:path";
11729
- import fs46 from "fs-extra";
12283
+ import fs49 from "fs-extra";
11730
12284
 
11731
12285
  // src/workspace/worktree/git-worktree.ts
11732
12286
  init_exec();
@@ -11847,12 +12401,12 @@ async function hasUnpushedWork(cwd) {
11847
12401
 
11848
12402
  // src/workspace/worktree/hydrate.ts
11849
12403
  import path61 from "node:path";
11850
- import fs44 from "fs-extra";
12404
+ import fs47 from "fs-extra";
11851
12405
 
11852
12406
  // src/workspace/worktree/cow-copy.ts
11853
12407
  init_exec();
11854
12408
  import path59 from "node:path";
11855
- import fs43 from "fs-extra";
12409
+ import fs46 from "fs-extra";
11856
12410
  async function getMacFilesystemType(dir) {
11857
12411
  const df = await runCommand("df", ["-P", dir]);
11858
12412
  if (df.exitCode !== 0) return void 0;
@@ -11883,14 +12437,14 @@ async function detectCowStrategy(dir) {
11883
12437
  return "unknown-platform";
11884
12438
  }
11885
12439
  async function cowCopyDir(src, dest) {
11886
- const srcExists = await fs43.pathExists(src);
12440
+ const srcExists = await fs46.pathExists(src);
11887
12441
  if (!srcExists)
11888
12442
  return { strategy: "unsupported", attempted: false, ok: false, error: "source missing" };
11889
12443
  const strategy = await detectCowStrategy(path59.dirname(dest));
11890
12444
  if (strategy === "unsupported" || strategy === "unknown-platform") {
11891
12445
  return { strategy, attempted: false, ok: false };
11892
12446
  }
11893
- await fs43.ensureDir(path59.dirname(dest));
12447
+ await fs46.ensureDir(path59.dirname(dest));
11894
12448
  const args = strategy === "darwin-clonefile" ? ["-c", "-R", src, dest] : ["-a", "--reflink=always", src, dest];
11895
12449
  const result = await runCommand("cp", args);
11896
12450
  return {
@@ -12077,11 +12631,11 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
12077
12631
  for (const target of HYDRATION_TARGETS) {
12078
12632
  const src = path61.join(mainMemberPath, target);
12079
12633
  const dest = path61.join(worktreeMemberPath, target);
12080
- if (!await fs44.pathExists(src)) continue;
12081
- const destExists = await fs44.pathExists(dest);
12634
+ if (!await fs47.pathExists(src)) continue;
12635
+ const destExists = await fs47.pathExists(dest);
12082
12636
  if (!opts.force && destExists) continue;
12083
12637
  if (opts.force && destExists) {
12084
- await fs44.remove(dest);
12638
+ await fs47.remove(dest);
12085
12639
  }
12086
12640
  const copy = await cowCopyDir(src, dest);
12087
12641
  targets.push({ target, copy });
@@ -12095,7 +12649,7 @@ async function hydrateMember(mainMemberPath, worktreeMemberPath, opts = {}) {
12095
12649
  // src/workspace/worktree/local-files.ts
12096
12650
  init_exec();
12097
12651
  import path62 from "node:path";
12098
- import fs45 from "fs-extra";
12652
+ import fs48 from "fs-extra";
12099
12653
  var MACHINE_LOCAL_FILES = [
12100
12654
  "docker-compose.local.yml",
12101
12655
  path62.join(".claude", "settings.local.json")
@@ -12109,11 +12663,11 @@ async function copyMachineLocalFiles(mainMemberPath, worktreeMemberPath) {
12109
12663
  const copied = [];
12110
12664
  for (const rel of MACHINE_LOCAL_FILES) {
12111
12665
  const src = path62.join(mainMemberPath, rel);
12112
- if (!await fs45.pathExists(src)) continue;
12666
+ if (!await fs48.pathExists(src)) continue;
12113
12667
  if (await isTracked(mainMemberPath, rel)) continue;
12114
12668
  const dest = path62.join(worktreeMemberPath, rel);
12115
- await fs45.ensureDir(path62.dirname(dest));
12116
- await fs45.copy(src, dest, { overwrite: true });
12669
+ await fs48.ensureDir(path62.dirname(dest));
12670
+ await fs48.copy(src, dest, { overwrite: true });
12117
12671
  copied.push(rel);
12118
12672
  }
12119
12673
  return copied;
@@ -12194,7 +12748,7 @@ async function runAdd(opts) {
12194
12748
  }
12195
12749
  const branch = opts.branch ?? opts.slug;
12196
12750
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
12197
- if (await fs46.pathExists(wsWorktreePath)) {
12751
+ if (await fs49.pathExists(wsWorktreePath)) {
12198
12752
  return {
12199
12753
  ok: false,
12200
12754
  error: `Workspace worktree already exists at ${wsWorktreePath} \u2014 run \`haus workspace worktree hydrate --materialize\` from inside it to check out any missing members (e.g. a worktree created by plain \`git worktree add\`), or \`remove\` first to start over.`
@@ -12563,14 +13117,14 @@ async function runHydrateWorktree(opts) {
12563
13117
 
12564
13118
  // src/workspace/worktree/list.ts
12565
13119
  import path69 from "node:path";
12566
- import fs47 from "fs-extra";
13120
+ import fs50 from "fs-extra";
12567
13121
  async function runList2() {
12568
13122
  const resolved = await resolveWorkspaceForWorktree();
12569
13123
  if (!resolved.ok) return { ok: false, error: resolved.reason };
12570
13124
  const { rootInfo, workspaceRoot } = resolved;
12571
13125
  const dir = worktreesDir(workspaceRoot);
12572
- if (!await fs47.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
12573
- const entries = await fs47.readdir(dir, { withFileTypes: true });
13126
+ if (!await fs50.pathExists(dir)) return { ok: true, workspaceRoot, worktrees: [] };
13127
+ const entries = await fs50.readdir(dir, { withFileTypes: true });
12574
13128
  const slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
12575
13129
  const configuredMembers = await readMembers(rootInfo);
12576
13130
  const worktrees = [];
@@ -12581,7 +13135,7 @@ async function runList2() {
12581
13135
  const source = state?.members ?? configuredMembers.map((m) => ({ id: m.id, folder: m.folder }));
12582
13136
  for (const m of source) {
12583
13137
  const memberPath = path69.join(wtPath, m.folder);
12584
- const materialized = await fs47.pathExists(memberPath);
13138
+ const materialized = await fs50.pathExists(memberPath);
12585
13139
  const actualBranch = materialized ? await currentBranch(memberPath) : void 0;
12586
13140
  members.push({
12587
13141
  id: m.id,
@@ -12643,13 +13197,13 @@ async function runPrune2(opts) {
12643
13197
 
12644
13198
  // src/workspace/worktree/remove.ts
12645
13199
  import path70 from "node:path";
12646
- import fs48 from "fs-extra";
13200
+ import fs51 from "fs-extra";
12647
13201
  async function runRemove2(opts) {
12648
13202
  const resolved = await resolveWorkspaceForWorktree();
12649
13203
  if (!resolved.ok) return { ok: false, error: resolved.reason };
12650
13204
  const { rootInfo, workspaceRoot } = resolved;
12651
13205
  const wsWorktreePath = worktreePath(workspaceRoot, opts.slug);
12652
- if (!await fs48.pathExists(wsWorktreePath)) {
13206
+ if (!await fs51.pathExists(wsWorktreePath)) {
12653
13207
  return { ok: false, error: `No workspace worktree found at ${wsWorktreePath}.` };
12654
13208
  }
12655
13209
  const state = await readWorktreeState(wsWorktreePath);
@@ -12674,7 +13228,7 @@ async function runRemove2(opts) {
12674
13228
  }
12675
13229
  const existingTargets = [];
12676
13230
  for (const target of targets) {
12677
- if (await fs48.pathExists(target.path)) existingTargets.push(target);
13231
+ if (await fs51.pathExists(target.path)) existingTargets.push(target);
12678
13232
  }
12679
13233
  if (!opts.force) {
12680
13234
  const blockers = [];
@@ -12730,8 +13284,8 @@ async function runRemove2(opts) {
12730
13284
  }
12731
13285
  await pruneWorktrees(workspaceRoot);
12732
13286
  }
12733
- if (wsRemoveOk && await fs48.pathExists(wsWorktreePath)) {
12734
- await fs48.remove(wsWorktreePath);
13287
+ if (wsRemoveOk && await fs51.pathExists(wsWorktreePath)) {
13288
+ await fs51.remove(wsWorktreePath);
12735
13289
  }
12736
13290
  return { ok: failed.length === 0, dryRun: false, slug: opts.slug, removed, failed };
12737
13291
  }
@@ -13075,9 +13629,9 @@ init_fs();
13075
13629
  init_paths();
13076
13630
  import { readFileSync as readFileSync4 } from "node:fs";
13077
13631
  import path71 from "node:path";
13078
- var MANIFEST_FILE = "workspace.manifest.json";
13632
+ var MANIFEST_FILE2 = "workspace.manifest.json";
13079
13633
  function manifestPath(workspaceRoot) {
13080
- return hausPath(workspaceRoot, MANIFEST_FILE);
13634
+ return hausPath(workspaceRoot, MANIFEST_FILE2);
13081
13635
  }
13082
13636
  function hausVersion() {
13083
13637
  try {
@@ -13293,17 +13847,17 @@ init_logger();
13293
13847
 
13294
13848
  // src/workspace/link-context/link.ts
13295
13849
  import path75 from "node:path";
13296
- import fs51 from "fs-extra";
13850
+ import fs54 from "fs-extra";
13297
13851
  init_fs();
13298
13852
  init_logger();
13299
13853
 
13300
13854
  // src/workspace/link-context/plan.ts
13301
13855
  import path74 from "node:path";
13302
- import fs50 from "fs-extra";
13856
+ import fs53 from "fs-extra";
13303
13857
 
13304
13858
  // src/workspace/link-context/scan-source.ts
13305
13859
  import path73 from "node:path";
13306
- import fs49 from "fs-extra";
13860
+ import fs52 from "fs-extra";
13307
13861
  var ASSET_SUBDIR = {
13308
13862
  skill: "skills",
13309
13863
  agent: "agents",
@@ -13311,20 +13865,20 @@ var ASSET_SUBDIR = {
13311
13865
  };
13312
13866
  async function hasLinkableContext(member) {
13313
13867
  for (const subdir of Object.values(ASSET_SUBDIR)) {
13314
- if (await fs49.pathExists(path73.join(member.absPath, ".claude", subdir))) return true;
13868
+ if (await fs52.pathExists(path73.join(member.absPath, ".claude", subdir))) return true;
13315
13869
  }
13316
13870
  return false;
13317
13871
  }
13318
13872
  async function collectSkills(member) {
13319
13873
  const dir = path73.join(member.absPath, ".claude", "skills");
13320
- if (!await fs49.pathExists(dir)) return [];
13321
- const names = await fs49.readdir(dir);
13874
+ if (!await fs52.pathExists(dir)) return [];
13875
+ const names = await fs52.readdir(dir);
13322
13876
  const out = [];
13323
13877
  for (const name of [...names].sort()) {
13324
13878
  const abs = path73.join(dir, name);
13325
- const stat = await fs49.lstat(abs);
13879
+ const stat = await fs52.lstat(abs);
13326
13880
  if (!stat.isDirectory()) continue;
13327
- if (!await fs49.pathExists(path73.join(abs, "SKILL.md"))) continue;
13881
+ if (!await fs52.pathExists(path73.join(abs, "SKILL.md"))) continue;
13328
13882
  out.push({ type: "skill", name, sourceRelPath: path73.posix.join(".claude", "skills", name) });
13329
13883
  }
13330
13884
  return out;
@@ -13332,13 +13886,13 @@ async function collectSkills(member) {
13332
13886
  async function collectFlatMarkdown(member, type) {
13333
13887
  const subdir = ASSET_SUBDIR[type];
13334
13888
  const dir = path73.join(member.absPath, ".claude", subdir);
13335
- if (!await fs49.pathExists(dir)) return [];
13336
- const names = await fs49.readdir(dir);
13889
+ if (!await fs52.pathExists(dir)) return [];
13890
+ const names = await fs52.readdir(dir);
13337
13891
  const out = [];
13338
13892
  for (const fileName of [...names].sort()) {
13339
13893
  if (!fileName.endsWith(".md")) continue;
13340
13894
  const abs = path73.join(dir, fileName);
13341
- if (!(await fs49.lstat(abs)).isFile()) continue;
13895
+ if (!(await fs52.lstat(abs)).isFile()) continue;
13342
13896
  const name = fileName.slice(0, -".md".length);
13343
13897
  out.push({ type, name, sourceRelPath: path73.posix.join(".claude", subdir, fileName) });
13344
13898
  }
@@ -13372,7 +13926,7 @@ async function buildLinkPlan(members) {
13372
13926
  const byKey = /* @__PURE__ */ new Map();
13373
13927
  const membersById = new Map(members.map((m) => [m.id, m]));
13374
13928
  for (const member of members) {
13375
- if (!await fs50.pathExists(member.absPath)) {
13929
+ if (!await fs53.pathExists(member.absPath)) {
13376
13930
  skipped.push({ repo: member.id, reason: `not cloned locally (${member.absPath}) \u2014 skipped` });
13377
13931
  continue;
13378
13932
  }
@@ -13453,15 +14007,15 @@ async function runLinkContext(workspaceRoot, opts = {}) {
13453
14007
  const destRel = destRelPathFor(entry.type, entry.destKey);
13454
14008
  const destAbs = path75.join(workspaceRoot, destRel);
13455
14009
  if (write) {
13456
- await fs51.ensureDir(path75.dirname(destAbs));
13457
- if (await fs51.pathExists(destAbs)) await fs51.remove(destAbs);
13458
- await fs51.copy(entry.sourceAbsPath, destAbs, {
14010
+ await fs54.ensureDir(path75.dirname(destAbs));
14011
+ if (await fs54.pathExists(destAbs)) await fs54.remove(destAbs);
14012
+ await fs54.copy(entry.sourceAbsPath, destAbs, {
13459
14013
  overwrite: true,
13460
14014
  errorOnExist: false,
13461
14015
  dereference: false,
13462
14016
  filter: (s) => {
13463
14017
  try {
13464
- return !fs51.lstatSync(s).isSymbolicLink();
14018
+ return !fs54.lstatSync(s).isSymbolicLink();
13465
14019
  } catch {
13466
14020
  return false;
13467
14021
  }
@@ -13490,7 +14044,7 @@ async function runLinkContext(workspaceRoot, opts = {}) {
13490
14044
  warn(`Refusing to remove linked context path outside the workspace root: ${old.path}`);
13491
14045
  continue;
13492
14046
  }
13493
- if (await fs51.pathExists(abs)) await fs51.remove(abs);
14047
+ if (await fs54.pathExists(abs)) await fs54.remove(abs);
13494
14048
  }
13495
14049
  }
13496
14050
  if (write) {
@@ -13546,7 +14100,7 @@ import path77 from "node:path";
13546
14100
 
13547
14101
  // src/claude/write-workspace-claude-md.ts
13548
14102
  import path76 from "node:path";
13549
- import fs52 from "fs-extra";
14103
+ import fs55 from "fs-extra";
13550
14104
  init_fs();
13551
14105
  init_logger();
13552
14106
  init_paths();
@@ -13571,8 +14125,8 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
13571
14125
  const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path76.join(workspaceRoot, "CLAUDE.md");
13572
14126
  const say = opts.quiet ? () => {
13573
14127
  } : log;
13574
- const existed = await fs52.pathExists(filePath);
13575
- const prev = existed ? await fs52.readFile(filePath, "utf8") : "";
14128
+ const existed = await fs55.pathExists(filePath);
14129
+ const prev = existed ? await fs55.readFile(filePath, "utf8") : "";
13576
14130
  const next = opts.collision ? `${block}
13577
14131
  ` : injectHausBlock(prev, block, WORKSPACE_SENTINELS);
13578
14132
  const printable = displayPath(workspaceRoot, filePath);
@@ -13644,34 +14198,49 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13644
14198
  const repoRoot = path77.resolve(workspaceRoot, repo.path);
13645
14199
  say(`
13646
14200
  \u2192 ${repo.name} (${repo.path})`);
14201
+ let res;
13647
14202
  try {
13648
14203
  if (!existsSync9(repoRoot) || !statSync(repoRoot).isDirectory()) {
13649
14204
  throw new Error(`Repo path is not a directory: ${repo.path}`);
13650
14205
  }
13651
- const res = await runSetupCore(repoRoot, {
14206
+ res = await runSetupCore(repoRoot, {
13652
14207
  apply,
13653
14208
  dryRun: options.dryRun,
13654
14209
  // Under --json, mute the inner pipeline entirely; we serialize the
13655
14210
  // aggregated result below. Otherwise let it print its normal human output.
13656
- quiet: options.json
14211
+ quiet: options.json,
14212
+ allowEmptyCache: options.allowEmptyCache
13657
14213
  });
14214
+ } catch (err) {
14215
+ const message2 = err instanceof Error ? err.message : String(err);
13658
14216
  statuses.push({
13659
14217
  name: repo.name,
13660
14218
  path: repo.path,
13661
14219
  root: repoRoot,
13662
- status: "ok",
13663
- roles: res.roles,
13664
- recommendedCount: res.recommendedCount
14220
+ status: "failed",
14221
+ error: message2
13665
14222
  });
13666
- const context = await readContextOrScan(repoRoot);
13667
- aggregateInputs.push({ name: repo.name, path: repo.path, context });
13668
- } catch (err) {
13669
- const message2 = err instanceof Error ? err.message : String(err);
14223
+ if (!options.continueOnError) {
14224
+ if (options.json) {
14225
+ const result2 = { workspaceRoot, statuses, written: [] };
14226
+ log(JSON.stringify(result2, null, 2));
14227
+ process.exitCode = 1;
14228
+ return result2;
14229
+ }
14230
+ throw new Error(`Setup failed for ${repo.name}: ${message2}`, { cause: err });
14231
+ }
14232
+ error(`Setup failed for ${repo.name}: ${message2}`);
14233
+ continue;
14234
+ }
14235
+ if (!res.catalogInstallOk) {
14236
+ const message2 = "No catalog items were installed for this repo (zero installable content) \u2014 see the per-repo output above, or re-run with --allow-empty-cache.";
13670
14237
  statuses.push({
13671
14238
  name: repo.name,
13672
14239
  path: repo.path,
13673
14240
  root: repoRoot,
13674
14241
  status: "failed",
14242
+ roles: res.roles,
14243
+ recommendedCount: res.recommendedCount,
13675
14244
  error: message2
13676
14245
  });
13677
14246
  if (!options.continueOnError) {
@@ -13681,10 +14250,21 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13681
14250
  process.exitCode = 1;
13682
14251
  return result2;
13683
14252
  }
13684
- throw new Error(`Setup failed for ${repo.name}: ${message2}`, { cause: err });
14253
+ throw new Error(`Setup failed for ${repo.name}: ${message2}`);
13685
14254
  }
13686
14255
  error(`Setup failed for ${repo.name}: ${message2}`);
14256
+ continue;
13687
14257
  }
14258
+ statuses.push({
14259
+ name: repo.name,
14260
+ path: repo.path,
14261
+ root: repoRoot,
14262
+ status: "ok",
14263
+ roles: res.roles,
14264
+ recommendedCount: res.recommendedCount
14265
+ });
14266
+ const context = await readContextOrScan(repoRoot);
14267
+ aggregateInputs.push({ name: repo.name, path: repo.path, context });
13688
14268
  }
13689
14269
  const written = [];
13690
14270
  if (apply && aggregateInputs.length > 0) {
@@ -13804,6 +14384,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
13804
14384
  }
13805
14385
  const ok = statuses.filter((s) => s.status === "ok").length;
13806
14386
  const failed = statuses.length - ok;
14387
+ if (failed > 0) process.exitCode = 1;
13807
14388
  const result = { workspaceRoot, statuses, written };
13808
14389
  if (options.json) {
13809
14390
  log(JSON.stringify(result, null, 2));
@@ -13820,7 +14401,7 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
13820
14401
  // src/commands/workspace/undo.ts
13821
14402
  import { existsSync as existsSync10, statSync as statSync2 } from "node:fs";
13822
14403
  import path78 from "node:path";
13823
- import fs53 from "fs-extra";
14404
+ import fs56 from "fs-extra";
13824
14405
  init_fs();
13825
14406
  init_logger();
13826
14407
  init_paths();
@@ -13871,7 +14452,7 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13871
14452
  warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
13872
14453
  continue;
13873
14454
  }
13874
- if (await fs53.pathExists(abs)) {
14455
+ if (await fs56.pathExists(abs)) {
13875
14456
  log(`[dry-run] Would remove ${entry.path} (linked ${entry.type} from ${entry.repo})`);
13876
14457
  }
13877
14458
  }
@@ -13881,11 +14462,11 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13881
14462
  hausPath(workspaceRoot, "WORKSPACE.md")
13882
14463
  ];
13883
14464
  for (const abs of workspaceArtifacts) {
13884
- if (await fs53.pathExists(abs)) {
14465
+ if (await fs56.pathExists(abs)) {
13885
14466
  log(`[dry-run] Would remove ${path78.relative(workspaceRoot, abs)}`);
13886
14467
  }
13887
14468
  }
13888
- if (!await fs53.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
14469
+ if (!await fs56.pathExists(hausPath(workspaceRoot, "WORKSPACE.md"))) {
13889
14470
  const claudeMd = await readText(path78.join(workspaceRoot, "CLAUDE.md"));
13890
14471
  if (claudeMd?.includes(WORKSPACE_BLOCK_BEGIN) || claudeMd && hasLegacyWorkspaceBlock(claudeMd)) {
13891
14472
  log("[dry-run] Would remove the workspace haus import block from CLAUDE.md");
@@ -13925,26 +14506,26 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13925
14506
  warn(`Refusing to remove linked context path outside the workspace root: ${entry.path}`);
13926
14507
  continue;
13927
14508
  }
13928
- if (await fs53.pathExists(abs)) {
13929
- await fs53.remove(abs);
14509
+ if (await fs56.pathExists(abs)) {
14510
+ await fs56.remove(abs);
13930
14511
  log(`Removed ${entry.path} (linked ${entry.type} from ${entry.repo})`);
13931
14512
  }
13932
14513
  }
13933
14514
  for (const rel of WORKSPACE_AGGREGATE_FILES) {
13934
14515
  const abs = hausPath(workspaceRoot, rel);
13935
- if (await fs53.pathExists(abs)) {
13936
- await fs53.remove(abs);
14516
+ if (await fs56.pathExists(abs)) {
14517
+ await fs56.remove(abs);
13937
14518
  log(`Removed ${path78.relative(workspaceRoot, abs)}`);
13938
14519
  }
13939
14520
  }
13940
14521
  const manifestFile = manifestPath(workspaceRoot);
13941
- if (await fs53.pathExists(manifestFile)) {
13942
- await fs53.remove(manifestFile);
14522
+ if (await fs56.pathExists(manifestFile)) {
14523
+ await fs56.remove(manifestFile);
13943
14524
  log(`Removed ${path78.relative(workspaceRoot, manifestFile)}`);
13944
14525
  }
13945
14526
  const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
13946
- if (await fs53.pathExists(workspaceMdPath)) {
13947
- await fs53.remove(workspaceMdPath);
14527
+ if (await fs56.pathExists(workspaceMdPath)) {
14528
+ await fs56.remove(workspaceMdPath);
13948
14529
  log(`Removed ${path78.relative(workspaceRoot, workspaceMdPath)}`);
13949
14530
  } else {
13950
14531
  const claudeMdPath = path78.join(workspaceRoot, "CLAUDE.md");
@@ -13956,19 +14537,19 @@ async function runWorkspaceUndo(workspaceRoot, options = {}) {
13956
14537
  end: WORKSPACE_BLOCK_END
13957
14538
  });
13958
14539
  if (next.length === 0) {
13959
- await fs53.remove(claudeMdPath);
14540
+ await fs56.remove(claudeMdPath);
13960
14541
  log("Removed CLAUDE.md (only contained the workspace haus import block).");
13961
14542
  } else {
13962
- await fs53.writeFile(claudeMdPath, next, "utf8");
14543
+ await fs56.writeFile(claudeMdPath, next, "utf8");
13963
14544
  log("Removed haus import block from CLAUDE.md (user content preserved).");
13964
14545
  }
13965
14546
  } else if (prev && hasLegacyWorkspaceBlock(prev)) {
13966
14547
  const next = stripHausBlock(prev, { begin: BLOCK_BEGIN, end: BLOCK_END });
13967
14548
  if (next.length === 0) {
13968
- await fs53.remove(claudeMdPath);
14549
+ await fs56.remove(claudeMdPath);
13969
14550
  log("Removed CLAUDE.md (only contained the pre-upgrade workspace haus import block).");
13970
14551
  } else {
13971
- await fs53.writeFile(claudeMdPath, next, "utf8");
14552
+ await fs56.writeFile(claudeMdPath, next, "utf8");
13972
14553
  log(
13973
14554
  "Removed pre-upgrade workspace haus import block from CLAUDE.md (user content preserved)."
13974
14555
  );
@@ -14086,7 +14667,8 @@ async function runWorkspace(action, options = {}) {
14086
14667
  dryRun: options.dryRun,
14087
14668
  json: options.json,
14088
14669
  continueOnError: options.continueOnError,
14089
- only: normalizeOnly(options.only)
14670
+ only: normalizeOnly(options.only),
14671
+ allowEmptyCache: options.allowEmptyCache
14090
14672
  });
14091
14673
  return;
14092
14674
  case "doctor":
@@ -14130,6 +14712,9 @@ function validateRuntimeNodeVersion() {
14130
14712
  }
14131
14713
  validateRuntimeNodeVersion();
14132
14714
  program.name("haus").description("Haus AI workflow CLI").version(cliVersion());
14715
+ program.option("--offline", "Make no network calls; use cached or bundled catalog content only").hook("preAction", (thisCommand) => {
14716
+ setOfflineFlag(Boolean(thisCommand.opts()["offline"]));
14717
+ });
14133
14718
  program.command("scan").description(
14134
14719
  "Scan the repo for roles, dependencies, and package manager; writes context-map.json"
14135
14720
  ).option("--json").action(runScan);
@@ -14146,7 +14731,7 @@ program.command("apply").description("Write recommended catalog items and core C
14146
14731
  "Install exactly these recommended item ids non-interactively (skill backend; space- or comma-separated)"
14147
14732
  ).option(
14148
14733
  "--allow-empty-cache",
14149
- "Apply core files only when catalog cache is empty (skip catalog items without error)"
14734
+ "Proceed when no catalog item has installable content: write core files and skip the catalog items. Still exits 1 if none installed (ADR-0060)"
14150
14735
  ).option(
14151
14736
  "--refill-config",
14152
14737
  "Fill still-blank fields in an existing workflow-config.md without touching edited ones"
@@ -14180,6 +14765,9 @@ program.command("explain-recommendation").description("Print a per-item breakdow
14180
14765
  program.command("init").description("Full setup: scan, recommend, and optionally apply Claude files (safe to re-run)").option("--dry-run", "Preview the `.claude/` write; scan artifacts are still written").option("--json", "Emit one JSON envelope on stdout (requires --dry-run)").option(
14181
14766
  "--force",
14182
14767
  "Write recommendation.json/haus.lock.json even when zero catalog items matched at all"
14768
+ ).option(
14769
+ "--allow-empty-cache",
14770
+ "Proceed when no catalog item has installable content: write core files and skip the catalog items. Still exits 1 if none installed (ADR-0060)"
14183
14771
  ).action(runInit);
14184
14772
  program.command("new").description("Create a new project from an allowed Haus stack, push it to GitHub, and run init").argument("[stack]", "Stack id (omit to pick interactively; see --list)").argument("[name]", "Project directory and repo name").option("--list", "Print the allowed stacks and exit").option("--org <org>", "GitHub org to create the repo in (default: personal account)").option("--public", "Create the GitHub repo public").option("--private", "Create the GitHub repo private (default)").option("--no-github", "Skip GitHub repo creation and push").option("--pm <pm>", "Package manager: yarn | npm | pnpm", "yarn").option("-y, --yes", "Skip confirmations (non-interactive)").option("--dry-run", "Print the full plan without executing anything").option("--json", "Emit a machine-readable result document").action(runNew);
14185
14773
  program.command("clone").description("Clone a single git repository by URL").argument("<url>", "Git URL to clone").argument("[dir]", "Target directory (default: repo name derived from the URL)").option("--dry-run", "Print what would happen without cloning").action(
@@ -14188,6 +14776,12 @@ program.command("clone").description("Clone a single git repository by URL").arg
14188
14776
  program.command("rescan").description("Re-scan the project, refresh recommendations, and update scan artifacts").option("--dry-run", "Report what would change without writing scan artifacts").action((opts) => runRescan({ dryRun: opts.dryRun }));
14189
14777
  program.command("catalog-audit").description("Check the bundled catalog manifest for unsupported or forbidden stacks").action(runCatalogAudit);
14190
14778
  program.command("validate-catalog").description("Validate a catalog manifest at an explicit path").argument("[manifest]").action(runValidateCatalog);
14779
+ var catalog = program.command("catalog").description("Catalog cache maintenance");
14780
+ var catalogBundle = catalog.command("bundle").description("Move catalog content between machines (for air-gapped installs)");
14781
+ catalogBundle.command("export").description("Write the populated catalog cache to a portable archive").requiredOption("--out <file>", "Destination .tgz path").action((opts) => runBundleExport({ out: opts.out }));
14782
+ catalogBundle.command("import <file>").description("Unpack a catalog bundle into the local cache (validated like a network sync)").option("--dry-run", "Print what would be imported without writing anything").action(
14783
+ (file, opts) => runBundleImport(file, { dryRun: opts.dryRun })
14784
+ );
14191
14785
  program.command("update").description("Sync the remote catalog cache, refresh the lockfile, and check for CLI updates").option("--check").option("--fast", "With --check, skip per-item content hashing (cheap count+ref only)").option("--from-hook", "Emit JSON for the Claude Code SessionStart hook (silent if up to date)").action(runUpdate);
14192
14786
  program.command("ci-gate").description(
14193
14787
  "Aggregate `doctor` + `decisions check` + `update --check --fast` into one pass/fail gate for CI. Uses the fast/cheap update-check tier (no per-item content hashing) to keep CI runs quick \u2014 run `haus update --check` directly for the fully-hashed tier. Each constituent command remains independently runnable with its own unchanged exit-code contract."
@@ -14216,7 +14810,10 @@ var workspace = program.command("workspace").description("Multi-repo workspace c
14216
14810
  workspace.command("init").option("-y, --yes", "Skip confirmation when haus.workspace.yaml already exists").action((opts) => runWorkspace("init", opts));
14217
14811
  workspace.command("discover").description("Auto-find member repos and write/merge haus.workspace.yaml").option("--write", "Persist haus.workspace.yaml (default previews only)").option("--json", "Output the discovered repos and proposed config as JSON").option("--max-depth <n>", "Max directory depth to traverse (default 3)").option("--client <name>", "Set the workspace client name").action((opts) => runWorkspace("discover", opts));
14218
14812
  workspace.command("scan").description("Aggregate a cross-repo summary from a fast scan of each repo").option("--json", "Output the written artifact paths as JSON").action((opts) => runWorkspace("scan", opts));
14219
- workspace.command("setup").description("Per-repo setup loop + workspace layer + manifest").option("--write", "Apply changes (default previews only)").option("--dry-run", "Preview changes without writing").option("--json", "Emit machine-readable per-repo output").option("--continue-on-error", "Keep going past a failed repo (default fail-fast)").option("--only <names>", "Restrict to comma-separated repo names").action((opts) => runWorkspace("setup", opts));
14813
+ workspace.command("setup").description("Per-repo setup loop + workspace layer + manifest").option("--write", "Apply changes (default previews only)").option("--dry-run", "Preview changes without writing").option("--json", "Emit machine-readable per-repo output").option("--continue-on-error", "Keep going past a failed repo (default fail-fast)").option("--only <names>", "Restrict to comma-separated repo names").option(
14814
+ "--allow-empty-cache",
14815
+ "Proceed when a member repo has no installable catalog content: write core files and skip catalog items for that repo. That repo is still recorded failed if nothing installed (ADR-0060)"
14816
+ ).action((opts) => runWorkspace("setup", opts));
14220
14817
  workspace.command("doctor").description("Report workspace drift against the manifest").option("--json", "Output the manifest and drift array as JSON").action((opts) => runWorkspace("doctor", opts));
14221
14818
  workspace.command("undo").description("Revert haus setup for every configured repo, plus workspace-root artifacts").option("-y, --yes", "Skip confirmation").option("--dry-run", "Print what would be removed without deleting anything").action((opts) => runWorkspace("undo", opts));
14222
14819
  workspace.command("link-context").description(