@haus-tech/haus-workflow 0.13.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/cli.js +1470 -598
- package/library/global/commands/haus-setup.md +26 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { readFileSync as
|
|
5
|
-
import
|
|
4
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
5
|
+
import path34 from "path";
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
|
|
8
8
|
// src/commands/apply.ts
|
|
9
|
-
import
|
|
9
|
+
import path12 from "path";
|
|
10
10
|
import checkbox from "@inquirer/checkbox";
|
|
11
|
+
import fs11 from "fs-extra";
|
|
11
12
|
|
|
12
13
|
// src/catalog/remote-catalog.ts
|
|
13
14
|
import os from "os";
|
|
@@ -102,13 +103,20 @@ async function syncRemoteCatalog() {
|
|
|
102
103
|
warn("Remote catalog fetch failed \u2014 using bundled catalog");
|
|
103
104
|
return { newItems: [], unchanged: 0, failed: [] };
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
try {
|
|
107
|
+
await fs.ensureDir(CACHE_DIR);
|
|
108
|
+
await fs.writeFile(
|
|
109
|
+
path.join(CACHE_DIR, "manifest.json"),
|
|
110
|
+
`${JSON.stringify({ items }, null, 2)}
|
|
109
111
|
`,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
+
"utf8"
|
|
113
|
+
);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
warn(
|
|
116
|
+
`Catalog cache not writable (${CACHE_DIR}) \u2014 skipping cache sync: ${err instanceof Error ? err.message : String(err)}`
|
|
117
|
+
);
|
|
118
|
+
return { newItems: [], unchanged: 0, failed: [] };
|
|
119
|
+
}
|
|
112
120
|
const newItems = [];
|
|
113
121
|
let unchanged = 0;
|
|
114
122
|
const failed = [];
|
|
@@ -140,10 +148,15 @@ async function syncRemoteCatalog() {
|
|
|
140
148
|
failed.push(item.id);
|
|
141
149
|
continue;
|
|
142
150
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
151
|
+
try {
|
|
152
|
+
await fs.ensureDir(path.dirname(dest));
|
|
153
|
+
await fs.writeFile(dest, text, "utf8");
|
|
154
|
+
await downloadSkillReferences(item, destDir);
|
|
155
|
+
newItems.push(item.id);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
158
|
+
failed.push(item.id);
|
|
159
|
+
}
|
|
147
160
|
} else {
|
|
148
161
|
const dest = safeJoin(CACHE_DIR, item.path);
|
|
149
162
|
if (!dest) {
|
|
@@ -162,9 +175,14 @@ async function syncRemoteCatalog() {
|
|
|
162
175
|
failed.push(item.id);
|
|
163
176
|
continue;
|
|
164
177
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
178
|
+
try {
|
|
179
|
+
await fs.ensureDir(path.dirname(dest));
|
|
180
|
+
await fs.writeFile(dest, text, "utf8");
|
|
181
|
+
newItems.push(item.id);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
184
|
+
failed.push(item.id);
|
|
185
|
+
}
|
|
168
186
|
}
|
|
169
187
|
}
|
|
170
188
|
return { newItems, unchanged, failed };
|
|
@@ -193,13 +211,21 @@ async function getCacheManifestAge() {
|
|
|
193
211
|
}
|
|
194
212
|
}
|
|
195
213
|
|
|
196
|
-
// src/
|
|
197
|
-
|
|
198
|
-
|
|
214
|
+
// src/install/allow-rules.ts
|
|
215
|
+
var ALLOWED_SUBCOMMANDS = [
|
|
216
|
+
"setup-project",
|
|
217
|
+
"apply",
|
|
218
|
+
"doctor",
|
|
219
|
+
"scan",
|
|
220
|
+
"context",
|
|
221
|
+
"recommend"
|
|
222
|
+
];
|
|
223
|
+
function buildAllowRules() {
|
|
224
|
+
return [...new Set(ALLOWED_SUBCOMMANDS.map((sub) => `Bash(haus ${sub}:*)`))];
|
|
225
|
+
}
|
|
199
226
|
|
|
200
|
-
// src/
|
|
201
|
-
import
|
|
202
|
-
import fg2 from "fast-glob";
|
|
227
|
+
// src/install/settings-merge.ts
|
|
228
|
+
import path4 from "path";
|
|
203
229
|
import fs3 from "fs-extra";
|
|
204
230
|
|
|
205
231
|
// src/utils/fs.ts
|
|
@@ -253,155 +279,218 @@ async function mapWithConcurrency(items, fn, concurrency = 24) {
|
|
|
253
279
|
return results;
|
|
254
280
|
}
|
|
255
281
|
|
|
256
|
-
// src/
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
|
|
263
|
-
const fileDigests = [];
|
|
264
|
-
for (const rel of normalized) {
|
|
265
|
-
const abs = path3.join(root, rel);
|
|
266
|
-
if (!await fs3.pathExists(abs)) continue;
|
|
267
|
-
const stat = await fs3.stat(abs);
|
|
268
|
-
if (stat.isFile()) {
|
|
269
|
-
const body = await fs3.readFile(abs, "utf8");
|
|
270
|
-
fileDigests.push({ rel, digest: hashText(body) });
|
|
271
|
-
continue;
|
|
272
|
-
}
|
|
273
|
-
if (!stat.isDirectory()) continue;
|
|
274
|
-
const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
|
|
275
|
-
for (const sub of inner.sort()) {
|
|
276
|
-
const relFile = path3.join(rel, sub).replace(/\\/g, "/");
|
|
277
|
-
const absFile = path3.join(abs, sub);
|
|
278
|
-
const body = await fs3.readFile(absFile, "utf8");
|
|
279
|
-
fileDigests.push({ rel: relFile, digest: hashText(body) });
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
if (fileDigests.length === 0) {
|
|
283
|
-
return hashText(`${EMPTY_LOCK_PATHS_TOKEN}|${normalized.join("|")}`);
|
|
284
|
-
}
|
|
285
|
-
fileDigests.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
286
|
-
return hashText(fileDigests.map((f) => `${f.rel}=${f.digest}`).join("|"));
|
|
282
|
+
// src/install/manifest.ts
|
|
283
|
+
import os2 from "os";
|
|
284
|
+
import path3 from "path";
|
|
285
|
+
var MANIFEST_SCHEMA = "haus-install-manifest/1";
|
|
286
|
+
function globalClaudeDir() {
|
|
287
|
+
return path3.join(os2.homedir(), ".claude");
|
|
287
288
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
import { createTwoFilesPatch } from "diff";
|
|
291
|
-
function hasTextChanged(before, after) {
|
|
292
|
-
return before !== after;
|
|
289
|
+
function hausManifestPath() {
|
|
290
|
+
return path3.join(globalClaudeDir(), "haus", "install-manifest.json");
|
|
293
291
|
}
|
|
294
|
-
function
|
|
295
|
-
return
|
|
296
|
-
context: 3
|
|
297
|
-
});
|
|
292
|
+
async function readManifest() {
|
|
293
|
+
return readJson(hausManifestPath());
|
|
298
294
|
}
|
|
299
|
-
function
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
295
|
+
async function writeManifest(manifest) {
|
|
296
|
+
await writeJson(hausManifestPath(), manifest);
|
|
297
|
+
}
|
|
298
|
+
function buildManifest(source, files, hooks) {
|
|
299
|
+
return {
|
|
300
|
+
_schema: MANIFEST_SCHEMA,
|
|
301
|
+
source,
|
|
302
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
303
|
+
files,
|
|
304
|
+
hooks
|
|
305
|
+
};
|
|
309
306
|
}
|
|
310
307
|
|
|
311
|
-
// src/
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
import path4 from "path";
|
|
315
|
-
import { fileURLToPath } from "url";
|
|
316
|
-
var HAUS_DIR = ".haus-workflow";
|
|
317
|
-
function hausPath(root, ...parts) {
|
|
318
|
-
return path4.join(root, HAUS_DIR, ...parts);
|
|
308
|
+
// src/install/settings-merge.ts
|
|
309
|
+
function settingsJsonPath() {
|
|
310
|
+
return path4.join(globalClaudeDir(), "settings.json");
|
|
319
311
|
}
|
|
320
|
-
function
|
|
321
|
-
|
|
312
|
+
async function readSettings() {
|
|
313
|
+
const parsed = await readJson(settingsJsonPath());
|
|
314
|
+
return parsed ?? {};
|
|
322
315
|
}
|
|
323
|
-
function
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
316
|
+
async function writeSettings(settings) {
|
|
317
|
+
await writeJson(settingsJsonPath(), settings);
|
|
318
|
+
}
|
|
319
|
+
function mergeHooks(settings, fragments) {
|
|
320
|
+
const existing = settings._haus?.hooks ?? [];
|
|
321
|
+
const existingCommands = settings._haus?.hookCommands ?? [];
|
|
322
|
+
const existingSet = new Set(existing);
|
|
323
|
+
const updated = { ...settings };
|
|
324
|
+
updated.hooks = { ...settings.hooks ?? {} };
|
|
325
|
+
const addedIds = [];
|
|
326
|
+
const addedCommands = [];
|
|
327
|
+
for (const fragment of fragments) {
|
|
328
|
+
if (fragment.gate !== "keep") continue;
|
|
329
|
+
if (existingSet.has(fragment.id)) continue;
|
|
330
|
+
const event = fragment.event;
|
|
331
|
+
if (!updated.hooks[event]) updated.hooks[event] = [];
|
|
332
|
+
const entry = {
|
|
333
|
+
hooks: [{ type: "command", command: fragment.command }]
|
|
334
|
+
};
|
|
335
|
+
if (fragment.matcher) entry.matcher = fragment.matcher;
|
|
336
|
+
updated.hooks[event] = [...updated.hooks[event] ?? [], entry];
|
|
337
|
+
addedIds.push(fragment.id);
|
|
338
|
+
addedCommands.push(fragment.command);
|
|
335
339
|
}
|
|
336
|
-
|
|
340
|
+
updated._haus = {
|
|
341
|
+
hooks: [...existing, ...addedIds],
|
|
342
|
+
hookCommands: [...existingCommands, ...addedCommands],
|
|
343
|
+
// Preserve deny/allow tracking so hook, deny, and allow merges are order-independent.
|
|
344
|
+
...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
|
|
345
|
+
...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
|
|
346
|
+
};
|
|
347
|
+
return { settings: updated, addedIds };
|
|
337
348
|
}
|
|
338
|
-
function
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
const parent = path4.dirname(dir);
|
|
350
|
-
if (parent === dir) break;
|
|
351
|
-
dir = parent;
|
|
349
|
+
function mergeDenyRules(settings, rules) {
|
|
350
|
+
const existingDeny = settings.permissions?.deny ?? [];
|
|
351
|
+
const seen = new Set(existingDeny);
|
|
352
|
+
const trackedDeny = settings._haus?.denyRules ?? [];
|
|
353
|
+
const addedRules = [];
|
|
354
|
+
for (const rule of rules) {
|
|
355
|
+
if (seen.has(rule)) continue;
|
|
356
|
+
seen.add(rule);
|
|
357
|
+
addedRules.push(rule);
|
|
352
358
|
}
|
|
353
|
-
const
|
|
354
|
-
|
|
359
|
+
const updated = { ...settings };
|
|
360
|
+
updated.permissions = {
|
|
361
|
+
...settings.permissions ?? {},
|
|
362
|
+
deny: [...existingDeny, ...addedRules]
|
|
363
|
+
};
|
|
364
|
+
updated._haus = {
|
|
365
|
+
hooks: settings._haus?.hooks ?? [],
|
|
366
|
+
...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
|
|
367
|
+
denyRules: [...trackedDeny, ...addedRules],
|
|
368
|
+
...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
|
|
369
|
+
};
|
|
370
|
+
return { settings: updated, addedRules };
|
|
355
371
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
372
|
+
function mergeAllowRules(settings, rules) {
|
|
373
|
+
const existingAllow = settings.permissions?.allow ?? [];
|
|
374
|
+
const seen = new Set(existingAllow);
|
|
375
|
+
const trackedAllow = settings._haus?.allowRules ?? [];
|
|
376
|
+
const addedRules = [];
|
|
377
|
+
for (const rule of rules) {
|
|
378
|
+
if (seen.has(rule)) continue;
|
|
379
|
+
seen.add(rule);
|
|
380
|
+
addedRules.push(rule);
|
|
363
381
|
}
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
382
|
+
const updated = { ...settings };
|
|
383
|
+
updated.permissions = {
|
|
384
|
+
...settings.permissions ?? {},
|
|
385
|
+
allow: [...existingAllow, ...addedRules]
|
|
386
|
+
};
|
|
387
|
+
updated._haus = {
|
|
388
|
+
hooks: settings._haus?.hooks ?? [],
|
|
389
|
+
...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
|
|
390
|
+
...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
|
|
391
|
+
allowRules: [...trackedAllow, ...addedRules]
|
|
392
|
+
};
|
|
393
|
+
return { settings: updated, addedRules };
|
|
368
394
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
395
|
+
function stripHausAllow(settings) {
|
|
396
|
+
const prevHaus = settings._haus;
|
|
397
|
+
if (!prevHaus?.allowRules || prevHaus.allowRules.length === 0) return settings;
|
|
398
|
+
const ownedSet = new Set(prevHaus.allowRules);
|
|
399
|
+
const updated = { ...settings };
|
|
400
|
+
const remainingAllow = (settings.permissions?.allow ?? []).filter((rule) => !ownedSet.has(rule));
|
|
401
|
+
const permissions = { ...settings.permissions ?? {} };
|
|
402
|
+
if (remainingAllow.length > 0) permissions.allow = remainingAllow;
|
|
403
|
+
else delete permissions.allow;
|
|
404
|
+
if (Object.keys(permissions).length > 0) updated.permissions = permissions;
|
|
405
|
+
else delete updated.permissions;
|
|
406
|
+
const haus = { ...prevHaus };
|
|
407
|
+
delete haus.allowRules;
|
|
408
|
+
const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0;
|
|
409
|
+
if (stillTracking) updated._haus = haus;
|
|
410
|
+
else delete updated._haus;
|
|
411
|
+
return updated;
|
|
412
|
+
}
|
|
413
|
+
function stripHausDeny(settings) {
|
|
414
|
+
const prevHaus = settings._haus;
|
|
415
|
+
if (!prevHaus?.denyRules || prevHaus.denyRules.length === 0) return settings;
|
|
416
|
+
const ownedSet = new Set(prevHaus.denyRules);
|
|
417
|
+
const updated = { ...settings };
|
|
418
|
+
const remainingDeny = (settings.permissions?.deny ?? []).filter((rule) => !ownedSet.has(rule));
|
|
419
|
+
const permissions = { ...settings.permissions ?? {} };
|
|
420
|
+
if (remainingDeny.length > 0) permissions.deny = remainingDeny;
|
|
421
|
+
else delete permissions.deny;
|
|
422
|
+
if (Object.keys(permissions).length > 0) updated.permissions = permissions;
|
|
423
|
+
else delete updated.permissions;
|
|
424
|
+
const haus = { ...prevHaus };
|
|
425
|
+
delete haus.denyRules;
|
|
426
|
+
const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0;
|
|
427
|
+
if (stillTracking) updated._haus = haus;
|
|
428
|
+
else delete updated._haus;
|
|
429
|
+
return updated;
|
|
430
|
+
}
|
|
431
|
+
function stripHausHooks(settings) {
|
|
432
|
+
if (!settings._haus) return settings;
|
|
433
|
+
const ownedCommands = new Set(settings._haus.hookCommands ?? []);
|
|
434
|
+
const usePrefix = ownedCommands.size === 0;
|
|
435
|
+
const updated = { ...settings };
|
|
436
|
+
updated.hooks = {};
|
|
437
|
+
for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
|
|
438
|
+
const kept = entries.filter((entry) => {
|
|
439
|
+
const cmd = entry.hooks[0]?.command ?? "";
|
|
440
|
+
return usePrefix ? !cmd.startsWith("haus ") : !ownedCommands.has(cmd);
|
|
441
|
+
});
|
|
442
|
+
if (kept.length > 0) updated.hooks[event] = kept;
|
|
443
|
+
}
|
|
444
|
+
const { _haus: _, ...rest } = updated;
|
|
445
|
+
void _;
|
|
446
|
+
return rest;
|
|
447
|
+
}
|
|
448
|
+
async function loadHooksFragment(fragmentPath) {
|
|
449
|
+
let raw;
|
|
450
|
+
try {
|
|
451
|
+
raw = await fs3.readJson(fragmentPath);
|
|
452
|
+
} catch {
|
|
453
|
+
return [];
|
|
454
|
+
}
|
|
455
|
+
const data = raw;
|
|
456
|
+
return Array.isArray(data?.hooks) ? data.hooks : [];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// src/security/dangerous-commands.ts
|
|
460
|
+
var DANGEROUS_COMMANDS = [
|
|
461
|
+
"rm -rf",
|
|
462
|
+
"sudo",
|
|
463
|
+
"chmod -R 777",
|
|
464
|
+
"chown -R",
|
|
465
|
+
"git push --force",
|
|
466
|
+
"git reset --hard",
|
|
467
|
+
"docker system prune",
|
|
468
|
+
"drop database",
|
|
469
|
+
"truncate table",
|
|
470
|
+
"php artisan migrate --force",
|
|
471
|
+
"npm publish",
|
|
472
|
+
"yarn npm publish",
|
|
473
|
+
"pnpm publish"
|
|
474
|
+
];
|
|
475
|
+
|
|
476
|
+
// src/security/sensitive-paths.ts
|
|
477
|
+
var SENSITIVE_PATHS = [
|
|
478
|
+
".env",
|
|
479
|
+
".env.*",
|
|
480
|
+
"*.pem",
|
|
481
|
+
"*.key",
|
|
482
|
+
"*.p12",
|
|
483
|
+
"*.pfx",
|
|
484
|
+
"id_rsa",
|
|
485
|
+
"id_ed25519",
|
|
486
|
+
"*.sql",
|
|
487
|
+
"*.dump",
|
|
488
|
+
"*.backup",
|
|
489
|
+
"*.bak",
|
|
490
|
+
"storage/logs",
|
|
491
|
+
"wp-content/uploads",
|
|
492
|
+
"uploads",
|
|
493
|
+
"customer-data",
|
|
405
494
|
"exports",
|
|
406
495
|
"secrets",
|
|
407
496
|
"certs"
|
|
@@ -449,8 +538,8 @@ function buildDenyRules() {
|
|
|
449
538
|
for (const command of DANGEROUS_COMMANDS) {
|
|
450
539
|
rules.push(`Bash(${command}:*)`);
|
|
451
540
|
}
|
|
452
|
-
for (const
|
|
453
|
-
const pattern = SENSITIVE_DIRS.has(
|
|
541
|
+
for (const path35 of SENSITIVE_PATHS) {
|
|
542
|
+
const pattern = SENSITIVE_DIRS.has(path35) ? `${path35}/**` : path35;
|
|
454
543
|
for (const tool of FILE_TOOLS) {
|
|
455
544
|
rules.push(`${tool}(${pattern})`);
|
|
456
545
|
}
|
|
@@ -458,6 +547,173 @@ function buildDenyRules() {
|
|
|
458
547
|
return [...new Set(rules)];
|
|
459
548
|
}
|
|
460
549
|
|
|
550
|
+
// src/utils/paths.ts
|
|
551
|
+
import { existsSync, readFileSync } from "fs";
|
|
552
|
+
import os3 from "os";
|
|
553
|
+
import path5 from "path";
|
|
554
|
+
import { fileURLToPath } from "url";
|
|
555
|
+
var HAUS_DIR = ".haus-workflow";
|
|
556
|
+
function hausPath(root, ...parts) {
|
|
557
|
+
return path5.join(root, HAUS_DIR, ...parts);
|
|
558
|
+
}
|
|
559
|
+
function claudePath(root, ...parts) {
|
|
560
|
+
return path5.join(root, ".claude", ...parts);
|
|
561
|
+
}
|
|
562
|
+
function displayPath(root, targetPath) {
|
|
563
|
+
const rel = path5.relative(root, targetPath).replace(/\\/g, "/");
|
|
564
|
+
if (rel && !rel.startsWith("../") && rel !== "..") {
|
|
565
|
+
return rel.startsWith("./") ? rel : `./${rel}`;
|
|
566
|
+
}
|
|
567
|
+
const home = os3.homedir();
|
|
568
|
+
const normalized = targetPath.replace(/\\/g, "/");
|
|
569
|
+
if (home && home.trim().length > 0) {
|
|
570
|
+
const homeRel = path5.relative(home, targetPath).replace(/\\/g, "/");
|
|
571
|
+
if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
|
|
572
|
+
return `~/${homeRel}`;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return normalized;
|
|
576
|
+
}
|
|
577
|
+
function packageRoot() {
|
|
578
|
+
let dir = path5.dirname(fileURLToPath(import.meta.url));
|
|
579
|
+
for (let i = 0; i < 12; i++) {
|
|
580
|
+
const pkgPath = path5.join(dir, "package.json");
|
|
581
|
+
if (existsSync(pkgPath)) {
|
|
582
|
+
try {
|
|
583
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
584
|
+
if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
|
|
585
|
+
} catch {
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const parent = path5.dirname(dir);
|
|
589
|
+
if (parent === dir) break;
|
|
590
|
+
dir = parent;
|
|
591
|
+
}
|
|
592
|
+
const file = fileURLToPath(import.meta.url);
|
|
593
|
+
return path5.resolve(path5.dirname(file), "../..");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/claude/merge-project-settings.ts
|
|
597
|
+
var PROJECT_HOOK_FRAGMENTS = [
|
|
598
|
+
{
|
|
599
|
+
id: "haus.context-hook",
|
|
600
|
+
gate: "keep",
|
|
601
|
+
event: "UserPromptSubmit",
|
|
602
|
+
command: "haus context --from-hook || true"
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
id: "haus.guard-file",
|
|
606
|
+
gate: "keep",
|
|
607
|
+
event: "PreToolUse",
|
|
608
|
+
matcher: "Read|Edit|Write",
|
|
609
|
+
command: "haus guard file-access --from-hook || true"
|
|
610
|
+
},
|
|
611
|
+
{
|
|
612
|
+
id: "haus.guard-bash",
|
|
613
|
+
gate: "keep",
|
|
614
|
+
event: "PreToolUse",
|
|
615
|
+
matcher: "Bash",
|
|
616
|
+
command: "haus guard bash --from-hook || true"
|
|
617
|
+
}
|
|
618
|
+
];
|
|
619
|
+
async function readProjectSettings(root) {
|
|
620
|
+
const parsed = await readJson(claudePath(root, "settings.json"));
|
|
621
|
+
return parsed ?? {};
|
|
622
|
+
}
|
|
623
|
+
async function writeProjectSettings(root, settings) {
|
|
624
|
+
await writeJson(claudePath(root, "settings.json"), settings);
|
|
625
|
+
}
|
|
626
|
+
async function mergeProjectSettings(root) {
|
|
627
|
+
const base = await readProjectSettings(root);
|
|
628
|
+
const { settings: withHooks } = mergeHooks(base, PROJECT_HOOK_FRAGMENTS);
|
|
629
|
+
const { settings: withDeny } = mergeDenyRules(withHooks, buildDenyRules());
|
|
630
|
+
const { settings: merged } = mergeAllowRules(withDeny, buildAllowRules());
|
|
631
|
+
return merged;
|
|
632
|
+
}
|
|
633
|
+
async function applyProjectSettingsMerge(root) {
|
|
634
|
+
const merged = await mergeProjectSettings(root);
|
|
635
|
+
await writeProjectSettings(root, merged);
|
|
636
|
+
return merged;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// src/claude/write-claude-files.ts
|
|
640
|
+
import path11 from "path";
|
|
641
|
+
import fs10 from "fs-extra";
|
|
642
|
+
|
|
643
|
+
// src/update/hash-installed.ts
|
|
644
|
+
import path6 from "path";
|
|
645
|
+
import fg2 from "fast-glob";
|
|
646
|
+
import fs4 from "fs-extra";
|
|
647
|
+
var EMPTY_LOCK_PATHS_TOKEN = "haus-lock:empty-paths";
|
|
648
|
+
async function hashInstalledPaths(root, relPaths) {
|
|
649
|
+
if (relPaths.length === 0) {
|
|
650
|
+
return hashText(EMPTY_LOCK_PATHS_TOKEN);
|
|
651
|
+
}
|
|
652
|
+
const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
|
|
653
|
+
const fileDigests = [];
|
|
654
|
+
for (const rel of normalized) {
|
|
655
|
+
const abs = path6.join(root, rel);
|
|
656
|
+
if (!await fs4.pathExists(abs)) continue;
|
|
657
|
+
const stat = await fs4.stat(abs);
|
|
658
|
+
if (stat.isFile()) {
|
|
659
|
+
const body = await fs4.readFile(abs, "utf8");
|
|
660
|
+
fileDigests.push({ rel, digest: hashText(body) });
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (!stat.isDirectory()) continue;
|
|
664
|
+
const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
|
|
665
|
+
for (const sub of inner.sort()) {
|
|
666
|
+
const relFile = path6.join(rel, sub).replace(/\\/g, "/");
|
|
667
|
+
const absFile = path6.join(abs, sub);
|
|
668
|
+
const body = await fs4.readFile(absFile, "utf8");
|
|
669
|
+
fileDigests.push({ rel: relFile, digest: hashText(body) });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
if (fileDigests.length === 0) {
|
|
673
|
+
return hashText(`${EMPTY_LOCK_PATHS_TOKEN}|${normalized.join("|")}`);
|
|
674
|
+
}
|
|
675
|
+
fileDigests.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
676
|
+
return hashText(fileDigests.map((f) => `${f.rel}=${f.digest}`).join("|"));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// src/utils/diff.ts
|
|
680
|
+
import { createTwoFilesPatch } from "diff";
|
|
681
|
+
function hasTextChanged(before, after) {
|
|
682
|
+
return before !== after;
|
|
683
|
+
}
|
|
684
|
+
function createUnifiedDiff(filePath, before, after) {
|
|
685
|
+
return createTwoFilesPatch(filePath, filePath, before, after, "before", "after", {
|
|
686
|
+
context: 3
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
function summarizeDiff(diffText) {
|
|
690
|
+
const lines = diffText.split("\n");
|
|
691
|
+
let additions = 0;
|
|
692
|
+
let deletions = 0;
|
|
693
|
+
for (const line2 of lines) {
|
|
694
|
+
if (line2.startsWith("+++ ") || line2.startsWith("--- ")) continue;
|
|
695
|
+
if (line2.startsWith("+")) additions += 1;
|
|
696
|
+
if (line2.startsWith("-")) deletions += 1;
|
|
697
|
+
}
|
|
698
|
+
return { additions, deletions };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/claude/load-hooks-config.ts
|
|
702
|
+
import path7 from "path";
|
|
703
|
+
var CONFIG_PATH = ".haus-workflow/config.json";
|
|
704
|
+
var DEFAULT_HOOKS_CONFIG = {
|
|
705
|
+
hooks: {
|
|
706
|
+
context: { enabled: false }
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
async function isHookEnabled(root, key) {
|
|
710
|
+
const cfg = await readJson(path7.join(root, CONFIG_PATH));
|
|
711
|
+
return cfg?.hooks?.[key]?.enabled === true;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// src/claude/verify-hooks-contract.ts
|
|
715
|
+
import fs5 from "fs-extra";
|
|
716
|
+
|
|
461
717
|
// src/claude/load-hooks.ts
|
|
462
718
|
var CANONICAL_HOOKS = {
|
|
463
719
|
hooks: {
|
|
@@ -505,24 +761,52 @@ function flattenRecommendedHooks(settings) {
|
|
|
505
761
|
}
|
|
506
762
|
|
|
507
763
|
// src/claude/verify-hooks-contract.ts
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
764
|
+
function collectHookCommands(settings) {
|
|
765
|
+
const cmds = [];
|
|
766
|
+
for (const entries of Object.values(settings.hooks ?? {})) {
|
|
767
|
+
for (const entry of entries) {
|
|
768
|
+
for (const h of entry.hooks ?? []) {
|
|
769
|
+
if (h.command) cmds.push(h.command);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return cmds;
|
|
774
|
+
}
|
|
775
|
+
function hausHookContractSatisfied(project, canonical) {
|
|
776
|
+
const present = new Set(collectHookCommands(project));
|
|
777
|
+
for (const block of canonical.hooks.UserPromptSubmit) {
|
|
778
|
+
for (const h of block.hooks) {
|
|
779
|
+
if (!present.has(h.command)) return false;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
for (const block of canonical.hooks.PreToolUse) {
|
|
783
|
+
for (const h of block.hooks) {
|
|
784
|
+
if (!present.has(h.command)) return false;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
const denySet = new Set(project.permissions?.deny ?? []);
|
|
788
|
+
for (const rule of canonical.permissions?.deny ?? []) {
|
|
789
|
+
if (!denySet.has(rule)) return false;
|
|
790
|
+
}
|
|
791
|
+
return true;
|
|
792
|
+
}
|
|
793
|
+
async function assertPostApplySettingsHausContract(root) {
|
|
794
|
+
const canonical = await loadClaudeHooksSettings();
|
|
511
795
|
const written = await readJson(claudePath(root, "settings.json"));
|
|
512
796
|
if (written == null || typeof written !== "object") {
|
|
513
797
|
throw new Error(
|
|
514
798
|
"haus: post-apply self-check failed: .claude/settings.json missing or unreadable"
|
|
515
799
|
);
|
|
516
800
|
}
|
|
517
|
-
if (!
|
|
801
|
+
if (!hausHookContractSatisfied(written, canonical)) {
|
|
518
802
|
throw new Error(
|
|
519
|
-
"haus: post-apply self-check failed: .claude/settings.json
|
|
803
|
+
"haus: post-apply self-check failed: .claude/settings.json missing required haus hooks or deny rules"
|
|
520
804
|
);
|
|
521
805
|
}
|
|
522
806
|
}
|
|
523
807
|
async function verifyProjectSettingsHooksContract(root) {
|
|
524
808
|
const settingsPath = claudePath(root, "settings.json");
|
|
525
|
-
if (!await
|
|
809
|
+
if (!await fs5.pathExists(settingsPath)) {
|
|
526
810
|
return {
|
|
527
811
|
ok: true,
|
|
528
812
|
skipped: true,
|
|
@@ -539,18 +823,18 @@ async function verifyProjectSettingsHooksContract(root) {
|
|
|
539
823
|
if (project == null || typeof project !== "object") {
|
|
540
824
|
return { ok: false, message: ".claude/settings.json is unreadable." };
|
|
541
825
|
}
|
|
542
|
-
if (!
|
|
826
|
+
if (!hausHookContractSatisfied(project, canonical)) {
|
|
543
827
|
return {
|
|
544
828
|
ok: false,
|
|
545
|
-
message: ".claude/settings.json
|
|
829
|
+
message: ".claude/settings.json missing required haus hooks or deny rules (regenerate with `haus apply --write`)."
|
|
546
830
|
};
|
|
547
831
|
}
|
|
548
|
-
return { ok: true, message: "settings.json
|
|
832
|
+
return { ok: true, message: "settings.json carries required haus hook contract." };
|
|
549
833
|
}
|
|
550
834
|
|
|
551
835
|
// src/claude/write-root-claude-md.ts
|
|
552
|
-
import
|
|
553
|
-
import
|
|
836
|
+
import path8 from "path";
|
|
837
|
+
import fs6 from "fs-extra";
|
|
554
838
|
var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
|
|
555
839
|
var BLOCK_END = "<!-- HAUS:END haus-imports -->";
|
|
556
840
|
var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
|
|
@@ -560,6 +844,16 @@ function buildImportBlock() {
|
|
|
560
844
|
${IMPORT_CONTENT}
|
|
561
845
|
${BLOCK_END}`;
|
|
562
846
|
}
|
|
847
|
+
function stripHausBlock(existing) {
|
|
848
|
+
const beginIdx = existing.indexOf(BLOCK_BEGIN);
|
|
849
|
+
const endIdx = existing.indexOf(BLOCK_END);
|
|
850
|
+
if (beginIdx === -1 || endIdx === -1 || endIdx <= beginIdx) return existing;
|
|
851
|
+
const before = existing.slice(0, beginIdx);
|
|
852
|
+
const after = existing.slice(endIdx + BLOCK_END.length);
|
|
853
|
+
const merged = `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
854
|
+
return merged.length > 0 ? `${merged}
|
|
855
|
+
` : "";
|
|
856
|
+
}
|
|
563
857
|
function injectHausBlock(existing, block) {
|
|
564
858
|
const beginIdx = existing.indexOf(BLOCK_BEGIN);
|
|
565
859
|
const endIdx = existing.indexOf(BLOCK_END);
|
|
@@ -579,9 +873,9 @@ ${block}
|
|
|
579
873
|
`;
|
|
580
874
|
}
|
|
581
875
|
async function writeRootClaudeMd(root, dryRun) {
|
|
582
|
-
const filePath =
|
|
876
|
+
const filePath = path8.join(root, "CLAUDE.md");
|
|
583
877
|
const block = buildImportBlock();
|
|
584
|
-
const prev = await
|
|
878
|
+
const prev = await fs6.pathExists(filePath) ? await fs6.readFile(filePath, "utf8") : "";
|
|
585
879
|
const next = injectHausBlock(prev, block);
|
|
586
880
|
const printable = displayPath(root, filePath);
|
|
587
881
|
if (dryRun) {
|
|
@@ -604,12 +898,12 @@ async function writeRootClaudeMd(root, dryRun) {
|
|
|
604
898
|
}
|
|
605
899
|
|
|
606
900
|
// src/claude/write-workflow-config.ts
|
|
607
|
-
import
|
|
608
|
-
import
|
|
901
|
+
import path10 from "path";
|
|
902
|
+
import fs8 from "fs-extra";
|
|
609
903
|
|
|
610
904
|
// src/claude/derive-workflow-config.ts
|
|
611
|
-
import
|
|
612
|
-
import
|
|
905
|
+
import path9 from "path";
|
|
906
|
+
import fs7 from "fs-extra";
|
|
613
907
|
function binCmd(pm, bin, args) {
|
|
614
908
|
const tail = args ? ` ${args}` : "";
|
|
615
909
|
if (pm === "yarn") return `yarn ${bin}${tail}`;
|
|
@@ -618,7 +912,7 @@ function binCmd(pm, bin, args) {
|
|
|
618
912
|
}
|
|
619
913
|
async function deriveWorkflowConfig(root, ctx) {
|
|
620
914
|
const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
|
|
621
|
-
const pkg = await readJson(
|
|
915
|
+
const pkg = await readJson(path9.join(root, "package.json"));
|
|
622
916
|
const scripts = pkg?.scripts ?? {};
|
|
623
917
|
const deps = new Set(ctx.dependencies);
|
|
624
918
|
const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
|
|
@@ -628,7 +922,7 @@ async function deriveWorkflowConfig(root, ctx) {
|
|
|
628
922
|
return null;
|
|
629
923
|
};
|
|
630
924
|
const hasDep = (name) => deps.has(name);
|
|
631
|
-
const exists = (rel) =>
|
|
925
|
+
const exists = (rel) => fs7.pathExistsSync(path9.join(root, rel));
|
|
632
926
|
const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
|
|
633
927
|
const hasCypress = hasDep("cypress");
|
|
634
928
|
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;
|
|
@@ -697,7 +991,7 @@ var FALLBACK_CONTEXT = {
|
|
|
697
991
|
async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
698
992
|
const destPath = hausPath(root, "workflow-config.md");
|
|
699
993
|
const printable = displayPath(root, destPath);
|
|
700
|
-
const exists = await
|
|
994
|
+
const exists = await fs8.pathExists(destPath);
|
|
701
995
|
if (exists && !opts.refill) {
|
|
702
996
|
if (dryRun) log(printable + ": exists (project-owned, skipping)");
|
|
703
997
|
return null;
|
|
@@ -705,11 +999,11 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
|
705
999
|
const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
|
|
706
1000
|
...FALLBACK_CONTEXT,
|
|
707
1001
|
root,
|
|
708
|
-
repoName:
|
|
1002
|
+
repoName: path10.basename(root)
|
|
709
1003
|
};
|
|
710
1004
|
const values = await deriveWorkflowConfig(root, ctx);
|
|
711
1005
|
if (exists) {
|
|
712
|
-
const current = await
|
|
1006
|
+
const current = await fs8.readFile(destPath, "utf8");
|
|
713
1007
|
const refilled = refillContent(current, values);
|
|
714
1008
|
if (refilled === current) {
|
|
715
1009
|
if (dryRun) log(printable + ": no blank fields to refill");
|
|
@@ -731,7 +1025,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
|
731
1025
|
}
|
|
732
1026
|
|
|
733
1027
|
// src/claude/write-workflow.ts
|
|
734
|
-
import
|
|
1028
|
+
import fs9 from "fs-extra";
|
|
735
1029
|
|
|
736
1030
|
// src/claude/managed-template.ts
|
|
737
1031
|
function normaliseLF(content2) {
|
|
@@ -764,8 +1058,8 @@ async function writeWorkflow(root, pkgVersion, dryRun) {
|
|
|
764
1058
|
${templateContent}`;
|
|
765
1059
|
const destPath = hausPath(root, "WORKFLOW.md");
|
|
766
1060
|
const printable = displayPath(root, destPath);
|
|
767
|
-
if (await
|
|
768
|
-
const existing = await
|
|
1061
|
+
if (await fs9.pathExists(destPath)) {
|
|
1062
|
+
const existing = await fs9.readFile(destPath, "utf8");
|
|
769
1063
|
const firstLine = existing.split("\n")[0] ?? "";
|
|
770
1064
|
const parsed = parseHausManagedHeader(firstLine);
|
|
771
1065
|
if (!parsed) {
|
|
@@ -787,7 +1081,7 @@ ${templateContent}`;
|
|
|
787
1081
|
}
|
|
788
1082
|
}
|
|
789
1083
|
if (dryRun) {
|
|
790
|
-
const prev = await
|
|
1084
|
+
const prev = await fs9.pathExists(destPath) ? await fs9.readFile(destPath, "utf8") : "";
|
|
791
1085
|
if (!prev) {
|
|
792
1086
|
log(createUnifiedDiff(printable, "", next));
|
|
793
1087
|
} else {
|
|
@@ -814,7 +1108,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
814
1108
|
estimatedTokenReductionPct: 0
|
|
815
1109
|
};
|
|
816
1110
|
const pkgRoot = packageRoot();
|
|
817
|
-
const
|
|
1111
|
+
const hausVersion2 = (await readJson(path11.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
|
|
818
1112
|
const coreFiles = [
|
|
819
1113
|
claudePath(root, "settings.json"),
|
|
820
1114
|
claudePath(root, "rules", "haus.md"),
|
|
@@ -823,7 +1117,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
823
1117
|
claudePath(root, "commands", "haus-review.md")
|
|
824
1118
|
];
|
|
825
1119
|
const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
|
|
826
|
-
const workflowPath = await writeWorkflow(root,
|
|
1120
|
+
const workflowPath = await writeWorkflow(root, hausVersion2, dryRun);
|
|
827
1121
|
const workflowConfigPath = await writeWorkflowConfig(root, dryRun, {
|
|
828
1122
|
refill: opts.refillConfig
|
|
829
1123
|
});
|
|
@@ -838,11 +1132,15 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
838
1132
|
hausPath(root, "selected-context.json"),
|
|
839
1133
|
hausPath(root, "haus.lock.json")
|
|
840
1134
|
];
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1135
|
+
if (dryRun) {
|
|
1136
|
+
const mergedSettings = await mergeProjectSettings(root);
|
|
1137
|
+
await writeManagedJson(root, claudePath(root, "settings.json"), mergedSettings, true);
|
|
1138
|
+
} else {
|
|
1139
|
+
await applyProjectSettingsMerge(root);
|
|
1140
|
+
await assertPostApplySettingsHausContract(root);
|
|
1141
|
+
}
|
|
844
1142
|
const configPath = hausPath(root, "config.json");
|
|
845
|
-
if (!await
|
|
1143
|
+
if (!await fs10.pathExists(configPath)) {
|
|
846
1144
|
await writeManagedJson(root, configPath, DEFAULT_HOOKS_CONFIG, dryRun);
|
|
847
1145
|
}
|
|
848
1146
|
await writeManagedText(
|
|
@@ -870,12 +1168,12 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
870
1168
|
dryRun
|
|
871
1169
|
);
|
|
872
1170
|
const fixtureManifestPath = process.env["HAUS_FIXTURE_CATALOG"];
|
|
873
|
-
const
|
|
874
|
-
const manifestDir =
|
|
875
|
-
const manifest = await readJson(
|
|
1171
|
+
const manifestPath2 = fixtureManifestPath ?? path11.join(pkgRoot, "library", "catalog", "manifest.json");
|
|
1172
|
+
const manifestDir = path11.dirname(manifestPath2);
|
|
1173
|
+
const manifest = await readJson(manifestPath2) ?? { items: [] };
|
|
876
1174
|
const manifestById = new Map((manifest.items ?? []).map((item) => [item.id, item]));
|
|
877
1175
|
const cacheManifest = await readJson(
|
|
878
|
-
|
|
1176
|
+
path11.join(CACHE_DIR, "manifest.json")
|
|
879
1177
|
);
|
|
880
1178
|
const cacheManifestById = new Map((cacheManifest?.items ?? []).map((item) => [item.id, item]));
|
|
881
1179
|
const installedPathsByItem = /* @__PURE__ */ new Map();
|
|
@@ -897,23 +1195,23 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
897
1195
|
}
|
|
898
1196
|
}
|
|
899
1197
|
const cachedItem = cacheManifestById.get(item.id);
|
|
900
|
-
const cachePath = cachedItem?.path ?
|
|
901
|
-
const sourcePath = cachePath && await
|
|
1198
|
+
const cachePath = cachedItem?.path ? path11.join(CACHE_DIR, cachedItem.path) : null;
|
|
1199
|
+
const sourcePath = cachePath && await fs10.pathExists(cachePath) ? cachePath : path11.join(manifestDir, manifestItem.path);
|
|
902
1200
|
const target = item.type === "agent" ? "agents" : item.type === "template" ? "templates" : "skills";
|
|
903
|
-
const destination = claudePath(root, target,
|
|
904
|
-
if (await
|
|
1201
|
+
const destination = claudePath(root, target, path11.basename(sourcePath));
|
|
1202
|
+
if (await fs10.pathExists(sourcePath)) {
|
|
905
1203
|
if (dryRun) {
|
|
906
|
-
const exists = await
|
|
1204
|
+
const exists = await fs10.pathExists(destination);
|
|
907
1205
|
log(
|
|
908
1206
|
`${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
|
|
909
1207
|
);
|
|
910
1208
|
} else {
|
|
911
|
-
await
|
|
912
|
-
await
|
|
1209
|
+
await fs10.ensureDir(path11.dirname(destination));
|
|
1210
|
+
await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
|
|
913
1211
|
}
|
|
914
1212
|
files.push(destination);
|
|
915
1213
|
const current = installedPathsByItem.get(item.id) ?? [];
|
|
916
|
-
installedPathsByItem.set(item.id, [...current,
|
|
1214
|
+
installedPathsByItem.set(item.id, [...current, path11.relative(root, destination)]);
|
|
917
1215
|
installedIds.add(item.id);
|
|
918
1216
|
} else {
|
|
919
1217
|
warn(
|
|
@@ -943,7 +1241,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
943
1241
|
id: r.id,
|
|
944
1242
|
type: r.type,
|
|
945
1243
|
source: isCurated ? "curated" : "haus",
|
|
946
|
-
version:
|
|
1244
|
+
version: hausVersion2,
|
|
947
1245
|
catalogRef: CATALOG_REF,
|
|
948
1246
|
hash: await hashInstalledPaths(root, relPaths),
|
|
949
1247
|
installMode: "copied",
|
|
@@ -964,7 +1262,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
964
1262
|
return [...new Set(files)];
|
|
965
1263
|
}
|
|
966
1264
|
async function writeManagedText(root, filePath, nextText, dryRun) {
|
|
967
|
-
const prev = await
|
|
1265
|
+
const prev = await fs10.pathExists(filePath) ? await fs10.readFile(filePath, "utf8") : "";
|
|
968
1266
|
const printable = displayPath(root, filePath);
|
|
969
1267
|
if (dryRun) {
|
|
970
1268
|
if (!prev) {
|
|
@@ -991,7 +1289,7 @@ async function writeManagedJson(root, filePath, value, dryRun) {
|
|
|
991
1289
|
|
|
992
1290
|
// src/commands/apply.ts
|
|
993
1291
|
async function cacheHasItems() {
|
|
994
|
-
const data = await readJson(
|
|
1292
|
+
const data = await readJson(path12.join(CACHE_DIR, "manifest.json"));
|
|
995
1293
|
return Array.isArray(data?.items) && data.items.length > 0;
|
|
996
1294
|
}
|
|
997
1295
|
async function runApply(options) {
|
|
@@ -1058,11 +1356,23 @@ async function runApply(options) {
|
|
|
1058
1356
|
files.forEach((f) => log(`- ${displayPath(root, f)}`));
|
|
1059
1357
|
}
|
|
1060
1358
|
}
|
|
1359
|
+
async function isHausProject(root) {
|
|
1360
|
+
if (await fs11.pathExists(hausPath(root, "recommendation.json"))) return true;
|
|
1361
|
+
if (await fs11.pathExists(claudePath(root, "settings.json"))) {
|
|
1362
|
+
const settings = await readProjectSettings(root);
|
|
1363
|
+
if (settings._haus != null) return true;
|
|
1364
|
+
}
|
|
1365
|
+
return false;
|
|
1366
|
+
}
|
|
1367
|
+
async function refreshProjectApply(root) {
|
|
1368
|
+
if (!await isHausProject(root)) return [];
|
|
1369
|
+
return writeClaudeFiles(root, false, void 0, { refillConfig: false });
|
|
1370
|
+
}
|
|
1061
1371
|
|
|
1062
1372
|
// src/catalog/load-catalog.ts
|
|
1063
|
-
import
|
|
1064
|
-
import
|
|
1065
|
-
var CACHE_MANIFEST =
|
|
1373
|
+
import os4 from "os";
|
|
1374
|
+
import path13 from "path";
|
|
1375
|
+
var CACHE_MANIFEST = path13.join(os4.homedir(), CATALOG_CACHE_SUBDIR, "manifest.json");
|
|
1066
1376
|
async function loadCatalog(root) {
|
|
1067
1377
|
const envPath = process.env["HAUS_FIXTURE_CATALOG"];
|
|
1068
1378
|
if (envPath) {
|
|
@@ -1071,10 +1381,10 @@ async function loadCatalog(root) {
|
|
|
1071
1381
|
}
|
|
1072
1382
|
const cacheData = await readJson(CACHE_MANIFEST);
|
|
1073
1383
|
if (cacheData?.items?.length) return cacheData.items;
|
|
1074
|
-
const localManifest =
|
|
1384
|
+
const localManifest = path13.join(root, "library/catalog/manifest.json");
|
|
1075
1385
|
const localData = await readJson(localManifest);
|
|
1076
1386
|
if (localData?.items?.length) return localData.items;
|
|
1077
|
-
const packageManifest =
|
|
1387
|
+
const packageManifest = path13.join(packageRoot(), "library/catalog/manifest.json");
|
|
1078
1388
|
const data = await readJson(packageManifest);
|
|
1079
1389
|
return data?.items ?? [];
|
|
1080
1390
|
}
|
|
@@ -1284,7 +1594,7 @@ async function runCatalogAudit() {
|
|
|
1284
1594
|
}
|
|
1285
1595
|
|
|
1286
1596
|
// src/commands/config.ts
|
|
1287
|
-
import
|
|
1597
|
+
import path14 from "path";
|
|
1288
1598
|
var CONFIG_PATH2 = ".haus-workflow/config.json";
|
|
1289
1599
|
var HOOK_ALIASES = {
|
|
1290
1600
|
"hook.context": "context"
|
|
@@ -1297,7 +1607,7 @@ async function runConfig(key, action) {
|
|
|
1297
1607
|
);
|
|
1298
1608
|
}
|
|
1299
1609
|
const root = process.cwd();
|
|
1300
|
-
const configPath =
|
|
1610
|
+
const configPath = path14.join(root, CONFIG_PATH2);
|
|
1301
1611
|
const existing = await readJson(configPath);
|
|
1302
1612
|
const cfg = existing ?? structuredClone(DEFAULT_HOOKS_CONFIG);
|
|
1303
1613
|
cfg.hooks ??= {};
|
|
@@ -1669,7 +1979,7 @@ function selectRules(recommended, task, taskIntents) {
|
|
|
1669
1979
|
|
|
1670
1980
|
// src/scanner/scan-project.ts
|
|
1671
1981
|
import { readFile as readFile2 } from "fs/promises";
|
|
1672
|
-
import
|
|
1982
|
+
import path18 from "path";
|
|
1673
1983
|
|
|
1674
1984
|
// src/utils/audit-checks.ts
|
|
1675
1985
|
function isRecord(v) {
|
|
@@ -1696,8 +2006,8 @@ function compareVersions(a, b) {
|
|
|
1696
2006
|
}
|
|
1697
2007
|
|
|
1698
2008
|
// src/scanner/detect-package-manager.ts
|
|
1699
|
-
import
|
|
1700
|
-
import
|
|
2009
|
+
import path15 from "path";
|
|
2010
|
+
import fs12 from "fs-extra";
|
|
1701
2011
|
function detectPackageManager(root, packageManagerField) {
|
|
1702
2012
|
const field = String(packageManagerField ?? "").trim();
|
|
1703
2013
|
if (field.startsWith("yarn@")) {
|
|
@@ -1715,9 +2025,9 @@ function detectPackageManager(root, packageManagerField) {
|
|
|
1715
2025
|
if (satisfiesVersion(version, ">=9")) return "npm";
|
|
1716
2026
|
return "unknown";
|
|
1717
2027
|
}
|
|
1718
|
-
if (
|
|
1719
|
-
if (
|
|
1720
|
-
if (
|
|
2028
|
+
if (fs12.existsSync(path15.join(root, "yarn.lock"))) return "yarn";
|
|
2029
|
+
if (fs12.existsSync(path15.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
2030
|
+
if (fs12.existsSync(path15.join(root, "package-lock.json"))) return "npm";
|
|
1721
2031
|
return "unknown";
|
|
1722
2032
|
}
|
|
1723
2033
|
|
|
@@ -1890,7 +2200,7 @@ function runDetection(ctx, rules = STACK_RULES) {
|
|
|
1890
2200
|
}
|
|
1891
2201
|
|
|
1892
2202
|
// src/scanner/detection.ts
|
|
1893
|
-
import
|
|
2203
|
+
import path16 from "path";
|
|
1894
2204
|
var UNSUPPORTED_MARKERS = {
|
|
1895
2205
|
"requirements.txt": "python",
|
|
1896
2206
|
"pyproject.toml": "python",
|
|
@@ -1944,14 +2254,14 @@ function finalizeRoles(registryRoles, deps, files) {
|
|
|
1944
2254
|
function collectUnsupportedSignals(files) {
|
|
1945
2255
|
return [
|
|
1946
2256
|
...new Set(
|
|
1947
|
-
files.map((f) => UNSUPPORTED_MARKERS[
|
|
2257
|
+
files.map((f) => UNSUPPORTED_MARKERS[path16.basename(f)]).filter((s) => Boolean(s))
|
|
1948
2258
|
)
|
|
1949
2259
|
].sort();
|
|
1950
2260
|
}
|
|
1951
2261
|
|
|
1952
2262
|
// src/scanner/render.ts
|
|
1953
2263
|
import { readFile } from "fs/promises";
|
|
1954
|
-
import
|
|
2264
|
+
import path17 from "path";
|
|
1955
2265
|
|
|
1956
2266
|
// src/scanner/role-labels.ts
|
|
1957
2267
|
var ROLE_LABELS = {
|
|
@@ -2013,7 +2323,7 @@ async function buildContentBlob(root, files) {
|
|
|
2013
2323
|
const slice = candidates.slice(0, 300);
|
|
2014
2324
|
const parts = await mapWithConcurrency(slice, async (rel) => {
|
|
2015
2325
|
try {
|
|
2016
|
-
return await readFile(
|
|
2326
|
+
return await readFile(path17.join(root, rel), "utf8");
|
|
2017
2327
|
} catch {
|
|
2018
2328
|
return "";
|
|
2019
2329
|
}
|
|
@@ -2079,8 +2389,8 @@ var SAFE_FILES = [
|
|
|
2079
2389
|
"Gemfile"
|
|
2080
2390
|
];
|
|
2081
2391
|
async function scanProject(root, mode = "fast") {
|
|
2082
|
-
const pkg = await readJson(
|
|
2083
|
-
const composer = await readJson(
|
|
2392
|
+
const pkg = await readJson(path18.join(root, "package.json"));
|
|
2393
|
+
const composer = await readJson(path18.join(root, "composer.json"));
|
|
2084
2394
|
const files = await listFiles(root, SAFE_FILES);
|
|
2085
2395
|
const safeFiles = files.filter((f) => !blocked(f));
|
|
2086
2396
|
const deps = dependencySet(pkg, composer);
|
|
@@ -2114,7 +2424,7 @@ async function scanProject(root, mode = "fast") {
|
|
|
2114
2424
|
mode,
|
|
2115
2425
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2116
2426
|
root,
|
|
2117
|
-
repoName: String(pkg?.name ??
|
|
2427
|
+
repoName: String(pkg?.name ?? path18.basename(root)),
|
|
2118
2428
|
packageManager,
|
|
2119
2429
|
repoRoles: roles,
|
|
2120
2430
|
detectedStacks: stacks,
|
|
@@ -2132,7 +2442,7 @@ async function scanProject(root, mode = "fast") {
|
|
|
2132
2442
|
const scanHashes = Object.fromEntries(
|
|
2133
2443
|
await mapWithConcurrency(
|
|
2134
2444
|
safeFiles,
|
|
2135
|
-
async (f) => [f, hashText(await readFile2(
|
|
2445
|
+
async (f) => [f, hashText(await readFile2(path18.join(root, f), "utf8"))]
|
|
2136
2446
|
)
|
|
2137
2447
|
);
|
|
2138
2448
|
const repoSummary = renderSummary(context);
|
|
@@ -2204,8 +2514,8 @@ async function runContext(options) {
|
|
|
2204
2514
|
}
|
|
2205
2515
|
|
|
2206
2516
|
// src/commands/doctor.ts
|
|
2207
|
-
import
|
|
2208
|
-
import
|
|
2517
|
+
import path19 from "path";
|
|
2518
|
+
import fs13 from "fs-extra";
|
|
2209
2519
|
|
|
2210
2520
|
// src/update/npm-version.ts
|
|
2211
2521
|
var NPM_PACKAGE_NAME = "@haus-tech/haus-workflow";
|
|
@@ -2285,7 +2595,7 @@ async function runDoctor(options) {
|
|
|
2285
2595
|
const enabled = await isHookEnabled(root, key);
|
|
2286
2596
|
ok(`- HOOK ${key}: ${enabled ? "enabled" : "disabled (default)"}`);
|
|
2287
2597
|
}
|
|
2288
|
-
const rootClaudeMdPath =
|
|
2598
|
+
const rootClaudeMdPath = path19.join(root, "CLAUDE.md");
|
|
2289
2599
|
const rootClaudeMdContent = await readText(rootClaudeMdPath);
|
|
2290
2600
|
if (!rootClaudeMdContent) {
|
|
2291
2601
|
flag(
|
|
@@ -2313,7 +2623,7 @@ async function runDoctor(options) {
|
|
|
2313
2623
|
const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
|
|
2314
2624
|
const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
|
|
2315
2625
|
for (const target of importTargets) {
|
|
2316
|
-
if (!await
|
|
2626
|
+
if (!await fs13.pathExists(hausPath(root, target))) {
|
|
2317
2627
|
flag(
|
|
2318
2628
|
`- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
|
|
2319
2629
|
`A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
|
|
@@ -2324,7 +2634,7 @@ async function runDoctor(options) {
|
|
|
2324
2634
|
}
|
|
2325
2635
|
}
|
|
2326
2636
|
const workflowPath = hausPath(root, "WORKFLOW.md");
|
|
2327
|
-
const workflowExists = await
|
|
2637
|
+
const workflowExists = await fs13.pathExists(workflowPath);
|
|
2328
2638
|
if (!workflowExists) {
|
|
2329
2639
|
flag(
|
|
2330
2640
|
"- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
|
|
@@ -2338,15 +2648,15 @@ async function runDoctor(options) {
|
|
|
2338
2648
|
ok("- .haus-workflow/WORKFLOW.md: OK (user-owned)");
|
|
2339
2649
|
} else {
|
|
2340
2650
|
const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
|
|
2341
|
-
const cachePath =
|
|
2342
|
-
const bundledPath =
|
|
2651
|
+
const cachePath = path19.join(CACHE_DIR, "templates/agentic-workflow-standard.md");
|
|
2652
|
+
const bundledPath = path19.join(
|
|
2343
2653
|
packageRoot(),
|
|
2344
2654
|
"library",
|
|
2345
2655
|
"global",
|
|
2346
2656
|
"templates",
|
|
2347
2657
|
"agentic-workflow-standard.md"
|
|
2348
2658
|
);
|
|
2349
|
-
const templatePath = await
|
|
2659
|
+
const templatePath = await fs13.pathExists(cachePath) ? cachePath : bundledPath;
|
|
2350
2660
|
const templateContent = await readText(templatePath);
|
|
2351
2661
|
if (storedHashMatch && templateContent) {
|
|
2352
2662
|
const currentHash = hashText(normaliseLF(templateContent));
|
|
@@ -2365,7 +2675,7 @@ async function runDoctor(options) {
|
|
|
2365
2675
|
}
|
|
2366
2676
|
}
|
|
2367
2677
|
const workflowConfigPath = hausPath(root, "workflow-config.md");
|
|
2368
|
-
const workflowConfigExists = await
|
|
2678
|
+
const workflowConfigExists = await fs13.pathExists(workflowConfigPath);
|
|
2369
2679
|
if (!workflowConfigExists) {
|
|
2370
2680
|
flag(
|
|
2371
2681
|
"- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
|
|
@@ -2373,7 +2683,7 @@ async function runDoctor(options) {
|
|
|
2373
2683
|
"haus apply --write"
|
|
2374
2684
|
);
|
|
2375
2685
|
} else {
|
|
2376
|
-
const cfg = await
|
|
2686
|
+
const cfg = await fs13.readFile(workflowConfigPath, "utf8");
|
|
2377
2687
|
const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
|
|
2378
2688
|
if (unfilled > 0) {
|
|
2379
2689
|
flag(
|
|
@@ -2404,7 +2714,7 @@ async function runDoctor(options) {
|
|
|
2404
2714
|
ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
|
|
2405
2715
|
}
|
|
2406
2716
|
}
|
|
2407
|
-
const pkgJson = await readJson(
|
|
2717
|
+
const pkgJson = await readJson(path19.join(packageRoot(), "package.json"));
|
|
2408
2718
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
2409
2719
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
2410
2720
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
@@ -2545,8 +2855,26 @@ async function runGuard(kind, _options) {
|
|
|
2545
2855
|
}
|
|
2546
2856
|
|
|
2547
2857
|
// src/commands/init.ts
|
|
2548
|
-
import
|
|
2549
|
-
import
|
|
2858
|
+
import path20 from "path";
|
|
2859
|
+
import fs14 from "fs-extra";
|
|
2860
|
+
|
|
2861
|
+
// src/utils/prompts.ts
|
|
2862
|
+
import { stdin as input, stdout as output } from "process";
|
|
2863
|
+
import readline from "readline/promises";
|
|
2864
|
+
async function ask(question) {
|
|
2865
|
+
const rl = readline.createInterface({ input, output });
|
|
2866
|
+
try {
|
|
2867
|
+
const answer = await rl.question(`${question}
|
|
2868
|
+
> `);
|
|
2869
|
+
return answer.trim();
|
|
2870
|
+
} finally {
|
|
2871
|
+
rl.close();
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
async function confirm(question) {
|
|
2875
|
+
const answer = (await ask(`${question} [y/N]`)).toLowerCase();
|
|
2876
|
+
return answer === "y" || answer === "yes";
|
|
2877
|
+
}
|
|
2550
2878
|
|
|
2551
2879
|
// src/utils/exec.ts
|
|
2552
2880
|
import { execa } from "execa";
|
|
@@ -2832,64 +3160,11 @@ function buildStackSet(context) {
|
|
|
2832
3160
|
);
|
|
2833
3161
|
}
|
|
2834
3162
|
|
|
2835
|
-
// src/
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
async function ask(question) {
|
|
2839
|
-
const rl = readline.createInterface({ input, output });
|
|
2840
|
-
try {
|
|
2841
|
-
const answer = await rl.question(`${question}
|
|
2842
|
-
> `);
|
|
2843
|
-
return answer.trim();
|
|
2844
|
-
} finally {
|
|
2845
|
-
rl.close();
|
|
2846
|
-
}
|
|
2847
|
-
}
|
|
2848
|
-
async function confirm(question) {
|
|
2849
|
-
const answer = (await ask(`${question} [y/N]`)).toLowerCase();
|
|
2850
|
-
return answer === "y" || answer === "yes";
|
|
2851
|
-
}
|
|
2852
|
-
|
|
2853
|
-
// src/commands/setup-project.ts
|
|
2854
|
-
var GUIDED_QUESTIONS = [
|
|
2855
|
-
"What is this project for?",
|
|
2856
|
-
"Is it for a client, internal Haus work, or experimentation?",
|
|
2857
|
-
"What should Claude help with most?",
|
|
2858
|
-
"Is this project connected to other repositories?",
|
|
2859
|
-
"Are there parts of the project Claude should avoid touching?",
|
|
2860
|
-
"Are there client-specific rules or sensitive areas?",
|
|
2861
|
-
"Do you want a minimal, standard, or strict setup?"
|
|
2862
|
-
];
|
|
2863
|
-
async function runSetupProject(options) {
|
|
2864
|
-
const root = process.cwd();
|
|
2865
|
-
let mode = options.guided ? "guided" : "fast";
|
|
2866
|
-
if (!options.guided && !options.fast && !options.json) {
|
|
2867
|
-
log("How do you want to set this project up?");
|
|
2868
|
-
log("1. Guided setup - I'll ask a few simple questions, then scan the project.");
|
|
2869
|
-
log("2. Fast setup - I'll only scan the project and recommend defaults.");
|
|
2870
|
-
const choice = await ask("Choose 1 or 2");
|
|
2871
|
-
mode = choice === "1" ? "guided" : "fast";
|
|
2872
|
-
}
|
|
2873
|
-
if (mode === "guided") {
|
|
2874
|
-
const existing = await readJson(hausPath(root, "setup-answers.json")) ?? {};
|
|
2875
|
-
const merged = {};
|
|
2876
|
-
for (const question of GUIDED_QUESTIONS) {
|
|
2877
|
-
if (options.json) {
|
|
2878
|
-
merged[question] = existing[question] ?? "pending-user-answer";
|
|
2879
|
-
continue;
|
|
2880
|
-
}
|
|
2881
|
-
const prefilled = existing[question];
|
|
2882
|
-
if (prefilled && prefilled !== "pending-user-answer" && prefilled !== "no-answer") {
|
|
2883
|
-
merged[question] = prefilled;
|
|
2884
|
-
continue;
|
|
2885
|
-
}
|
|
2886
|
-
const answer = await ask(question);
|
|
2887
|
-
merged[question] = answer || prefilled || "no-answer";
|
|
2888
|
-
}
|
|
2889
|
-
await writeJson(hausPath(root, "setup-answers.json"), merged);
|
|
2890
|
-
}
|
|
3163
|
+
// src/commands/setup-core.ts
|
|
3164
|
+
async function runSetupCore(root, opts) {
|
|
3165
|
+
const { mode, json, apply, dryRun, confirm: confirm2 } = opts;
|
|
2891
3166
|
const scanResult = await scanProject(root, mode);
|
|
2892
|
-
if (
|
|
3167
|
+
if (json) {
|
|
2893
3168
|
log(JSON.stringify(scanResult, null, 2));
|
|
2894
3169
|
} else {
|
|
2895
3170
|
log("Haus scan complete");
|
|
@@ -2905,7 +3180,7 @@ async function runSetupProject(options) {
|
|
|
2905
3180
|
{ id: "haus.rule.context-minimal", enabled: true },
|
|
2906
3181
|
{ id: "haus.rule.security", enabled: true }
|
|
2907
3182
|
]);
|
|
2908
|
-
if (
|
|
3183
|
+
if (json) {
|
|
2909
3184
|
log(JSON.stringify(recommendation, null, 2));
|
|
2910
3185
|
} else {
|
|
2911
3186
|
log("Haus recommendation ready");
|
|
@@ -2916,6 +3191,7 @@ async function runSetupProject(options) {
|
|
|
2916
3191
|
const warningLines = [.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? []])];
|
|
2917
3192
|
log(`Repo: ${context.repoName}`);
|
|
2918
3193
|
for (const warning of warningLines) log(`- WARN: ${warning}`);
|
|
3194
|
+
const hooksOk = hooks.ok;
|
|
2919
3195
|
if (hooks.skipped) {
|
|
2920
3196
|
log(`- HOOKS: (skipped) ${hooks.message}`);
|
|
2921
3197
|
} else if (!hooks.ok) {
|
|
@@ -2924,17 +3200,29 @@ async function runSetupProject(options) {
|
|
|
2924
3200
|
} else {
|
|
2925
3201
|
log(`- HOOKS OK: ${hooks.message}`);
|
|
2926
3202
|
}
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
3203
|
+
const baseResult = {
|
|
3204
|
+
root,
|
|
3205
|
+
repoName: context.repoName,
|
|
3206
|
+
roles: scanResult.repoRoles,
|
|
3207
|
+
recommendedCount: recommendation.recommended.length,
|
|
3208
|
+
warnings: warningLines,
|
|
3209
|
+
hooksOk,
|
|
3210
|
+
written: []
|
|
3211
|
+
};
|
|
3212
|
+
if (!apply) return baseResult;
|
|
3213
|
+
if (confirm2) {
|
|
3214
|
+
const approved = await confirm2();
|
|
3215
|
+
if (!approved) {
|
|
3216
|
+
log("Setup reviewed. No files written.");
|
|
3217
|
+
log("Next step: run `haus apply --write` when ready.");
|
|
3218
|
+
return baseResult;
|
|
3219
|
+
}
|
|
2933
3220
|
}
|
|
2934
|
-
const files = await writeClaudeFiles(root, false);
|
|
3221
|
+
const files = await writeClaudeFiles(root, dryRun ?? false);
|
|
2935
3222
|
log("Applied files:");
|
|
2936
3223
|
files.forEach((f) => log(`- ${displayPath(root, f)}`));
|
|
2937
3224
|
const hooksAfter = await verifyProjectSettingsHooksContract(root);
|
|
3225
|
+
const hooksOkAfter = hooksAfter.ok;
|
|
2938
3226
|
if (hooksAfter.skipped) {
|
|
2939
3227
|
log(`- HOOKS: (skipped) ${hooksAfter.message}`);
|
|
2940
3228
|
} else if (!hooksAfter.ok) {
|
|
@@ -2943,13 +3231,61 @@ async function runSetupProject(options) {
|
|
|
2943
3231
|
} else {
|
|
2944
3232
|
log(`- HOOKS OK: ${hooksAfter.message}`);
|
|
2945
3233
|
}
|
|
3234
|
+
return { ...baseResult, hooksOk: hooksOkAfter, written: files };
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
// src/commands/setup-project.ts
|
|
3238
|
+
var GUIDED_QUESTIONS = [
|
|
3239
|
+
"What is this project for?",
|
|
3240
|
+
"Is it for a client, internal Haus work, or experimentation?",
|
|
3241
|
+
"What should Claude help with most?",
|
|
3242
|
+
"Is this project connected to other repositories?",
|
|
3243
|
+
"Are there parts of the project Claude should avoid touching?",
|
|
3244
|
+
"Are there client-specific rules or sensitive areas?",
|
|
3245
|
+
"Do you want a minimal, standard, or strict setup?"
|
|
3246
|
+
];
|
|
3247
|
+
async function runSetupProject(options) {
|
|
3248
|
+
const root = process.cwd();
|
|
3249
|
+
let mode = options.guided ? "guided" : "fast";
|
|
3250
|
+
if (!options.guided && !options.fast && !options.json) {
|
|
3251
|
+
log("How do you want to set this project up?");
|
|
3252
|
+
log("1. Guided setup - I'll ask a few simple questions, then scan the project.");
|
|
3253
|
+
log("2. Fast setup - I'll only scan the project and recommend defaults.");
|
|
3254
|
+
const choice = await ask("Choose 1 or 2");
|
|
3255
|
+
mode = choice === "1" ? "guided" : "fast";
|
|
3256
|
+
}
|
|
3257
|
+
if (mode === "guided") {
|
|
3258
|
+
const existing = await readJson(hausPath(root, "setup-answers.json")) ?? {};
|
|
3259
|
+
const merged = {};
|
|
3260
|
+
for (const question of GUIDED_QUESTIONS) {
|
|
3261
|
+
if (options.json) {
|
|
3262
|
+
merged[question] = existing[question] ?? "pending-user-answer";
|
|
3263
|
+
continue;
|
|
3264
|
+
}
|
|
3265
|
+
const prefilled = existing[question];
|
|
3266
|
+
if (prefilled && prefilled !== "pending-user-answer" && prefilled !== "no-answer") {
|
|
3267
|
+
merged[question] = prefilled;
|
|
3268
|
+
continue;
|
|
3269
|
+
}
|
|
3270
|
+
const answer = await ask(question);
|
|
3271
|
+
merged[question] = answer || prefilled || "no-answer";
|
|
3272
|
+
}
|
|
3273
|
+
await writeJson(hausPath(root, "setup-answers.json"), merged);
|
|
3274
|
+
}
|
|
3275
|
+
await runSetupCore(root, {
|
|
3276
|
+
mode,
|
|
3277
|
+
json: options.json,
|
|
3278
|
+
apply: !options.json,
|
|
3279
|
+
dryRun: false,
|
|
3280
|
+
confirm: () => confirm("Approve and write Claude files now?")
|
|
3281
|
+
});
|
|
2946
3282
|
}
|
|
2947
3283
|
|
|
2948
3284
|
// src/commands/init.ts
|
|
2949
3285
|
async function runInit(options) {
|
|
2950
3286
|
const root = process.cwd();
|
|
2951
|
-
const hausDir =
|
|
2952
|
-
const alreadyInit = await
|
|
3287
|
+
const hausDir = path20.join(root, ".haus-workflow");
|
|
3288
|
+
const alreadyInit = await fs14.pathExists(hausDir);
|
|
2953
3289
|
if (alreadyInit) {
|
|
2954
3290
|
log("Haus AI already initialized in this project.");
|
|
2955
3291
|
log("Run `haus setup-project` to reconfigure.");
|
|
@@ -2962,20 +3298,7 @@ async function runInit(options) {
|
|
|
2962
3298
|
// src/install/apply.ts
|
|
2963
3299
|
import crypto2 from "crypto";
|
|
2964
3300
|
import path21 from "path";
|
|
2965
|
-
import
|
|
2966
|
-
|
|
2967
|
-
// src/install/allow-rules.ts
|
|
2968
|
-
var ALLOWED_SUBCOMMANDS = [
|
|
2969
|
-
"setup-project",
|
|
2970
|
-
"apply",
|
|
2971
|
-
"doctor",
|
|
2972
|
-
"scan",
|
|
2973
|
-
"context",
|
|
2974
|
-
"recommend"
|
|
2975
|
-
];
|
|
2976
|
-
function buildAllowRules() {
|
|
2977
|
-
return [...new Set(ALLOWED_SUBCOMMANDS.map((sub) => `Bash(haus ${sub}:*)`))];
|
|
2978
|
-
}
|
|
3301
|
+
import fs15 from "fs-extra";
|
|
2979
3302
|
|
|
2980
3303
|
// src/install/header.ts
|
|
2981
3304
|
var MD_PREFIX = "<!-- HAUS-MANAGED";
|
|
@@ -3004,186 +3327,7 @@ function stampMarkdown(content2, h) {
|
|
|
3004
3327
|
${rest}`;
|
|
3005
3328
|
}
|
|
3006
3329
|
return `${header}
|
|
3007
|
-
${content2}`;
|
|
3008
|
-
}
|
|
3009
|
-
|
|
3010
|
-
// src/install/manifest.ts
|
|
3011
|
-
import os4 from "os";
|
|
3012
|
-
import path19 from "path";
|
|
3013
|
-
var MANIFEST_SCHEMA = "haus-install-manifest/1";
|
|
3014
|
-
function globalClaudeDir() {
|
|
3015
|
-
return path19.join(os4.homedir(), ".claude");
|
|
3016
|
-
}
|
|
3017
|
-
function hausManifestPath() {
|
|
3018
|
-
return path19.join(globalClaudeDir(), "haus", "install-manifest.json");
|
|
3019
|
-
}
|
|
3020
|
-
async function readManifest() {
|
|
3021
|
-
return readJson(hausManifestPath());
|
|
3022
|
-
}
|
|
3023
|
-
async function writeManifest(manifest) {
|
|
3024
|
-
await writeJson(hausManifestPath(), manifest);
|
|
3025
|
-
}
|
|
3026
|
-
function buildManifest(source, files, hooks) {
|
|
3027
|
-
return {
|
|
3028
|
-
_schema: MANIFEST_SCHEMA,
|
|
3029
|
-
source,
|
|
3030
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3031
|
-
files,
|
|
3032
|
-
hooks
|
|
3033
|
-
};
|
|
3034
|
-
}
|
|
3035
|
-
|
|
3036
|
-
// src/install/settings-merge.ts
|
|
3037
|
-
import path20 from "path";
|
|
3038
|
-
import fs13 from "fs-extra";
|
|
3039
|
-
function settingsJsonPath() {
|
|
3040
|
-
return path20.join(globalClaudeDir(), "settings.json");
|
|
3041
|
-
}
|
|
3042
|
-
async function readSettings() {
|
|
3043
|
-
const parsed = await readJson(settingsJsonPath());
|
|
3044
|
-
return parsed ?? {};
|
|
3045
|
-
}
|
|
3046
|
-
async function writeSettings(settings) {
|
|
3047
|
-
await writeJson(settingsJsonPath(), settings);
|
|
3048
|
-
}
|
|
3049
|
-
function mergeHooks(settings, fragments) {
|
|
3050
|
-
const existing = settings._haus?.hooks ?? [];
|
|
3051
|
-
const existingCommands = settings._haus?.hookCommands ?? [];
|
|
3052
|
-
const existingSet = new Set(existing);
|
|
3053
|
-
const updated = { ...settings };
|
|
3054
|
-
updated.hooks = { ...settings.hooks ?? {} };
|
|
3055
|
-
const addedIds = [];
|
|
3056
|
-
const addedCommands = [];
|
|
3057
|
-
for (const fragment of fragments) {
|
|
3058
|
-
if (fragment.gate !== "keep") continue;
|
|
3059
|
-
if (existingSet.has(fragment.id)) continue;
|
|
3060
|
-
const event = fragment.event;
|
|
3061
|
-
if (!updated.hooks[event]) updated.hooks[event] = [];
|
|
3062
|
-
const entry = {
|
|
3063
|
-
hooks: [{ type: "command", command: fragment.command }]
|
|
3064
|
-
};
|
|
3065
|
-
if (fragment.matcher) entry.matcher = fragment.matcher;
|
|
3066
|
-
updated.hooks[event] = [...updated.hooks[event] ?? [], entry];
|
|
3067
|
-
addedIds.push(fragment.id);
|
|
3068
|
-
addedCommands.push(fragment.command);
|
|
3069
|
-
}
|
|
3070
|
-
updated._haus = {
|
|
3071
|
-
hooks: [...existing, ...addedIds],
|
|
3072
|
-
hookCommands: [...existingCommands, ...addedCommands],
|
|
3073
|
-
// Preserve deny/allow tracking so hook, deny, and allow merges are order-independent.
|
|
3074
|
-
...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
|
|
3075
|
-
...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
|
|
3076
|
-
};
|
|
3077
|
-
return { settings: updated, addedIds };
|
|
3078
|
-
}
|
|
3079
|
-
function mergeDenyRules(settings, rules) {
|
|
3080
|
-
const existingDeny = settings.permissions?.deny ?? [];
|
|
3081
|
-
const seen = new Set(existingDeny);
|
|
3082
|
-
const trackedDeny = settings._haus?.denyRules ?? [];
|
|
3083
|
-
const addedRules = [];
|
|
3084
|
-
for (const rule of rules) {
|
|
3085
|
-
if (seen.has(rule)) continue;
|
|
3086
|
-
seen.add(rule);
|
|
3087
|
-
addedRules.push(rule);
|
|
3088
|
-
}
|
|
3089
|
-
const updated = { ...settings };
|
|
3090
|
-
updated.permissions = {
|
|
3091
|
-
...settings.permissions ?? {},
|
|
3092
|
-
deny: [...existingDeny, ...addedRules]
|
|
3093
|
-
};
|
|
3094
|
-
updated._haus = {
|
|
3095
|
-
hooks: settings._haus?.hooks ?? [],
|
|
3096
|
-
...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
|
|
3097
|
-
denyRules: [...trackedDeny, ...addedRules],
|
|
3098
|
-
...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
|
|
3099
|
-
};
|
|
3100
|
-
return { settings: updated, addedRules };
|
|
3101
|
-
}
|
|
3102
|
-
function mergeAllowRules(settings, rules) {
|
|
3103
|
-
const existingAllow = settings.permissions?.allow ?? [];
|
|
3104
|
-
const seen = new Set(existingAllow);
|
|
3105
|
-
const trackedAllow = settings._haus?.allowRules ?? [];
|
|
3106
|
-
const addedRules = [];
|
|
3107
|
-
for (const rule of rules) {
|
|
3108
|
-
if (seen.has(rule)) continue;
|
|
3109
|
-
seen.add(rule);
|
|
3110
|
-
addedRules.push(rule);
|
|
3111
|
-
}
|
|
3112
|
-
const updated = { ...settings };
|
|
3113
|
-
updated.permissions = {
|
|
3114
|
-
...settings.permissions ?? {},
|
|
3115
|
-
allow: [...existingAllow, ...addedRules]
|
|
3116
|
-
};
|
|
3117
|
-
updated._haus = {
|
|
3118
|
-
hooks: settings._haus?.hooks ?? [],
|
|
3119
|
-
...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
|
|
3120
|
-
...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
|
|
3121
|
-
allowRules: [...trackedAllow, ...addedRules]
|
|
3122
|
-
};
|
|
3123
|
-
return { settings: updated, addedRules };
|
|
3124
|
-
}
|
|
3125
|
-
function stripHausAllow(settings) {
|
|
3126
|
-
const prevHaus = settings._haus;
|
|
3127
|
-
if (!prevHaus?.allowRules || prevHaus.allowRules.length === 0) return settings;
|
|
3128
|
-
const ownedSet = new Set(prevHaus.allowRules);
|
|
3129
|
-
const updated = { ...settings };
|
|
3130
|
-
const remainingAllow = (settings.permissions?.allow ?? []).filter((rule) => !ownedSet.has(rule));
|
|
3131
|
-
const permissions = { ...settings.permissions ?? {} };
|
|
3132
|
-
if (remainingAllow.length > 0) permissions.allow = remainingAllow;
|
|
3133
|
-
else delete permissions.allow;
|
|
3134
|
-
if (Object.keys(permissions).length > 0) updated.permissions = permissions;
|
|
3135
|
-
else delete updated.permissions;
|
|
3136
|
-
const haus = { ...prevHaus };
|
|
3137
|
-
delete haus.allowRules;
|
|
3138
|
-
const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0;
|
|
3139
|
-
if (stillTracking) updated._haus = haus;
|
|
3140
|
-
else delete updated._haus;
|
|
3141
|
-
return updated;
|
|
3142
|
-
}
|
|
3143
|
-
function stripHausDeny(settings) {
|
|
3144
|
-
const prevHaus = settings._haus;
|
|
3145
|
-
if (!prevHaus?.denyRules || prevHaus.denyRules.length === 0) return settings;
|
|
3146
|
-
const ownedSet = new Set(prevHaus.denyRules);
|
|
3147
|
-
const updated = { ...settings };
|
|
3148
|
-
const remainingDeny = (settings.permissions?.deny ?? []).filter((rule) => !ownedSet.has(rule));
|
|
3149
|
-
const permissions = { ...settings.permissions ?? {} };
|
|
3150
|
-
if (remainingDeny.length > 0) permissions.deny = remainingDeny;
|
|
3151
|
-
else delete permissions.deny;
|
|
3152
|
-
if (Object.keys(permissions).length > 0) updated.permissions = permissions;
|
|
3153
|
-
else delete updated.permissions;
|
|
3154
|
-
const haus = { ...prevHaus };
|
|
3155
|
-
delete haus.denyRules;
|
|
3156
|
-
const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0;
|
|
3157
|
-
if (stillTracking) updated._haus = haus;
|
|
3158
|
-
else delete updated._haus;
|
|
3159
|
-
return updated;
|
|
3160
|
-
}
|
|
3161
|
-
function stripHausHooks(settings) {
|
|
3162
|
-
if (!settings._haus) return settings;
|
|
3163
|
-
const ownedCommands = new Set(settings._haus.hookCommands ?? []);
|
|
3164
|
-
const usePrefix = ownedCommands.size === 0;
|
|
3165
|
-
const updated = { ...settings };
|
|
3166
|
-
updated.hooks = {};
|
|
3167
|
-
for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
|
|
3168
|
-
const kept = entries.filter((entry) => {
|
|
3169
|
-
const cmd = entry.hooks[0]?.command ?? "";
|
|
3170
|
-
return usePrefix ? !cmd.startsWith("haus ") : !ownedCommands.has(cmd);
|
|
3171
|
-
});
|
|
3172
|
-
if (kept.length > 0) updated.hooks[event] = kept;
|
|
3173
|
-
}
|
|
3174
|
-
const { _haus: _, ...rest } = updated;
|
|
3175
|
-
void _;
|
|
3176
|
-
return rest;
|
|
3177
|
-
}
|
|
3178
|
-
async function loadHooksFragment(fragmentPath) {
|
|
3179
|
-
let raw;
|
|
3180
|
-
try {
|
|
3181
|
-
raw = await fs13.readJson(fragmentPath);
|
|
3182
|
-
} catch {
|
|
3183
|
-
return [];
|
|
3184
|
-
}
|
|
3185
|
-
const data = raw;
|
|
3186
|
-
return Array.isArray(data?.hooks) ? data.hooks : [];
|
|
3330
|
+
${content2}`;
|
|
3187
3331
|
}
|
|
3188
3332
|
|
|
3189
3333
|
// src/install/apply.ts
|
|
@@ -3194,7 +3338,7 @@ function hashContent(content2) {
|
|
|
3194
3338
|
function sourceVersion() {
|
|
3195
3339
|
try {
|
|
3196
3340
|
const pkgPath = path21.join(packageRoot(), "package.json");
|
|
3197
|
-
const pkg = JSON.parse(
|
|
3341
|
+
const pkg = JSON.parse(fs15.readFileSync(pkgPath, "utf8"));
|
|
3198
3342
|
return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
|
|
3199
3343
|
} catch {
|
|
3200
3344
|
return "haus@0.0.0";
|
|
@@ -3206,10 +3350,10 @@ function globalSrcDir() {
|
|
|
3206
3350
|
function collectSourceFiles(srcDir, claudeDir) {
|
|
3207
3351
|
const entries = [];
|
|
3208
3352
|
const skillsDir = path21.join(srcDir, "skills");
|
|
3209
|
-
if (
|
|
3210
|
-
for (const skillName of
|
|
3353
|
+
if (fs15.pathExistsSync(skillsDir)) {
|
|
3354
|
+
for (const skillName of fs15.readdirSync(skillsDir)) {
|
|
3211
3355
|
const skillFile = path21.join(skillsDir, skillName, "SKILL.md");
|
|
3212
|
-
if (
|
|
3356
|
+
if (fs15.pathExistsSync(skillFile)) {
|
|
3213
3357
|
entries.push({
|
|
3214
3358
|
stableId: `skill.${skillName}`,
|
|
3215
3359
|
srcRelPath: path21.join("library", "global", "skills", skillName, "SKILL.md"),
|
|
@@ -3219,8 +3363,8 @@ function collectSourceFiles(srcDir, claudeDir) {
|
|
|
3219
3363
|
}
|
|
3220
3364
|
}
|
|
3221
3365
|
const commandsDir = path21.join(srcDir, "commands");
|
|
3222
|
-
if (
|
|
3223
|
-
for (const fileName of
|
|
3366
|
+
if (fs15.pathExistsSync(commandsDir)) {
|
|
3367
|
+
for (const fileName of fs15.readdirSync(commandsDir)) {
|
|
3224
3368
|
if (!fileName.endsWith(".md")) continue;
|
|
3225
3369
|
const commandName = fileName.slice(0, -".md".length);
|
|
3226
3370
|
entries.push({
|
|
@@ -3270,7 +3414,7 @@ async function applyInstall(options = {}) {
|
|
|
3270
3414
|
}
|
|
3271
3415
|
continue;
|
|
3272
3416
|
}
|
|
3273
|
-
const destExists =
|
|
3417
|
+
const destExists = fs15.pathExistsSync(entry.destPath);
|
|
3274
3418
|
if (destExists) {
|
|
3275
3419
|
const currentContent = await readText(entry.destPath);
|
|
3276
3420
|
if (currentContent !== void 0) {
|
|
@@ -3317,13 +3461,13 @@ async function applyInstall(options = {}) {
|
|
|
3317
3461
|
const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
|
|
3318
3462
|
for (const entry of existingManifest.files) {
|
|
3319
3463
|
if (currentDestPaths.has(entry.destPath)) continue;
|
|
3320
|
-
if (!
|
|
3464
|
+
if (!fs15.pathExistsSync(entry.destPath)) continue;
|
|
3321
3465
|
const content2 = await readText(entry.destPath);
|
|
3322
3466
|
if (!content2) continue;
|
|
3323
3467
|
const hasHeader = parseMarkdownHeader(content2) !== void 0;
|
|
3324
3468
|
const currentHash = hashContent(content2);
|
|
3325
3469
|
if (hasHeader && currentHash === entry.hash) {
|
|
3326
|
-
if (!dryRun) await
|
|
3470
|
+
if (!dryRun) await fs15.remove(entry.destPath);
|
|
3327
3471
|
result.deleted.push(entry.destPath);
|
|
3328
3472
|
} else {
|
|
3329
3473
|
warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
|
|
@@ -3447,35 +3591,128 @@ async function runScan(options) {
|
|
|
3447
3591
|
|
|
3448
3592
|
// src/commands/undo.ts
|
|
3449
3593
|
import path22 from "path";
|
|
3450
|
-
import
|
|
3451
|
-
|
|
3594
|
+
import fs16 from "fs-extra";
|
|
3595
|
+
|
|
3596
|
+
// src/claude/managed-paths.ts
|
|
3597
|
+
var PROJECT_MANAGED_CLAUDE_REL = [
|
|
3598
|
+
"rules/haus.md",
|
|
3599
|
+
"rules/security.md",
|
|
3600
|
+
"commands/haus-doctor.md",
|
|
3601
|
+
"commands/haus-review.md"
|
|
3602
|
+
];
|
|
3603
|
+
var PROJECT_MANAGED_HAUS_REL = [
|
|
3604
|
+
"selected-context.json",
|
|
3605
|
+
"haus.lock.json",
|
|
3606
|
+
"config.json"
|
|
3607
|
+
];
|
|
3608
|
+
function coreManagedAbsolutePaths(root) {
|
|
3609
|
+
const claude = PROJECT_MANAGED_CLAUDE_REL.map((rel) => claudePath(root, rel));
|
|
3610
|
+
const haus = PROJECT_MANAGED_HAUS_REL.map((rel) => hausPath(root, rel));
|
|
3611
|
+
return [...claude, ...haus];
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
// src/commands/undo.ts
|
|
3615
|
+
async function collectManagedPaths(root) {
|
|
3616
|
+
const paths = new Set(coreManagedAbsolutePaths(root));
|
|
3617
|
+
const lock = await readJson(hausPath(root, "haus.lock.json"));
|
|
3618
|
+
for (const row of lock ?? []) {
|
|
3619
|
+
for (const rel of row.paths ?? []) {
|
|
3620
|
+
paths.add(path22.resolve(root, rel));
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
const existing = [];
|
|
3624
|
+
for (const abs of paths) {
|
|
3625
|
+
if (await fs16.pathExists(abs)) existing.push(abs);
|
|
3626
|
+
}
|
|
3627
|
+
return existing;
|
|
3628
|
+
}
|
|
3629
|
+
async function settingsHasHausContent(root) {
|
|
3630
|
+
const settingsPath = claudePath(root, "settings.json");
|
|
3631
|
+
if (!await fs16.pathExists(settingsPath)) return false;
|
|
3632
|
+
const settings = await readProjectSettings(root);
|
|
3633
|
+
return settings._haus != null;
|
|
3634
|
+
}
|
|
3635
|
+
async function claudeMdHasHausBlock(root) {
|
|
3636
|
+
const filePath = path22.join(root, "CLAUDE.md");
|
|
3637
|
+
if (!await fs16.pathExists(filePath)) return false;
|
|
3638
|
+
const text = await fs16.readFile(filePath, "utf8");
|
|
3639
|
+
return text.includes(BLOCK_BEGIN);
|
|
3640
|
+
}
|
|
3641
|
+
async function stripProjectSettings(root) {
|
|
3642
|
+
const settingsPath = claudePath(root, "settings.json");
|
|
3643
|
+
if (!await fs16.pathExists(settingsPath)) return false;
|
|
3644
|
+
let settings = await readProjectSettings(root);
|
|
3645
|
+
settings = stripHausAllow(stripHausDeny(stripHausHooks(settings)));
|
|
3646
|
+
const hasContent = Object.keys(settings).length > 0;
|
|
3647
|
+
if (hasContent) {
|
|
3648
|
+
await writeProjectSettings(root, settings);
|
|
3649
|
+
log(`Stripped haus rules from ${path22.relative(root, settingsPath)} (user settings preserved).`);
|
|
3650
|
+
return true;
|
|
3651
|
+
}
|
|
3652
|
+
await fs16.remove(settingsPath);
|
|
3653
|
+
log(`Removed ${path22.relative(root, settingsPath)} (no user-owned settings remained).`);
|
|
3654
|
+
return true;
|
|
3655
|
+
}
|
|
3656
|
+
async function stripRootClaudeMd(root) {
|
|
3657
|
+
const filePath = path22.join(root, "CLAUDE.md");
|
|
3658
|
+
if (!await fs16.pathExists(filePath)) return false;
|
|
3659
|
+
const prev = await fs16.readFile(filePath, "utf8");
|
|
3660
|
+
if (!prev.includes(BLOCK_BEGIN)) return false;
|
|
3661
|
+
const next = stripHausBlock(prev);
|
|
3662
|
+
if (next.length === 0) {
|
|
3663
|
+
await fs16.remove(filePath);
|
|
3664
|
+
log("Removed CLAUDE.md (only contained haus import block).");
|
|
3665
|
+
} else {
|
|
3666
|
+
await fs16.writeFile(filePath, next, "utf8");
|
|
3667
|
+
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
3668
|
+
}
|
|
3669
|
+
return true;
|
|
3670
|
+
}
|
|
3671
|
+
async function pruneDirIfEmpty(dir) {
|
|
3672
|
+
if (!await fs16.pathExists(dir)) return;
|
|
3673
|
+
const entries = await fs16.readdir(dir);
|
|
3674
|
+
if (entries.length === 0) await fs16.remove(dir);
|
|
3675
|
+
}
|
|
3452
3676
|
async function runUndo(options) {
|
|
3453
3677
|
const root = process.cwd();
|
|
3454
|
-
const
|
|
3455
|
-
const
|
|
3456
|
-
|
|
3457
|
-
|
|
3678
|
+
const managed = await collectManagedPaths(root);
|
|
3679
|
+
const stripSettings = await settingsHasHausContent(root);
|
|
3680
|
+
const stripClaudeMd = await claudeMdHasHausBlock(root);
|
|
3681
|
+
if (managed.length === 0 && !stripSettings && !stripClaudeMd) {
|
|
3682
|
+
log("Nothing to remove: no haus-managed files found in this directory.");
|
|
3458
3683
|
return;
|
|
3459
3684
|
}
|
|
3685
|
+
const relTargets = managed.map((p) => path22.relative(root, p));
|
|
3686
|
+
const summaryParts = [...relTargets];
|
|
3687
|
+
if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
|
|
3688
|
+
if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
|
|
3460
3689
|
if (!options.yes) {
|
|
3461
3690
|
const ok = await confirm(
|
|
3462
|
-
`Remove
|
|
3691
|
+
`Remove haus-managed files?
|
|
3692
|
+
${summaryParts.join("\n ")}
|
|
3693
|
+
User-owned .claude/ files will be preserved.`
|
|
3463
3694
|
);
|
|
3464
3695
|
if (!ok) {
|
|
3465
3696
|
log("Cancelled.");
|
|
3466
3697
|
return;
|
|
3467
3698
|
}
|
|
3468
3699
|
}
|
|
3469
|
-
for (const
|
|
3470
|
-
await
|
|
3471
|
-
|
|
3700
|
+
for (const abs of managed) {
|
|
3701
|
+
if (!await fs16.pathExists(abs)) continue;
|
|
3702
|
+
await fs16.remove(abs);
|
|
3703
|
+
log(`Removed ${path22.relative(root, abs)}`);
|
|
3472
3704
|
}
|
|
3705
|
+
if (stripSettings) await stripProjectSettings(root);
|
|
3706
|
+
if (stripClaudeMd) await stripRootClaudeMd(root);
|
|
3707
|
+
await pruneDirIfEmpty(claudePath(root));
|
|
3708
|
+
await pruneDirIfEmpty(hausPath(root));
|
|
3709
|
+
log("haus undo complete. Scan artifacts under .haus-workflow/ were left in place.");
|
|
3473
3710
|
}
|
|
3474
3711
|
|
|
3475
3712
|
// src/install/uninstall.ts
|
|
3476
3713
|
import crypto3 from "crypto";
|
|
3477
3714
|
import path23 from "path";
|
|
3478
|
-
import
|
|
3715
|
+
import fs17 from "fs-extra";
|
|
3479
3716
|
async function runUninstall(options = {}) {
|
|
3480
3717
|
const { force = false } = options;
|
|
3481
3718
|
const manifest = await readManifest();
|
|
@@ -3485,7 +3722,7 @@ async function runUninstall(options = {}) {
|
|
|
3485
3722
|
return result;
|
|
3486
3723
|
}
|
|
3487
3724
|
for (const entry of manifest.files) {
|
|
3488
|
-
const exists =
|
|
3725
|
+
const exists = fs17.pathExistsSync(entry.destPath);
|
|
3489
3726
|
if (!exists) continue;
|
|
3490
3727
|
const content2 = await readText(entry.destPath);
|
|
3491
3728
|
if (content2 === void 0) continue;
|
|
@@ -3503,7 +3740,7 @@ async function runUninstall(options = {}) {
|
|
|
3503
3740
|
result.skipped.push(entry.destPath);
|
|
3504
3741
|
continue;
|
|
3505
3742
|
}
|
|
3506
|
-
await
|
|
3743
|
+
await fs17.remove(entry.destPath);
|
|
3507
3744
|
await pruneEmptyDir(path23.dirname(entry.destPath));
|
|
3508
3745
|
result.deleted.push(entry.destPath);
|
|
3509
3746
|
}
|
|
@@ -3512,13 +3749,13 @@ async function runUninstall(options = {}) {
|
|
|
3512
3749
|
await writeSettings(stripped);
|
|
3513
3750
|
result.hooksStripped = true;
|
|
3514
3751
|
const hausDir = path23.join(globalClaudeDir(), "haus");
|
|
3515
|
-
const
|
|
3516
|
-
if (
|
|
3517
|
-
await
|
|
3752
|
+
const manifestPath2 = hausManifestPath();
|
|
3753
|
+
if (fs17.pathExistsSync(manifestPath2)) {
|
|
3754
|
+
await fs17.remove(manifestPath2);
|
|
3518
3755
|
}
|
|
3519
|
-
if (
|
|
3520
|
-
const remaining = await
|
|
3521
|
-
if (remaining.length === 0) await
|
|
3756
|
+
if (fs17.pathExistsSync(hausDir)) {
|
|
3757
|
+
const remaining = await fs17.readdir(hausDir);
|
|
3758
|
+
if (remaining.length === 0) await fs17.remove(hausDir);
|
|
3522
3759
|
}
|
|
3523
3760
|
return result;
|
|
3524
3761
|
}
|
|
@@ -3537,8 +3774,8 @@ function printUninstallResult(result) {
|
|
|
3537
3774
|
}
|
|
3538
3775
|
async function pruneEmptyDir(dir) {
|
|
3539
3776
|
try {
|
|
3540
|
-
const entries = await
|
|
3541
|
-
if (entries.length === 0) await
|
|
3777
|
+
const entries = await fs17.readdir(dir);
|
|
3778
|
+
if (entries.length === 0) await fs17.remove(dir);
|
|
3542
3779
|
} catch {
|
|
3543
3780
|
}
|
|
3544
3781
|
}
|
|
@@ -3640,10 +3877,11 @@ async function runUpdate(options) {
|
|
|
3640
3877
|
if (options.check) {
|
|
3641
3878
|
const pkgJson2 = await readJson(path25.join(packageRoot(), "package.json"));
|
|
3642
3879
|
const currentVersion2 = pkgJson2?.version ?? "0.0.0";
|
|
3643
|
-
const [status, npmVersion, latestCatalogTag] = await Promise.all([
|
|
3880
|
+
const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
|
|
3644
3881
|
checkLock(root),
|
|
3645
3882
|
fetchNpmVersionStatus(currentVersion2),
|
|
3646
|
-
fetchLatestCatalogTag()
|
|
3883
|
+
fetchLatestCatalogTag(),
|
|
3884
|
+
detectGlobalInstallDrift()
|
|
3647
3885
|
]);
|
|
3648
3886
|
const installedRef = status.catalogRef ?? "main";
|
|
3649
3887
|
const catalogRefBehind = latestCatalogTag !== null && installedRef !== latestCatalogTag ? `installed from ${installedRef}, latest tag is ${latestCatalogTag}` : false;
|
|
@@ -3654,6 +3892,7 @@ async function runUpdate(options) {
|
|
|
3654
3892
|
installedCatalogRef: installedRef,
|
|
3655
3893
|
latestCatalogTag,
|
|
3656
3894
|
catalogRefBehind,
|
|
3895
|
+
globalInstallDrift,
|
|
3657
3896
|
localOverrides: await hasLocalOverrides(root),
|
|
3658
3897
|
summary: diffGeneratedFiles(),
|
|
3659
3898
|
npmVersion
|
|
@@ -3675,7 +3914,7 @@ async function runUpdate(options) {
|
|
|
3675
3914
|
log(`npm package up to date: ${currentVersion}`);
|
|
3676
3915
|
}
|
|
3677
3916
|
if (await hasLocalOverrides(root)) {
|
|
3678
|
-
log("
|
|
3917
|
+
log("Existing .claude/settings.json \u2014 haus rules will be merged, not replaced.");
|
|
3679
3918
|
}
|
|
3680
3919
|
const { before, after } = await applyLock(root);
|
|
3681
3920
|
log(diffLock(before, after));
|
|
@@ -3691,11 +3930,53 @@ async function runUpdate(options) {
|
|
|
3691
3930
|
if (sync.failed.length > 0) {
|
|
3692
3931
|
warn(`Failed to fetch ${sync.failed.length} item(s): ${sync.failed.join(", ")}`);
|
|
3693
3932
|
}
|
|
3933
|
+
await refreshGlobalInstall();
|
|
3934
|
+
await refreshProjectFiles(root);
|
|
3694
3935
|
log("Update applied with backup in .haus-workflow/backups/. Run haus doctor.");
|
|
3695
3936
|
}
|
|
3937
|
+
async function refreshProjectFiles(root) {
|
|
3938
|
+
log("Refreshing project .claude/ files...");
|
|
3939
|
+
try {
|
|
3940
|
+
const files = await refreshProjectApply(root);
|
|
3941
|
+
if (files.length === 0) {
|
|
3942
|
+
log("No prior haus project setup detected \u2014 skipped project re-apply.");
|
|
3943
|
+
return;
|
|
3944
|
+
}
|
|
3945
|
+
log(`Project refreshed: ${files.length} managed path(s) updated.`);
|
|
3946
|
+
} catch (err) {
|
|
3947
|
+
warn(`Could not refresh project files: ${err instanceof Error ? err.message : String(err)}`);
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
async function refreshGlobalInstall() {
|
|
3951
|
+
log("Refreshing ~/.claude/ global files...");
|
|
3952
|
+
try {
|
|
3953
|
+
const result = await applyInstall({});
|
|
3954
|
+
const total = result.created.length + result.updated.length;
|
|
3955
|
+
if (total > 0) {
|
|
3956
|
+
log(`~/.claude refreshed: ${result.created.length} added, ${result.updated.length} updated.`);
|
|
3957
|
+
} else {
|
|
3958
|
+
log("~/.claude already up to date.");
|
|
3959
|
+
}
|
|
3960
|
+
if (result.skipped.length > 0) {
|
|
3961
|
+
log(
|
|
3962
|
+
`Preserved ${result.skipped.length} locally-edited file(s) (run \`haus install --force\` to overwrite).`
|
|
3963
|
+
);
|
|
3964
|
+
}
|
|
3965
|
+
} catch (err) {
|
|
3966
|
+
warn(`Could not refresh ~/.claude/: ${err instanceof Error ? err.message : String(err)}`);
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
async function detectGlobalInstallDrift() {
|
|
3970
|
+
try {
|
|
3971
|
+
const result = await applyInstall({ check: true });
|
|
3972
|
+
return result.drift;
|
|
3973
|
+
} catch {
|
|
3974
|
+
return null;
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3696
3977
|
|
|
3697
3978
|
// src/commands/validate-catalog.ts
|
|
3698
|
-
import
|
|
3979
|
+
import fs18 from "fs";
|
|
3699
3980
|
import path26 from "path";
|
|
3700
3981
|
function auditForbiddenStacks(items) {
|
|
3701
3982
|
const failures = [];
|
|
@@ -3767,20 +4048,20 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
3767
4048
|
const absPath = path26.join(manifestDir, item.path);
|
|
3768
4049
|
if (item.type === "skill") {
|
|
3769
4050
|
const skillMd = path26.join(absPath, "SKILL.md");
|
|
3770
|
-
if (!
|
|
4051
|
+
if (!fs18.existsSync(skillMd)) {
|
|
3771
4052
|
failures.push(`${item.id}: missing ${path26.relative(manifestDir, skillMd)}`);
|
|
3772
4053
|
continue;
|
|
3773
4054
|
}
|
|
3774
|
-
const text =
|
|
4055
|
+
const text = fs18.readFileSync(skillMd, "utf8");
|
|
3775
4056
|
for (const section of REQUIRED_SKILL_SECTIONS) {
|
|
3776
4057
|
if (!text.includes(section)) failures.push(`${item.id}: SKILL.md missing ${section}`);
|
|
3777
4058
|
}
|
|
3778
4059
|
} else if (item.type === "agent") {
|
|
3779
|
-
if (!
|
|
4060
|
+
if (!fs18.existsSync(absPath)) {
|
|
3780
4061
|
failures.push(`${item.id}: missing agent file ${item.path}`);
|
|
3781
4062
|
continue;
|
|
3782
4063
|
}
|
|
3783
|
-
const text =
|
|
4064
|
+
const text = fs18.readFileSync(absPath, "utf8");
|
|
3784
4065
|
if (!text.startsWith("---")) failures.push(`${item.id}: agent file missing YAML frontmatter`);
|
|
3785
4066
|
for (const section of REQUIRED_AGENT_SECTIONS) {
|
|
3786
4067
|
if (!text.includes(section)) failures.push(`${item.id}: agent file missing ${section}`);
|
|
@@ -3791,7 +4072,7 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
3791
4072
|
failures.push(`${item.id}: agent file contains disallowed phrase "${phrase}"`);
|
|
3792
4073
|
}
|
|
3793
4074
|
} else if (item.type === "template") {
|
|
3794
|
-
if (!
|
|
4075
|
+
if (!fs18.existsSync(absPath)) {
|
|
3795
4076
|
failures.push(`${item.id}: missing template file ${item.path}`);
|
|
3796
4077
|
}
|
|
3797
4078
|
}
|
|
@@ -3803,9 +4084,9 @@ function auditMarkdownContent(manifestDir) {
|
|
|
3803
4084
|
const dirs = ["skills", "agents"];
|
|
3804
4085
|
for (const dir of dirs) {
|
|
3805
4086
|
const abs = path26.join(manifestDir, dir);
|
|
3806
|
-
if (!
|
|
4087
|
+
if (!fs18.existsSync(abs)) continue;
|
|
3807
4088
|
walkMd(abs, (file) => {
|
|
3808
|
-
const text =
|
|
4089
|
+
const text = fs18.readFileSync(file, "utf8");
|
|
3809
4090
|
const rel = path26.relative(manifestDir, file);
|
|
3810
4091
|
const lines = text.split(/\r?\n/);
|
|
3811
4092
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -3825,19 +4106,19 @@ function auditMarkdownContent(manifestDir) {
|
|
|
3825
4106
|
return failures;
|
|
3826
4107
|
}
|
|
3827
4108
|
function walkMd(dir, fn) {
|
|
3828
|
-
for (const entry of
|
|
4109
|
+
for (const entry of fs18.readdirSync(dir, { withFileTypes: true })) {
|
|
3829
4110
|
const full = path26.join(dir, entry.name);
|
|
3830
4111
|
if (entry.isDirectory()) walkMd(full, fn);
|
|
3831
4112
|
else if (entry.name.endsWith(".md")) fn(full);
|
|
3832
4113
|
}
|
|
3833
4114
|
}
|
|
3834
|
-
async function runValidateCatalog(
|
|
3835
|
-
if (!
|
|
4115
|
+
async function runValidateCatalog(manifestPath2) {
|
|
4116
|
+
if (!manifestPath2) {
|
|
3836
4117
|
error("Usage: haus validate-catalog <path/to/manifest.json>");
|
|
3837
4118
|
process.exitCode = 1;
|
|
3838
4119
|
return;
|
|
3839
4120
|
}
|
|
3840
|
-
const abs = path26.resolve(process.cwd(),
|
|
4121
|
+
const abs = path26.resolve(process.cwd(), manifestPath2);
|
|
3841
4122
|
const manifestDir = path26.dirname(abs);
|
|
3842
4123
|
const data = await readJson(abs);
|
|
3843
4124
|
if (!data?.items) {
|
|
@@ -3867,77 +4148,665 @@ async function runValidateCatalog(manifestPath) {
|
|
|
3867
4148
|
}
|
|
3868
4149
|
|
|
3869
4150
|
// src/commands/workspace.ts
|
|
4151
|
+
import { existsSync as existsSync4, statSync as statSync2 } from "fs";
|
|
4152
|
+
import path33 from "path";
|
|
4153
|
+
|
|
4154
|
+
// src/commands/workspace/aggregate.ts
|
|
4155
|
+
async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
|
|
4156
|
+
const summaries = repos.map((repo) => ({
|
|
4157
|
+
name: repo.name,
|
|
4158
|
+
path: repo.path,
|
|
4159
|
+
roles: repo.context.repoRoles ?? [],
|
|
4160
|
+
packageManager: repo.context.packageManager,
|
|
4161
|
+
deps: repo.context.dependencies ?? []
|
|
4162
|
+
}));
|
|
4163
|
+
const ownership = {};
|
|
4164
|
+
for (const repo of summaries) {
|
|
4165
|
+
for (const dep2 of repo.deps) {
|
|
4166
|
+
ownership[dep2] ??= [];
|
|
4167
|
+
ownership[dep2].push(repo.name);
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
const roles = [...new Set(summaries.flatMap((r) => r.roles))].sort();
|
|
4171
|
+
const crossRepoHints = [...new Set(repos.flatMap((r) => r.context.crossRepoHints ?? []))].sort();
|
|
4172
|
+
const summaryPath = hausPath(workspaceRoot, "workspace-summary.json");
|
|
4173
|
+
const ownershipPath = hausPath(workspaceRoot, "dependency-ownership-map.json");
|
|
4174
|
+
const crossRepoPath = hausPath(workspaceRoot, "cross-repo-summary.md");
|
|
4175
|
+
const contextMapPath = hausPath(workspaceRoot, "workspace-context-map.json");
|
|
4176
|
+
await writeJson(summaryPath, {
|
|
4177
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4178
|
+
repos: summaries
|
|
4179
|
+
});
|
|
4180
|
+
await writeJson(ownershipPath, ownership);
|
|
4181
|
+
await writeText(
|
|
4182
|
+
crossRepoPath,
|
|
4183
|
+
`# Cross Repo Summary
|
|
4184
|
+
|
|
4185
|
+
${summaries.map(
|
|
4186
|
+
(repo) => `- ${repo.name} (${repo.path}) roles: ${repo.roles.join(", ") || "unknown"}; package manager: ${repo.packageManager}`
|
|
4187
|
+
).join("\n")}
|
|
4188
|
+
`
|
|
4189
|
+
);
|
|
4190
|
+
await writeJson(contextMapPath, {
|
|
4191
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4192
|
+
roles,
|
|
4193
|
+
crossRepoHints,
|
|
4194
|
+
repos: summaries.map((r) => ({
|
|
4195
|
+
name: r.name,
|
|
4196
|
+
path: r.path,
|
|
4197
|
+
roles: r.roles,
|
|
4198
|
+
packageManager: r.packageManager
|
|
4199
|
+
})),
|
|
4200
|
+
relationships
|
|
4201
|
+
});
|
|
4202
|
+
return [summaryPath, ownershipPath, crossRepoPath, contextMapPath];
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
// src/commands/workspace/config.ts
|
|
3870
4206
|
import path27 from "path";
|
|
3871
4207
|
import YAML from "yaml";
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
4208
|
+
var WORKSPACE_FILE = "haus.workspace.yaml";
|
|
4209
|
+
function parseWorkspaceConfig(text) {
|
|
4210
|
+
if (!text) return void 0;
|
|
4211
|
+
let parsed;
|
|
4212
|
+
try {
|
|
4213
|
+
parsed = YAML.parse(text);
|
|
4214
|
+
} catch {
|
|
4215
|
+
return void 0;
|
|
4216
|
+
}
|
|
4217
|
+
if (!parsed || typeof parsed !== "object") return void 0;
|
|
4218
|
+
const obj = parsed;
|
|
4219
|
+
const repos = Array.isArray(obj.repos) ? obj.repos.filter(
|
|
4220
|
+
(r) => typeof r === "object" && r !== null && typeof r.name === "string" && typeof r.path === "string"
|
|
4221
|
+
) : [];
|
|
4222
|
+
return {
|
|
4223
|
+
client: typeof obj.client === "string" ? obj.client : "unknown",
|
|
4224
|
+
repos,
|
|
4225
|
+
relationships: Array.isArray(obj.relationships) ? obj.relationships : []
|
|
4226
|
+
};
|
|
4227
|
+
}
|
|
4228
|
+
async function readWorkspaceConfig(workspaceRoot) {
|
|
4229
|
+
return parseWorkspaceConfig(await readText(path27.join(workspaceRoot, WORKSPACE_FILE)));
|
|
4230
|
+
}
|
|
4231
|
+
|
|
4232
|
+
// src/commands/workspace/discover.ts
|
|
4233
|
+
import path28 from "path";
|
|
4234
|
+
import fg3 from "fast-glob";
|
|
4235
|
+
import YAML2 from "yaml";
|
|
4236
|
+
var DEFAULT_MAX_DEPTH = 3;
|
|
4237
|
+
var REPO_MARKERS = ["**/.git", "**/package.json", "**/composer.json"];
|
|
4238
|
+
var IGNORE = [
|
|
4239
|
+
"**/node_modules/**",
|
|
4240
|
+
"**/.git/**",
|
|
4241
|
+
"**/vendor/**",
|
|
4242
|
+
"**/dist/**",
|
|
4243
|
+
"**/.haus-workflow/**"
|
|
4244
|
+
];
|
|
4245
|
+
function isDescendant(child, ancestor) {
|
|
4246
|
+
if (ancestor === ".") return child !== ".";
|
|
4247
|
+
return child === ancestor ? false : child.startsWith(`${ancestor}/`);
|
|
4248
|
+
}
|
|
4249
|
+
async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
4250
|
+
const matches = await fg3(REPO_MARKERS, {
|
|
4251
|
+
cwd: workspaceRoot,
|
|
4252
|
+
dot: true,
|
|
4253
|
+
onlyFiles: false,
|
|
4254
|
+
deep: maxDepth,
|
|
4255
|
+
followSymbolicLinks: false,
|
|
4256
|
+
ignore: IGNORE
|
|
4257
|
+
});
|
|
4258
|
+
const gitDirs = /* @__PURE__ */ new Set();
|
|
4259
|
+
const manifestDirs = /* @__PURE__ */ new Set();
|
|
4260
|
+
for (const match of matches) {
|
|
4261
|
+
const base = path28.posix.basename(match);
|
|
4262
|
+
const dir = path28.posix.dirname(match);
|
|
4263
|
+
const owner = dir === "." ? "." : dir;
|
|
4264
|
+
if (base === ".git") gitDirs.add(owner);
|
|
4265
|
+
else manifestDirs.add(owner);
|
|
4266
|
+
}
|
|
4267
|
+
const repoRoots = [...gitDirs];
|
|
4268
|
+
const manifestSorted = [...manifestDirs].sort(
|
|
4269
|
+
(a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b)
|
|
4270
|
+
);
|
|
4271
|
+
for (const dir of manifestSorted) {
|
|
4272
|
+
if (gitDirs.has(dir)) continue;
|
|
4273
|
+
if (repoRoots.some((root) => isDescendant(dir, root))) continue;
|
|
4274
|
+
repoRoots.push(dir);
|
|
4275
|
+
}
|
|
4276
|
+
repoRoots.sort((a, b) => a.localeCompare(b));
|
|
4277
|
+
return mapWithConcurrency(repoRoots, async (relDir) => {
|
|
4278
|
+
const absDir = path28.resolve(workspaceRoot, relDir);
|
|
4279
|
+
const pkg = await readJson(path28.join(absDir, "package.json"));
|
|
4280
|
+
const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path28.basename(relDir === "." ? workspaceRoot : absDir);
|
|
4281
|
+
let role = "auto";
|
|
4282
|
+
try {
|
|
4283
|
+
const scan = await scanProject(absDir, "fast");
|
|
4284
|
+
if (scan.repoRoles[0]) role = scan.repoRoles[0];
|
|
4285
|
+
} catch {
|
|
4286
|
+
}
|
|
4287
|
+
return { name, path: relDir === "." ? "." : relDir, role };
|
|
4288
|
+
});
|
|
4289
|
+
}
|
|
4290
|
+
function mergeWorkspaceConfig(existing, discovered, opts = {}) {
|
|
4291
|
+
const existingRepos = existing?.repos ?? [];
|
|
4292
|
+
const byPath = new Map(existingRepos.map((r) => [r.path, r]));
|
|
4293
|
+
for (const repo of discovered) {
|
|
4294
|
+
if (!byPath.has(repo.path)) {
|
|
4295
|
+
byPath.set(repo.path, { name: repo.name, path: repo.path, role: repo.role });
|
|
4296
|
+
}
|
|
4297
|
+
}
|
|
4298
|
+
const ordered = [];
|
|
4299
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4300
|
+
for (const repo of existingRepos) {
|
|
4301
|
+
ordered.push(byPath.get(repo.path));
|
|
4302
|
+
seen.add(repo.path);
|
|
4303
|
+
}
|
|
4304
|
+
for (const repo of discovered) {
|
|
4305
|
+
if (seen.has(repo.path)) continue;
|
|
4306
|
+
ordered.push(byPath.get(repo.path));
|
|
4307
|
+
seen.add(repo.path);
|
|
4308
|
+
}
|
|
4309
|
+
return {
|
|
4310
|
+
client: opts.client ?? existing?.client ?? "unknown",
|
|
4311
|
+
repos: ordered,
|
|
4312
|
+
relationships: existing?.relationships ?? []
|
|
4313
|
+
};
|
|
4314
|
+
}
|
|
4315
|
+
function renderWorkspaceYaml(config2) {
|
|
4316
|
+
return YAML2.stringify({
|
|
4317
|
+
client: config2.client,
|
|
4318
|
+
repos: config2.repos.map((r) => ({ name: r.name, path: r.path, role: r.role ?? "auto" })),
|
|
4319
|
+
relationships: config2.relationships
|
|
4320
|
+
});
|
|
4321
|
+
}
|
|
4322
|
+
async function runDiscover(workspaceRoot, opts = {}) {
|
|
4323
|
+
const yamlPath = path28.join(workspaceRoot, "haus.workspace.yaml");
|
|
4324
|
+
const existingText = await readText(yamlPath);
|
|
4325
|
+
const existing = parseWorkspaceConfig(existingText);
|
|
4326
|
+
if (existingText && !existing) {
|
|
4327
|
+
error(
|
|
4328
|
+
"Existing haus.workspace.yaml is malformed \u2014 fix or remove it before running discover (refusing to overwrite)."
|
|
3883
4329
|
);
|
|
3884
|
-
|
|
4330
|
+
process.exitCode = 1;
|
|
3885
4331
|
return;
|
|
3886
4332
|
}
|
|
3887
|
-
const
|
|
3888
|
-
if (
|
|
3889
|
-
error("
|
|
4333
|
+
const discovered = await discoverRepos(workspaceRoot, opts.maxDepth ?? DEFAULT_MAX_DEPTH);
|
|
4334
|
+
if (discovered.length === 0) {
|
|
4335
|
+
error("No repos discovered under the workspace root.");
|
|
3890
4336
|
process.exitCode = 1;
|
|
3891
4337
|
return;
|
|
3892
4338
|
}
|
|
3893
|
-
const
|
|
3894
|
-
const
|
|
3895
|
-
if (
|
|
3896
|
-
|
|
3897
|
-
|
|
4339
|
+
const merged = mergeWorkspaceConfig(existing, discovered, { client: opts.client });
|
|
4340
|
+
const yamlText = renderWorkspaceYaml(merged);
|
|
4341
|
+
if (opts.json) {
|
|
4342
|
+
log(JSON.stringify({ discovered, config: merged }, null, 2));
|
|
4343
|
+
}
|
|
4344
|
+
if (opts.write) {
|
|
4345
|
+
await writeText(yamlPath, yamlText);
|
|
4346
|
+
log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
|
|
3898
4347
|
return;
|
|
3899
4348
|
}
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
4349
|
+
if (!opts.json) {
|
|
4350
|
+
log("Proposed haus.workspace.yaml (run with --write to persist):\n");
|
|
4351
|
+
log(yamlText);
|
|
4352
|
+
}
|
|
4353
|
+
}
|
|
4354
|
+
|
|
4355
|
+
// src/commands/workspace/doctor.ts
|
|
4356
|
+
import { existsSync as existsSync2 } from "fs";
|
|
4357
|
+
import path30 from "path";
|
|
4358
|
+
|
|
4359
|
+
// src/commands/workspace/manifest.ts
|
|
4360
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
4361
|
+
import path29 from "path";
|
|
4362
|
+
var MANIFEST_FILE = "workspace.manifest.json";
|
|
4363
|
+
function manifestPath(workspaceRoot) {
|
|
4364
|
+
return hausPath(workspaceRoot, MANIFEST_FILE);
|
|
4365
|
+
}
|
|
4366
|
+
function hausVersion() {
|
|
4367
|
+
try {
|
|
4368
|
+
const pkg = JSON.parse(readFileSync3(path29.join(packageRoot(), "package.json"), "utf8"));
|
|
4369
|
+
return pkg.version ?? "0.0.0";
|
|
4370
|
+
} catch {
|
|
4371
|
+
return "0.0.0";
|
|
4372
|
+
}
|
|
4373
|
+
}
|
|
4374
|
+
function buildManifest2(opts) {
|
|
4375
|
+
const now = opts.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4376
|
+
const version = opts.version ?? hausVersion();
|
|
4377
|
+
return {
|
|
4378
|
+
version: 1,
|
|
4379
|
+
generatedAt: now,
|
|
4380
|
+
hausVersion: version,
|
|
4381
|
+
client: opts.client,
|
|
4382
|
+
repos: opts.repos.map((repo) => ({
|
|
3906
4383
|
name: repo.name,
|
|
3907
4384
|
path: repo.path,
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
4385
|
+
role: repo.role,
|
|
4386
|
+
lastSetupAt: repo.lastSetupAt !== void 0 ? repo.lastSetupAt : repo.status === "ok" ? now : null,
|
|
4387
|
+
hausVersionAtSetup: repo.hausVersionAtSetup !== void 0 ? repo.hausVersionAtSetup : repo.status === "ok" ? version : null,
|
|
4388
|
+
lockItemCount: repo.lockItemCount,
|
|
4389
|
+
catalogRef: repo.catalogRef,
|
|
4390
|
+
status: repo.status,
|
|
4391
|
+
...repo.error ? { error: repo.error } : {}
|
|
4392
|
+
}))
|
|
4393
|
+
};
|
|
4394
|
+
}
|
|
4395
|
+
async function readManifest2(workspaceRoot) {
|
|
4396
|
+
return readJson(manifestPath(workspaceRoot));
|
|
4397
|
+
}
|
|
4398
|
+
async function writeWorkspaceManifest(workspaceRoot, manifest) {
|
|
4399
|
+
const target = manifestPath(workspaceRoot);
|
|
4400
|
+
await writeJson(target, manifest);
|
|
4401
|
+
return target;
|
|
4402
|
+
}
|
|
4403
|
+
|
|
4404
|
+
// src/commands/workspace/doctor.ts
|
|
4405
|
+
async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
4406
|
+
const config2 = await readWorkspaceConfig(workspaceRoot);
|
|
4407
|
+
const manifest = await readManifest2(workspaceRoot);
|
|
4408
|
+
const currentVersion = hausVersion();
|
|
4409
|
+
const drift = [];
|
|
4410
|
+
const detail = [];
|
|
4411
|
+
const ok = (text) => detail.push({ stream: "log", text });
|
|
4412
|
+
const flag = (item) => {
|
|
4413
|
+
drift.push(item);
|
|
4414
|
+
detail.push({ stream: "warn", text: `- ${item.repo}: ${item.detail}` });
|
|
4415
|
+
};
|
|
4416
|
+
if (!config2) {
|
|
4417
|
+
flag({
|
|
4418
|
+
repo: "(workspace)",
|
|
4419
|
+
kind: "no-config",
|
|
4420
|
+
detail: "Missing or malformed haus.workspace.yaml \u2014 run `haus workspace discover --write` or `init`."
|
|
3911
4421
|
});
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
4422
|
+
return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
|
|
4423
|
+
}
|
|
4424
|
+
if (!manifest) {
|
|
4425
|
+
flag({
|
|
4426
|
+
repo: "(workspace)",
|
|
4427
|
+
kind: "no-manifest",
|
|
4428
|
+
detail: "No workspace.manifest.json \u2014 run `haus workspace setup --write` first."
|
|
4429
|
+
});
|
|
4430
|
+
return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
|
|
4431
|
+
}
|
|
4432
|
+
const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
|
|
4433
|
+
for (const repo of config2.repos) {
|
|
4434
|
+
const repoRoot = path30.resolve(workspaceRoot, repo.path);
|
|
4435
|
+
const entry = manifestByName.get(repo.name);
|
|
4436
|
+
if (!entry) {
|
|
4437
|
+
flag({
|
|
4438
|
+
repo: repo.name,
|
|
4439
|
+
kind: "missing-from-manifest",
|
|
4440
|
+
detail: "Configured in yaml but absent from the manifest \u2014 run `haus workspace setup --write`."
|
|
4441
|
+
});
|
|
4442
|
+
continue;
|
|
4443
|
+
}
|
|
4444
|
+
const driftBefore = drift.length;
|
|
4445
|
+
if (entry.status === "failed") {
|
|
4446
|
+
flag({
|
|
4447
|
+
repo: repo.name,
|
|
4448
|
+
kind: "failed",
|
|
4449
|
+
detail: `Last setup failed${entry.error ? `: ${entry.error}` : ""}.`
|
|
4450
|
+
});
|
|
4451
|
+
}
|
|
4452
|
+
if (entry.hausVersionAtSetup && entry.hausVersionAtSetup !== currentVersion) {
|
|
4453
|
+
flag({
|
|
4454
|
+
repo: repo.name,
|
|
4455
|
+
kind: "version-mismatch",
|
|
4456
|
+
detail: `Set up at haus ${entry.hausVersionAtSetup}, current is ${currentVersion} \u2014 re-run setup.`
|
|
4457
|
+
});
|
|
4458
|
+
}
|
|
4459
|
+
if (!existsSync2(claudePath(repoRoot))) {
|
|
4460
|
+
flag({
|
|
4461
|
+
repo: repo.name,
|
|
4462
|
+
kind: "missing-claude",
|
|
4463
|
+
detail: "Missing .claude/ \u2014 run `haus workspace setup --write`."
|
|
4464
|
+
});
|
|
4465
|
+
}
|
|
4466
|
+
const lock = await checkLock(repoRoot);
|
|
4467
|
+
if (!existsSync2(hausPath(repoRoot, "haus.lock.json"))) {
|
|
4468
|
+
flag({
|
|
4469
|
+
repo: repo.name,
|
|
4470
|
+
kind: "missing-lock",
|
|
4471
|
+
detail: "Missing .haus-workflow/haus.lock.json \u2014 run `haus workspace setup --write`."
|
|
4472
|
+
});
|
|
4473
|
+
} else if (lock.count > 0 && !lock.ok) {
|
|
4474
|
+
flag({
|
|
4475
|
+
repo: repo.name,
|
|
4476
|
+
kind: "invalid-lock",
|
|
4477
|
+
detail: "haus.lock.json present but invalid \u2014 re-run `haus workspace setup --write`."
|
|
4478
|
+
});
|
|
4479
|
+
}
|
|
4480
|
+
if (drift.length === driftBefore) {
|
|
4481
|
+
ok(`- ${repo.name}: OK (${lock.count} lock item(s))`);
|
|
3915
4482
|
}
|
|
3916
4483
|
}
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
}
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
3924
|
-
|
|
4484
|
+
return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
|
|
4485
|
+
}
|
|
4486
|
+
function emit(args) {
|
|
4487
|
+
const { workspaceRoot, manifest, drift, detail } = args;
|
|
4488
|
+
if (args.json) {
|
|
4489
|
+
log(JSON.stringify({ manifest: manifest ?? null, drift }, null, 2));
|
|
4490
|
+
} else {
|
|
4491
|
+
if (drift.length === 0) {
|
|
4492
|
+
log("\u2705 Workspace is set up and healthy.");
|
|
4493
|
+
} else {
|
|
4494
|
+
log(`\u26A0\uFE0F ${drift.length} workspace drift item(s) need attention:`);
|
|
4495
|
+
}
|
|
4496
|
+
log("Haus Workspace Doctor");
|
|
4497
|
+
for (const line2 of detail) {
|
|
4498
|
+
if (line2.stream === "warn") warn(line2.text);
|
|
4499
|
+
else log(line2.text);
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4502
|
+
if (drift.length > 0) process.exitCode = 1;
|
|
4503
|
+
return { workspaceRoot, manifest, drift };
|
|
4504
|
+
}
|
|
3925
4505
|
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
4506
|
+
// src/commands/workspace/setup.ts
|
|
4507
|
+
import { existsSync as existsSync3, statSync } from "fs";
|
|
4508
|
+
import path32 from "path";
|
|
4509
|
+
|
|
4510
|
+
// src/claude/write-workspace-claude-md.ts
|
|
4511
|
+
import path31 from "path";
|
|
4512
|
+
import fs19 from "fs-extra";
|
|
4513
|
+
function buildWorkspaceImportBlock(client, members) {
|
|
4514
|
+
const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
|
|
4515
|
+
const body = [
|
|
4516
|
+
"@.haus-workflow/cross-repo-summary.md",
|
|
4517
|
+
"",
|
|
4518
|
+
`# Workspace: ${client}`,
|
|
4519
|
+
"",
|
|
4520
|
+
"Member repos:",
|
|
4521
|
+
...memberLines
|
|
4522
|
+
].join("\n");
|
|
4523
|
+
return `${BLOCK_BEGIN}
|
|
4524
|
+
${body}
|
|
4525
|
+
${BLOCK_END}`;
|
|
4526
|
+
}
|
|
4527
|
+
async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
4528
|
+
const block = buildWorkspaceImportBlock(opts.client, opts.members);
|
|
4529
|
+
const dryRun = opts.dryRun ?? false;
|
|
4530
|
+
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path31.join(workspaceRoot, "CLAUDE.md");
|
|
4531
|
+
const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
|
|
4532
|
+
const next = opts.collision ? `${block}
|
|
4533
|
+
` : injectHausBlock(prev, block);
|
|
4534
|
+
const printable = displayPath(workspaceRoot, filePath);
|
|
4535
|
+
if (dryRun) {
|
|
4536
|
+
if (!prev) {
|
|
4537
|
+
log(createUnifiedDiff(printable, "", next));
|
|
4538
|
+
} else if (hasTextChanged(prev, next)) {
|
|
4539
|
+
log(createUnifiedDiff(printable, prev, next));
|
|
4540
|
+
} else {
|
|
4541
|
+
log(`${printable}: unchanged`);
|
|
4542
|
+
}
|
|
4543
|
+
return filePath;
|
|
4544
|
+
}
|
|
4545
|
+
if (hasTextChanged(prev, next) && prev.length > 0) {
|
|
4546
|
+
const diffText = createUnifiedDiff(printable, prev, next);
|
|
4547
|
+
const summary = summarizeDiff(diffText);
|
|
4548
|
+
log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
|
|
4549
|
+
}
|
|
4550
|
+
await writeText(filePath, next);
|
|
4551
|
+
return filePath;
|
|
4552
|
+
}
|
|
4553
|
+
|
|
4554
|
+
// src/commands/workspace/setup.ts
|
|
4555
|
+
function resolveWorkspaceRoot(start = process.cwd()) {
|
|
4556
|
+
let dir = path32.resolve(start);
|
|
4557
|
+
for (; ; ) {
|
|
4558
|
+
if (existsSync3(path32.join(dir, WORKSPACE_FILE))) return dir;
|
|
4559
|
+
const parent = path32.dirname(dir);
|
|
4560
|
+
if (parent === dir) return path32.resolve(start);
|
|
4561
|
+
dir = parent;
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
function isRootRepo(workspaceRoot, repoPath) {
|
|
4565
|
+
return path32.resolve(workspaceRoot, repoPath) === path32.resolve(workspaceRoot);
|
|
4566
|
+
}
|
|
4567
|
+
async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
4568
|
+
const mode = options.mode ?? "fast";
|
|
4569
|
+
const apply = options.write ?? false;
|
|
4570
|
+
const configText = await readText(path32.join(workspaceRoot, WORKSPACE_FILE));
|
|
4571
|
+
if (!configText) {
|
|
4572
|
+
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
4573
|
+
process.exitCode = 1;
|
|
4574
|
+
return { workspaceRoot, statuses: [], written: [] };
|
|
4575
|
+
}
|
|
4576
|
+
const config2 = parseWorkspaceConfig(configText);
|
|
4577
|
+
if (!config2 || config2.repos.length === 0) {
|
|
4578
|
+
error(`No repos configured in ${WORKSPACE_FILE}.`);
|
|
4579
|
+
process.exitCode = 1;
|
|
4580
|
+
return { workspaceRoot, statuses: [], written: [] };
|
|
4581
|
+
}
|
|
4582
|
+
const onlySet = options.only && options.only.length > 0 ? new Set(options.only) : void 0;
|
|
4583
|
+
const repos = onlySet ? config2.repos.filter((r) => onlySet.has(r.name)) : config2.repos;
|
|
4584
|
+
const statuses = [];
|
|
4585
|
+
const aggregateInputs = [];
|
|
4586
|
+
for (const repo of repos) {
|
|
4587
|
+
const repoRoot = path32.resolve(workspaceRoot, repo.path);
|
|
4588
|
+
log(`
|
|
4589
|
+
\u2192 ${repo.name} (${repo.path})`);
|
|
4590
|
+
try {
|
|
4591
|
+
if (!existsSync3(repoRoot) || !statSync(repoRoot).isDirectory()) {
|
|
4592
|
+
throw new Error(`Repo path is not a directory: ${repo.path}`);
|
|
4593
|
+
}
|
|
4594
|
+
const res = await runSetupCore(repoRoot, {
|
|
4595
|
+
mode,
|
|
4596
|
+
json: options.json,
|
|
4597
|
+
apply,
|
|
4598
|
+
dryRun: options.dryRun
|
|
4599
|
+
});
|
|
4600
|
+
statuses.push({
|
|
4601
|
+
name: repo.name,
|
|
4602
|
+
path: repo.path,
|
|
4603
|
+
root: repoRoot,
|
|
4604
|
+
status: "ok",
|
|
4605
|
+
roles: res.roles,
|
|
4606
|
+
recommendedCount: res.recommendedCount
|
|
4607
|
+
});
|
|
4608
|
+
const context = await readContextOrScan(repoRoot);
|
|
4609
|
+
aggregateInputs.push({ name: repo.name, path: repo.path, context });
|
|
4610
|
+
} catch (err) {
|
|
4611
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4612
|
+
statuses.push({
|
|
4613
|
+
name: repo.name,
|
|
4614
|
+
path: repo.path,
|
|
4615
|
+
root: repoRoot,
|
|
4616
|
+
status: "failed",
|
|
4617
|
+
error: message
|
|
4618
|
+
});
|
|
4619
|
+
if (!options.continueOnError) throw err;
|
|
4620
|
+
error(`Setup failed for ${repo.name}: ${message}`);
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
4623
|
+
const written = [];
|
|
4624
|
+
if (apply && aggregateInputs.length > 0) {
|
|
4625
|
+
const collision = config2.repos.some((r) => isRootRepo(workspaceRoot, r.path));
|
|
4626
|
+
if (!options.dryRun) {
|
|
4627
|
+
const artifacts = await writeWorkspaceArtifacts(
|
|
4628
|
+
workspaceRoot,
|
|
4629
|
+
aggregateInputs,
|
|
4630
|
+
config2.relationships
|
|
4631
|
+
);
|
|
4632
|
+
written.push(...artifacts);
|
|
4633
|
+
}
|
|
4634
|
+
const docPath = await writeWorkspaceClaudeMd(workspaceRoot, {
|
|
4635
|
+
client: config2.client,
|
|
4636
|
+
members: config2.repos.map((r) => ({ name: r.name, path: r.path })),
|
|
4637
|
+
collision,
|
|
4638
|
+
dryRun: options.dryRun
|
|
4639
|
+
});
|
|
4640
|
+
written.push(docPath);
|
|
4641
|
+
}
|
|
4642
|
+
if (apply && !options.dryRun) {
|
|
4643
|
+
const statusByName = new Map(statuses.map((s) => [s.name, s]));
|
|
4644
|
+
const prior = await readManifest2(workspaceRoot);
|
|
4645
|
+
if (!prior && existsSync3(manifestPath(workspaceRoot))) {
|
|
4646
|
+
warn(
|
|
4647
|
+
"Existing workspace.manifest.json is unreadable \u2014 prior per-repo state will not be carried forward."
|
|
4648
|
+
);
|
|
4649
|
+
}
|
|
4650
|
+
const priorByName = new Map((prior?.repos ?? []).map((r) => [r.name, r]));
|
|
4651
|
+
const manifestRepos = [];
|
|
4652
|
+
for (const repo of config2.repos) {
|
|
4653
|
+
const status = statusByName.get(repo.name);
|
|
4654
|
+
const role = repo.role ?? status?.roles?.[0] ?? "auto";
|
|
4655
|
+
if (status?.status === "ok") {
|
|
4656
|
+
const lock = await checkLock(path32.resolve(workspaceRoot, repo.path));
|
|
4657
|
+
manifestRepos.push({
|
|
4658
|
+
name: repo.name,
|
|
4659
|
+
path: repo.path,
|
|
4660
|
+
role,
|
|
4661
|
+
status: "ok",
|
|
4662
|
+
lockItemCount: lock.count,
|
|
4663
|
+
catalogRef: lock.catalogRef
|
|
4664
|
+
});
|
|
4665
|
+
} else if (status?.status === "failed") {
|
|
4666
|
+
manifestRepos.push({
|
|
4667
|
+
name: repo.name,
|
|
4668
|
+
path: repo.path,
|
|
4669
|
+
role,
|
|
4670
|
+
status: "failed",
|
|
4671
|
+
lockItemCount: 0,
|
|
4672
|
+
catalogRef: null,
|
|
4673
|
+
error: status.error
|
|
4674
|
+
});
|
|
4675
|
+
} else {
|
|
4676
|
+
const carried = priorByName.get(repo.name);
|
|
4677
|
+
manifestRepos.push(
|
|
4678
|
+
carried ? {
|
|
4679
|
+
name: carried.name,
|
|
4680
|
+
path: carried.path,
|
|
4681
|
+
role: carried.role,
|
|
4682
|
+
status: carried.status,
|
|
4683
|
+
lockItemCount: carried.lockItemCount,
|
|
4684
|
+
catalogRef: carried.catalogRef,
|
|
4685
|
+
lastSetupAt: carried.lastSetupAt,
|
|
4686
|
+
hausVersionAtSetup: carried.hausVersionAtSetup,
|
|
4687
|
+
...carried.error ? { error: carried.error } : {}
|
|
4688
|
+
} : {
|
|
4689
|
+
name: repo.name,
|
|
4690
|
+
path: repo.path,
|
|
4691
|
+
role,
|
|
4692
|
+
status: "pending",
|
|
4693
|
+
lockItemCount: 0,
|
|
4694
|
+
catalogRef: null
|
|
4695
|
+
}
|
|
4696
|
+
);
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4699
|
+
const manifest = buildManifest2({ client: config2.client, repos: manifestRepos });
|
|
4700
|
+
const manifestFile = await writeWorkspaceManifest(workspaceRoot, manifest);
|
|
4701
|
+
written.push(manifestFile);
|
|
4702
|
+
}
|
|
4703
|
+
const ok = statuses.filter((s) => s.status === "ok").length;
|
|
4704
|
+
const failed = statuses.length - ok;
|
|
4705
|
+
log(`
|
|
4706
|
+
Workspace setup complete: ${ok} ok, ${failed} failed.`);
|
|
4707
|
+
return { workspaceRoot, statuses, written };
|
|
4708
|
+
}
|
|
4709
|
+
|
|
4710
|
+
// src/commands/workspace.ts
|
|
4711
|
+
function normalizeOnly(only) {
|
|
4712
|
+
if (!only) return void 0;
|
|
4713
|
+
const list = Array.isArray(only) ? only : only.split(/[\s,]+/);
|
|
4714
|
+
const cleaned = list.map((s) => s.trim()).filter(Boolean);
|
|
4715
|
+
return cleaned.length > 0 ? cleaned : void 0;
|
|
4716
|
+
}
|
|
4717
|
+
function normalizeMaxDepth(maxDepth) {
|
|
4718
|
+
if (maxDepth === void 0) return void 0;
|
|
4719
|
+
const n = typeof maxDepth === "number" ? maxDepth : Number.parseInt(maxDepth, 10);
|
|
4720
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : void 0;
|
|
4721
|
+
}
|
|
4722
|
+
async function initWorkspace() {
|
|
4723
|
+
await writeText(
|
|
4724
|
+
WORKSPACE_FILE,
|
|
4725
|
+
`client: unknown
|
|
4726
|
+
repos:
|
|
4727
|
+
- name: current
|
|
4728
|
+
path: .
|
|
4729
|
+
role: auto
|
|
4730
|
+
relationships: []
|
|
3929
4731
|
`
|
|
3930
4732
|
);
|
|
3931
|
-
log(
|
|
3932
|
-
|
|
3933
|
-
|
|
4733
|
+
log("Workspace initialized.");
|
|
4734
|
+
}
|
|
4735
|
+
async function scanWorkspace(workspaceRoot, opts) {
|
|
4736
|
+
const configText = await readText(path33.join(workspaceRoot, WORKSPACE_FILE));
|
|
4737
|
+
if (!configText) {
|
|
4738
|
+
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
4739
|
+
process.exitCode = 1;
|
|
4740
|
+
return;
|
|
4741
|
+
}
|
|
4742
|
+
const config2 = parseWorkspaceConfig(configText);
|
|
4743
|
+
if (!config2) {
|
|
4744
|
+
error(
|
|
4745
|
+
`Malformed ${WORKSPACE_FILE}. Fix the YAML or re-run \`haus workspace discover --write\`.`
|
|
4746
|
+
);
|
|
4747
|
+
process.exitCode = 1;
|
|
4748
|
+
return;
|
|
4749
|
+
}
|
|
4750
|
+
if (config2.repos.length === 0) {
|
|
4751
|
+
error(`No repos configured in ${WORKSPACE_FILE}.`);
|
|
4752
|
+
process.exitCode = 1;
|
|
4753
|
+
return;
|
|
4754
|
+
}
|
|
4755
|
+
const inputs = [];
|
|
4756
|
+
for (const repo of config2.repos) {
|
|
4757
|
+
const repoRoot = path33.resolve(workspaceRoot, repo.path);
|
|
4758
|
+
if (!existsSync4(repoRoot) || !statSync2(repoRoot).isDirectory()) {
|
|
4759
|
+
throw new Error(`Repo path is not a directory: ${repo.path}`);
|
|
4760
|
+
}
|
|
4761
|
+
const result = await scanProject(repoRoot, "fast");
|
|
4762
|
+
inputs.push({ name: repo.name, path: repo.path, context: result });
|
|
4763
|
+
}
|
|
4764
|
+
const written = await writeWorkspaceArtifacts(workspaceRoot, inputs, config2.relationships);
|
|
4765
|
+
if (opts.json) {
|
|
4766
|
+
log(JSON.stringify({ written }, null, 2));
|
|
4767
|
+
} else {
|
|
4768
|
+
log(`Workspace scan complete. Wrote ${written.length} artifact(s) under .haus-workflow/.`);
|
|
4769
|
+
}
|
|
4770
|
+
}
|
|
4771
|
+
async function runWorkspace(action, options = {}) {
|
|
4772
|
+
if (action === "init") {
|
|
4773
|
+
await initWorkspace();
|
|
4774
|
+
return;
|
|
4775
|
+
}
|
|
4776
|
+
const workspaceRoot = resolveWorkspaceRoot();
|
|
4777
|
+
switch (action) {
|
|
4778
|
+
case "discover":
|
|
4779
|
+
await runDiscover(workspaceRoot, {
|
|
4780
|
+
write: options.write,
|
|
4781
|
+
json: options.json,
|
|
4782
|
+
maxDepth: normalizeMaxDepth(options.maxDepth),
|
|
4783
|
+
client: options.client
|
|
4784
|
+
});
|
|
4785
|
+
return;
|
|
4786
|
+
case "scan":
|
|
4787
|
+
await scanWorkspace(workspaceRoot, { json: options.json });
|
|
4788
|
+
return;
|
|
4789
|
+
case "setup":
|
|
4790
|
+
await runWorkspaceSetup(workspaceRoot, {
|
|
4791
|
+
mode: options.guided ? "guided" : "fast",
|
|
4792
|
+
write: options.write,
|
|
4793
|
+
dryRun: options.dryRun,
|
|
4794
|
+
json: options.json,
|
|
4795
|
+
continueOnError: options.continueOnError,
|
|
4796
|
+
only: normalizeOnly(options.only)
|
|
4797
|
+
});
|
|
4798
|
+
return;
|
|
4799
|
+
case "doctor":
|
|
4800
|
+
await runWorkspaceDoctor(workspaceRoot, { json: options.json });
|
|
4801
|
+
return;
|
|
4802
|
+
}
|
|
3934
4803
|
}
|
|
3935
4804
|
|
|
3936
4805
|
// src/cli.ts
|
|
3937
4806
|
function cliVersion() {
|
|
3938
4807
|
try {
|
|
3939
|
-
const pkgPath =
|
|
3940
|
-
const pkg = JSON.parse(
|
|
4808
|
+
const pkgPath = path34.join(packageRoot(), "package.json");
|
|
4809
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
3941
4810
|
return pkg.version ?? "0.0.0";
|
|
3942
4811
|
} catch {
|
|
3943
4812
|
return "0.0.0";
|
|
@@ -3946,8 +4815,8 @@ function cliVersion() {
|
|
|
3946
4815
|
var program = new Command();
|
|
3947
4816
|
function validateRuntimeNodeVersion() {
|
|
3948
4817
|
try {
|
|
3949
|
-
const pkgPath =
|
|
3950
|
-
const pkg = JSON.parse(
|
|
4818
|
+
const pkgPath = path34.join(packageRoot(), "package.json");
|
|
4819
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
3951
4820
|
const requiredRange = pkg.engines?.node;
|
|
3952
4821
|
if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
|
|
3953
4822
|
throw new Error(`Node ${process.version} does not satisfy required range ${requiredRange}`);
|
|
@@ -3990,7 +4859,10 @@ config.command("disable <key>").description("Disable a hook (hook.context)").act
|
|
|
3990
4859
|
config.command("status <key>").description("Show current state of a hook (hook.context)").action((key) => runConfig(key, "status"));
|
|
3991
4860
|
var workspace = program.command("workspace");
|
|
3992
4861
|
workspace.command("init").action(() => runWorkspace("init"));
|
|
3993
|
-
workspace.command("
|
|
4862
|
+
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));
|
|
4863
|
+
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));
|
|
4864
|
+
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("--fast", "Skip interactive prompts (default)").option("--guided", "Enable guided Q&A per repo").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));
|
|
4865
|
+
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));
|
|
3994
4866
|
program.parseAsync(process.argv).catch((err) => {
|
|
3995
4867
|
const message = err instanceof Error ? err.message : String(err);
|
|
3996
4868
|
error(message);
|