@haus-tech/haus-workflow 0.14.0 → 0.16.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 +22 -0
- package/dist/cli.js +964 -577
- package/library/global/commands/haus-setup.md +26 -4
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,8 +6,9 @@ import path34 from "path";
|
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
|
|
8
8
|
// src/commands/apply.ts
|
|
9
|
-
import
|
|
9
|
+
import path13 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";
|
|
@@ -31,7 +32,9 @@ var CATALOG_REF = process.env.HAUS_CATALOG_REF ?? "main";
|
|
|
31
32
|
var CATALOG_CACHE_SUBDIR = ".claude/haus/catalog-cache";
|
|
32
33
|
|
|
33
34
|
// src/catalog/remote-catalog.ts
|
|
34
|
-
|
|
35
|
+
function getCacheDir() {
|
|
36
|
+
return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path.join(os.homedir(), CATALOG_CACHE_SUBDIR);
|
|
37
|
+
}
|
|
35
38
|
var REMOTE_BASE = process.env["HAUS_CATALOG_REMOTE_BASE"] ?? `${CATALOG_REPO_URL}/${CATALOG_REF}`;
|
|
36
39
|
var REMOTE_MANIFEST_URL = `${REMOTE_BASE}/manifest.json`;
|
|
37
40
|
async function fetchText(url) {
|
|
@@ -53,15 +56,29 @@ async function fetchRemoteManifest() {
|
|
|
53
56
|
return null;
|
|
54
57
|
}
|
|
55
58
|
}
|
|
59
|
+
async function writeTextIfChanged(dest, text) {
|
|
60
|
+
if (await fs.pathExists(dest)) {
|
|
61
|
+
const local = await fs.readFile(dest, "utf8");
|
|
62
|
+
if (local === text) return "unchanged";
|
|
63
|
+
await fs.writeFile(dest, text, "utf8");
|
|
64
|
+
return "updated";
|
|
65
|
+
}
|
|
66
|
+
await fs.ensureDir(path.dirname(dest));
|
|
67
|
+
await fs.writeFile(dest, text, "utf8");
|
|
68
|
+
return "created";
|
|
69
|
+
}
|
|
56
70
|
var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
|
|
57
71
|
async function readWorkflowTemplate(opts = {}) {
|
|
58
|
-
const dest = path.join(
|
|
59
|
-
if (await fs.pathExists(dest)) return fs.readFile(dest, "utf8");
|
|
72
|
+
const dest = path.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
|
|
60
73
|
const text = await fetchText(`${REMOTE_BASE}/${WORKFLOW_TEMPLATE_REL}`);
|
|
61
|
-
if (text === null)
|
|
74
|
+
if (text === null) {
|
|
75
|
+
if (await fs.pathExists(dest)) return fs.readFile(dest, "utf8");
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
62
78
|
if (!opts.dryRun) {
|
|
63
|
-
await
|
|
64
|
-
|
|
79
|
+
await writeTextIfChanged(dest, text);
|
|
80
|
+
} else if (await fs.pathExists(dest)) {
|
|
81
|
+
return fs.readFile(dest, "utf8");
|
|
65
82
|
}
|
|
66
83
|
return text;
|
|
67
84
|
}
|
|
@@ -86,30 +103,37 @@ async function downloadSkillReferences(item, destDir) {
|
|
|
86
103
|
warn(`Skipping reference "${ref}" for ${item.id}: path traversal detected`);
|
|
87
104
|
continue;
|
|
88
105
|
}
|
|
89
|
-
if (await fs.pathExists(refDest)) continue;
|
|
90
106
|
const text = await fetchText(`${REMOTE_BASE}/${item.path}/${ref}`);
|
|
91
107
|
if (text === null) {
|
|
92
108
|
warn(`Failed to fetch reference "${ref}" for ${item.id}`);
|
|
93
109
|
continue;
|
|
94
110
|
}
|
|
95
|
-
await
|
|
96
|
-
await fs.writeFile(refDest, text, "utf8");
|
|
111
|
+
await writeTextIfChanged(refDest, text);
|
|
97
112
|
}
|
|
98
113
|
}
|
|
99
114
|
async function syncRemoteCatalog() {
|
|
100
115
|
const items = await fetchRemoteManifest();
|
|
101
116
|
if (!items) {
|
|
102
117
|
warn("Remote catalog fetch failed \u2014 using bundled catalog");
|
|
103
|
-
return { newItems: [], unchanged: 0, failed: [] };
|
|
118
|
+
return { newItems: [], refreshed: [], unchanged: 0, failed: [] };
|
|
104
119
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
120
|
+
const cacheDir = getCacheDir();
|
|
121
|
+
try {
|
|
122
|
+
await fs.ensureDir(cacheDir);
|
|
123
|
+
await fs.writeFile(
|
|
124
|
+
path.join(cacheDir, "manifest.json"),
|
|
125
|
+
`${JSON.stringify({ items }, null, 2)}
|
|
109
126
|
`,
|
|
110
|
-
|
|
111
|
-
|
|
127
|
+
"utf8"
|
|
128
|
+
);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
warn(
|
|
131
|
+
`Catalog cache not writable (${cacheDir}) \u2014 skipping cache sync: ${err instanceof Error ? err.message : String(err)}`
|
|
132
|
+
);
|
|
133
|
+
return { newItems: [], refreshed: [], unchanged: 0, failed: [] };
|
|
134
|
+
}
|
|
112
135
|
const newItems = [];
|
|
136
|
+
const refreshed = [];
|
|
113
137
|
let unchanged = 0;
|
|
114
138
|
const failed = [];
|
|
115
139
|
for (const item of items) {
|
|
@@ -121,18 +145,13 @@ async function syncRemoteCatalog() {
|
|
|
121
145
|
continue;
|
|
122
146
|
}
|
|
123
147
|
if (item.type === "skill") {
|
|
124
|
-
const destDir = safeJoin(
|
|
148
|
+
const destDir = safeJoin(getCacheDir(), item.path);
|
|
125
149
|
if (!destDir) {
|
|
126
150
|
warn(`Skipping ${item.id}: path traversal detected`);
|
|
127
151
|
failed.push(item.id);
|
|
128
152
|
continue;
|
|
129
153
|
}
|
|
130
154
|
const dest = path.join(destDir, "SKILL.md");
|
|
131
|
-
if (await fs.pathExists(dest)) {
|
|
132
|
-
await downloadSkillReferences(item, destDir);
|
|
133
|
-
unchanged++;
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
155
|
const url = `${REMOTE_BASE}/${item.path}/SKILL.md`;
|
|
137
156
|
const text = await fetchText(url);
|
|
138
157
|
if (!text) {
|
|
@@ -140,21 +159,23 @@ async function syncRemoteCatalog() {
|
|
|
140
159
|
failed.push(item.id);
|
|
141
160
|
continue;
|
|
142
161
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
162
|
+
try {
|
|
163
|
+
const outcome = await writeTextIfChanged(dest, text);
|
|
164
|
+
await downloadSkillReferences(item, destDir);
|
|
165
|
+
if (outcome === "created") newItems.push(item.id);
|
|
166
|
+
else if (outcome === "updated") refreshed.push(item.id);
|
|
167
|
+
else unchanged++;
|
|
168
|
+
} catch (err) {
|
|
169
|
+
warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
170
|
+
failed.push(item.id);
|
|
171
|
+
}
|
|
147
172
|
} else {
|
|
148
|
-
const dest = safeJoin(
|
|
173
|
+
const dest = safeJoin(getCacheDir(), item.path);
|
|
149
174
|
if (!dest) {
|
|
150
175
|
warn(`Skipping ${item.id}: path traversal detected`);
|
|
151
176
|
failed.push(item.id);
|
|
152
177
|
continue;
|
|
153
178
|
}
|
|
154
|
-
if (await fs.pathExists(dest)) {
|
|
155
|
-
unchanged++;
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
179
|
const url = `${REMOTE_BASE}/${item.path}`;
|
|
159
180
|
const text = await fetchText(url);
|
|
160
181
|
if (!text) {
|
|
@@ -162,12 +183,18 @@ async function syncRemoteCatalog() {
|
|
|
162
183
|
failed.push(item.id);
|
|
163
184
|
continue;
|
|
164
185
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
186
|
+
try {
|
|
187
|
+
const outcome = await writeTextIfChanged(dest, text);
|
|
188
|
+
if (outcome === "created") newItems.push(item.id);
|
|
189
|
+
else if (outcome === "updated") refreshed.push(item.id);
|
|
190
|
+
else unchanged++;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
warn(`Failed to cache ${item.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
193
|
+
failed.push(item.id);
|
|
194
|
+
}
|
|
168
195
|
}
|
|
169
196
|
}
|
|
170
|
-
return { newItems, unchanged, failed };
|
|
197
|
+
return { newItems, refreshed, unchanged, failed };
|
|
171
198
|
}
|
|
172
199
|
var CATALOG_TAGS_API_URL = "https://api.github.com/repos/WeAreHausTech/haus-workflow-catalog/tags";
|
|
173
200
|
async function fetchLatestCatalogTag() {
|
|
@@ -186,20 +213,28 @@ async function fetchLatestCatalogTag() {
|
|
|
186
213
|
}
|
|
187
214
|
async function getCacheManifestAge() {
|
|
188
215
|
try {
|
|
189
|
-
const stat = await fs.stat(path.join(
|
|
216
|
+
const stat = await fs.stat(path.join(getCacheDir(), "manifest.json"));
|
|
190
217
|
return Date.now() - stat.mtimeMs;
|
|
191
218
|
} catch {
|
|
192
219
|
return null;
|
|
193
220
|
}
|
|
194
221
|
}
|
|
195
222
|
|
|
196
|
-
// src/
|
|
197
|
-
|
|
198
|
-
|
|
223
|
+
// src/install/allow-rules.ts
|
|
224
|
+
var ALLOWED_SUBCOMMANDS = [
|
|
225
|
+
"setup-project",
|
|
226
|
+
"apply",
|
|
227
|
+
"doctor",
|
|
228
|
+
"scan",
|
|
229
|
+
"context",
|
|
230
|
+
"recommend"
|
|
231
|
+
];
|
|
232
|
+
function buildAllowRules() {
|
|
233
|
+
return [...new Set(ALLOWED_SUBCOMMANDS.map((sub) => `Bash(haus ${sub}:*)`))];
|
|
234
|
+
}
|
|
199
235
|
|
|
200
|
-
// src/
|
|
201
|
-
import
|
|
202
|
-
import fg2 from "fast-glob";
|
|
236
|
+
// src/install/settings-merge.ts
|
|
237
|
+
import path4 from "path";
|
|
203
238
|
import fs3 from "fs-extra";
|
|
204
239
|
|
|
205
240
|
// src/utils/fs.ts
|
|
@@ -253,7 +288,414 @@ async function mapWithConcurrency(items, fn, concurrency = 24) {
|
|
|
253
288
|
return results;
|
|
254
289
|
}
|
|
255
290
|
|
|
291
|
+
// src/install/manifest.ts
|
|
292
|
+
import os2 from "os";
|
|
293
|
+
import path3 from "path";
|
|
294
|
+
var MANIFEST_SCHEMA = "haus-install-manifest/1";
|
|
295
|
+
function globalClaudeDir() {
|
|
296
|
+
return path3.join(os2.homedir(), ".claude");
|
|
297
|
+
}
|
|
298
|
+
function hausManifestPath() {
|
|
299
|
+
return path3.join(globalClaudeDir(), "haus", "install-manifest.json");
|
|
300
|
+
}
|
|
301
|
+
async function readManifest() {
|
|
302
|
+
return readJson(hausManifestPath());
|
|
303
|
+
}
|
|
304
|
+
async function writeManifest(manifest) {
|
|
305
|
+
await writeJson(hausManifestPath(), manifest);
|
|
306
|
+
}
|
|
307
|
+
function buildManifest(source, files, hooks) {
|
|
308
|
+
return {
|
|
309
|
+
_schema: MANIFEST_SCHEMA,
|
|
310
|
+
source,
|
|
311
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
312
|
+
files,
|
|
313
|
+
hooks
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/install/settings-merge.ts
|
|
318
|
+
function settingsJsonPath() {
|
|
319
|
+
return path4.join(globalClaudeDir(), "settings.json");
|
|
320
|
+
}
|
|
321
|
+
async function readSettings() {
|
|
322
|
+
const parsed = await readJson(settingsJsonPath());
|
|
323
|
+
return parsed ?? {};
|
|
324
|
+
}
|
|
325
|
+
async function writeSettings(settings) {
|
|
326
|
+
await writeJson(settingsJsonPath(), settings);
|
|
327
|
+
}
|
|
328
|
+
function mergeHooks(settings, fragments) {
|
|
329
|
+
const existing = settings._haus?.hooks ?? [];
|
|
330
|
+
const existingCommands = settings._haus?.hookCommands ?? [];
|
|
331
|
+
const existingSet = new Set(existing);
|
|
332
|
+
const updated = { ...settings };
|
|
333
|
+
updated.hooks = { ...settings.hooks ?? {} };
|
|
334
|
+
const addedIds = [];
|
|
335
|
+
const addedCommands = [];
|
|
336
|
+
for (const fragment of fragments) {
|
|
337
|
+
if (fragment.gate !== "keep") continue;
|
|
338
|
+
if (existingSet.has(fragment.id)) continue;
|
|
339
|
+
const event = fragment.event;
|
|
340
|
+
if (!updated.hooks[event]) updated.hooks[event] = [];
|
|
341
|
+
const entry = {
|
|
342
|
+
hooks: [{ type: "command", command: fragment.command }]
|
|
343
|
+
};
|
|
344
|
+
if (fragment.matcher) entry.matcher = fragment.matcher;
|
|
345
|
+
updated.hooks[event] = [...updated.hooks[event] ?? [], entry];
|
|
346
|
+
addedIds.push(fragment.id);
|
|
347
|
+
addedCommands.push(fragment.command);
|
|
348
|
+
}
|
|
349
|
+
updated._haus = {
|
|
350
|
+
hooks: [...existing, ...addedIds],
|
|
351
|
+
hookCommands: [...existingCommands, ...addedCommands],
|
|
352
|
+
// Preserve deny/allow tracking so hook, deny, and allow merges are order-independent.
|
|
353
|
+
...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
|
|
354
|
+
...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
|
|
355
|
+
};
|
|
356
|
+
return { settings: updated, addedIds };
|
|
357
|
+
}
|
|
358
|
+
function mergeDenyRules(settings, rules) {
|
|
359
|
+
const existingDeny = settings.permissions?.deny ?? [];
|
|
360
|
+
const seen = new Set(existingDeny);
|
|
361
|
+
const trackedDeny = settings._haus?.denyRules ?? [];
|
|
362
|
+
const addedRules = [];
|
|
363
|
+
for (const rule of rules) {
|
|
364
|
+
if (seen.has(rule)) continue;
|
|
365
|
+
seen.add(rule);
|
|
366
|
+
addedRules.push(rule);
|
|
367
|
+
}
|
|
368
|
+
const updated = { ...settings };
|
|
369
|
+
updated.permissions = {
|
|
370
|
+
...settings.permissions ?? {},
|
|
371
|
+
deny: [...existingDeny, ...addedRules]
|
|
372
|
+
};
|
|
373
|
+
updated._haus = {
|
|
374
|
+
hooks: settings._haus?.hooks ?? [],
|
|
375
|
+
...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
|
|
376
|
+
denyRules: [...trackedDeny, ...addedRules],
|
|
377
|
+
...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
|
|
378
|
+
};
|
|
379
|
+
return { settings: updated, addedRules };
|
|
380
|
+
}
|
|
381
|
+
function mergeAllowRules(settings, rules) {
|
|
382
|
+
const existingAllow = settings.permissions?.allow ?? [];
|
|
383
|
+
const seen = new Set(existingAllow);
|
|
384
|
+
const trackedAllow = settings._haus?.allowRules ?? [];
|
|
385
|
+
const addedRules = [];
|
|
386
|
+
for (const rule of rules) {
|
|
387
|
+
if (seen.has(rule)) continue;
|
|
388
|
+
seen.add(rule);
|
|
389
|
+
addedRules.push(rule);
|
|
390
|
+
}
|
|
391
|
+
const updated = { ...settings };
|
|
392
|
+
updated.permissions = {
|
|
393
|
+
...settings.permissions ?? {},
|
|
394
|
+
allow: [...existingAllow, ...addedRules]
|
|
395
|
+
};
|
|
396
|
+
updated._haus = {
|
|
397
|
+
hooks: settings._haus?.hooks ?? [],
|
|
398
|
+
...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
|
|
399
|
+
...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
|
|
400
|
+
allowRules: [...trackedAllow, ...addedRules]
|
|
401
|
+
};
|
|
402
|
+
return { settings: updated, addedRules };
|
|
403
|
+
}
|
|
404
|
+
function stripHausAllow(settings) {
|
|
405
|
+
const prevHaus = settings._haus;
|
|
406
|
+
if (!prevHaus?.allowRules || prevHaus.allowRules.length === 0) return settings;
|
|
407
|
+
const ownedSet = new Set(prevHaus.allowRules);
|
|
408
|
+
const updated = { ...settings };
|
|
409
|
+
const remainingAllow = (settings.permissions?.allow ?? []).filter((rule) => !ownedSet.has(rule));
|
|
410
|
+
const permissions = { ...settings.permissions ?? {} };
|
|
411
|
+
if (remainingAllow.length > 0) permissions.allow = remainingAllow;
|
|
412
|
+
else delete permissions.allow;
|
|
413
|
+
if (Object.keys(permissions).length > 0) updated.permissions = permissions;
|
|
414
|
+
else delete updated.permissions;
|
|
415
|
+
const haus = { ...prevHaus };
|
|
416
|
+
delete haus.allowRules;
|
|
417
|
+
const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0;
|
|
418
|
+
if (stillTracking) updated._haus = haus;
|
|
419
|
+
else delete updated._haus;
|
|
420
|
+
return updated;
|
|
421
|
+
}
|
|
422
|
+
function stripHausDeny(settings) {
|
|
423
|
+
const prevHaus = settings._haus;
|
|
424
|
+
if (!prevHaus?.denyRules || prevHaus.denyRules.length === 0) return settings;
|
|
425
|
+
const ownedSet = new Set(prevHaus.denyRules);
|
|
426
|
+
const updated = { ...settings };
|
|
427
|
+
const remainingDeny = (settings.permissions?.deny ?? []).filter((rule) => !ownedSet.has(rule));
|
|
428
|
+
const permissions = { ...settings.permissions ?? {} };
|
|
429
|
+
if (remainingDeny.length > 0) permissions.deny = remainingDeny;
|
|
430
|
+
else delete permissions.deny;
|
|
431
|
+
if (Object.keys(permissions).length > 0) updated.permissions = permissions;
|
|
432
|
+
else delete updated.permissions;
|
|
433
|
+
const haus = { ...prevHaus };
|
|
434
|
+
delete haus.denyRules;
|
|
435
|
+
const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0;
|
|
436
|
+
if (stillTracking) updated._haus = haus;
|
|
437
|
+
else delete updated._haus;
|
|
438
|
+
return updated;
|
|
439
|
+
}
|
|
440
|
+
function stripHausHooks(settings) {
|
|
441
|
+
if (!settings._haus) return settings;
|
|
442
|
+
const ownedCommands = new Set(settings._haus.hookCommands ?? []);
|
|
443
|
+
const usePrefix = ownedCommands.size === 0;
|
|
444
|
+
const updated = { ...settings };
|
|
445
|
+
updated.hooks = {};
|
|
446
|
+
for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
|
|
447
|
+
const kept = entries.filter((entry) => {
|
|
448
|
+
const cmd = entry.hooks[0]?.command ?? "";
|
|
449
|
+
return usePrefix ? !cmd.startsWith("haus ") : !ownedCommands.has(cmd);
|
|
450
|
+
});
|
|
451
|
+
if (kept.length > 0) updated.hooks[event] = kept;
|
|
452
|
+
}
|
|
453
|
+
const { _haus: _, ...rest } = updated;
|
|
454
|
+
void _;
|
|
455
|
+
return rest;
|
|
456
|
+
}
|
|
457
|
+
async function loadHooksFragment(fragmentPath) {
|
|
458
|
+
let raw;
|
|
459
|
+
try {
|
|
460
|
+
raw = await fs3.readJson(fragmentPath);
|
|
461
|
+
} catch {
|
|
462
|
+
return [];
|
|
463
|
+
}
|
|
464
|
+
const data = raw;
|
|
465
|
+
return Array.isArray(data?.hooks) ? data.hooks : [];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// src/security/dangerous-commands.ts
|
|
469
|
+
var DANGEROUS_COMMANDS = [
|
|
470
|
+
"rm -rf",
|
|
471
|
+
"sudo",
|
|
472
|
+
"chmod -R 777",
|
|
473
|
+
"chown -R",
|
|
474
|
+
"git push --force",
|
|
475
|
+
"git reset --hard",
|
|
476
|
+
"docker system prune",
|
|
477
|
+
"drop database",
|
|
478
|
+
"truncate table",
|
|
479
|
+
"php artisan migrate --force",
|
|
480
|
+
"npm publish",
|
|
481
|
+
"yarn npm publish",
|
|
482
|
+
"pnpm publish"
|
|
483
|
+
];
|
|
484
|
+
|
|
485
|
+
// src/security/sensitive-paths.ts
|
|
486
|
+
var SENSITIVE_PATHS = [
|
|
487
|
+
".env",
|
|
488
|
+
".env.*",
|
|
489
|
+
"*.pem",
|
|
490
|
+
"*.key",
|
|
491
|
+
"*.p12",
|
|
492
|
+
"*.pfx",
|
|
493
|
+
"id_rsa",
|
|
494
|
+
"id_ed25519",
|
|
495
|
+
"*.sql",
|
|
496
|
+
"*.dump",
|
|
497
|
+
"*.backup",
|
|
498
|
+
"*.bak",
|
|
499
|
+
"storage/logs",
|
|
500
|
+
"wp-content/uploads",
|
|
501
|
+
"uploads",
|
|
502
|
+
"customer-data",
|
|
503
|
+
"exports",
|
|
504
|
+
"secrets",
|
|
505
|
+
"certs"
|
|
506
|
+
];
|
|
507
|
+
var SENSITIVE_PATH_REGEXES = [
|
|
508
|
+
/^\.env(\.|$)/,
|
|
509
|
+
/(^|\/)\.env(\.|$)/,
|
|
510
|
+
/\.pem$/,
|
|
511
|
+
/\.key$/,
|
|
512
|
+
/\.p12$/,
|
|
513
|
+
/\.pfx$/,
|
|
514
|
+
/\.sql$/,
|
|
515
|
+
/\.dump$/,
|
|
516
|
+
/customer-data/,
|
|
517
|
+
/exports/,
|
|
518
|
+
/certs/,
|
|
519
|
+
/secrets/,
|
|
520
|
+
/(^|\/)storage\/logs(\/|$)/,
|
|
521
|
+
/(^|\/)wp-content\/uploads(\/|$)/,
|
|
522
|
+
/(^|\/)uploads(\/|$)/
|
|
523
|
+
];
|
|
524
|
+
var SENSITIVE_ITEM_KEYWORDS = [
|
|
525
|
+
".env",
|
|
526
|
+
"secrets",
|
|
527
|
+
"certs",
|
|
528
|
+
"customer-data",
|
|
529
|
+
"exports",
|
|
530
|
+
".pem",
|
|
531
|
+
".key"
|
|
532
|
+
];
|
|
533
|
+
|
|
534
|
+
// src/security/deny-rules.ts
|
|
535
|
+
var SENSITIVE_DIRS = /* @__PURE__ */ new Set([
|
|
536
|
+
"storage/logs",
|
|
537
|
+
"wp-content/uploads",
|
|
538
|
+
"uploads",
|
|
539
|
+
"customer-data",
|
|
540
|
+
"exports",
|
|
541
|
+
"secrets",
|
|
542
|
+
"certs"
|
|
543
|
+
]);
|
|
544
|
+
var FILE_TOOLS = ["Read", "Edit", "Write"];
|
|
545
|
+
function buildDenyRules() {
|
|
546
|
+
const rules = [];
|
|
547
|
+
for (const command of DANGEROUS_COMMANDS) {
|
|
548
|
+
rules.push(`Bash(${command}:*)`);
|
|
549
|
+
}
|
|
550
|
+
for (const path35 of SENSITIVE_PATHS) {
|
|
551
|
+
const pattern = SENSITIVE_DIRS.has(path35) ? `${path35}/**` : path35;
|
|
552
|
+
for (const tool of FILE_TOOLS) {
|
|
553
|
+
rules.push(`${tool}(${pattern})`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return [...new Set(rules)];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// src/utils/paths.ts
|
|
560
|
+
import { existsSync, readFileSync } from "fs";
|
|
561
|
+
import os3 from "os";
|
|
562
|
+
import path5 from "path";
|
|
563
|
+
import { fileURLToPath } from "url";
|
|
564
|
+
var HAUS_DIR = ".haus-workflow";
|
|
565
|
+
function hausPath(root, ...parts) {
|
|
566
|
+
return path5.join(root, HAUS_DIR, ...parts);
|
|
567
|
+
}
|
|
568
|
+
function claudePath(root, ...parts) {
|
|
569
|
+
return path5.join(root, ".claude", ...parts);
|
|
570
|
+
}
|
|
571
|
+
function displayPath(root, targetPath) {
|
|
572
|
+
const rel = path5.relative(root, targetPath).replace(/\\/g, "/");
|
|
573
|
+
if (rel && !rel.startsWith("../") && rel !== "..") {
|
|
574
|
+
return rel.startsWith("./") ? rel : `./${rel}`;
|
|
575
|
+
}
|
|
576
|
+
const home = os3.homedir();
|
|
577
|
+
const normalized = targetPath.replace(/\\/g, "/");
|
|
578
|
+
if (home && home.trim().length > 0) {
|
|
579
|
+
const homeRel = path5.relative(home, targetPath).replace(/\\/g, "/");
|
|
580
|
+
if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
|
|
581
|
+
return `~/${homeRel}`;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return normalized;
|
|
585
|
+
}
|
|
586
|
+
function packageRoot() {
|
|
587
|
+
let dir = path5.dirname(fileURLToPath(import.meta.url));
|
|
588
|
+
for (let i = 0; i < 12; i++) {
|
|
589
|
+
const pkgPath = path5.join(dir, "package.json");
|
|
590
|
+
if (existsSync(pkgPath)) {
|
|
591
|
+
try {
|
|
592
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
593
|
+
if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
|
|
594
|
+
} catch {
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const parent = path5.dirname(dir);
|
|
598
|
+
if (parent === dir) break;
|
|
599
|
+
dir = parent;
|
|
600
|
+
}
|
|
601
|
+
const file = fileURLToPath(import.meta.url);
|
|
602
|
+
return path5.resolve(path5.dirname(file), "../..");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// src/claude/merge-project-settings.ts
|
|
606
|
+
var PROJECT_HOOK_FRAGMENTS = [
|
|
607
|
+
{
|
|
608
|
+
id: "haus.context-hook",
|
|
609
|
+
gate: "keep",
|
|
610
|
+
event: "UserPromptSubmit",
|
|
611
|
+
command: "haus context --from-hook"
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
id: "haus.guard-file",
|
|
615
|
+
gate: "keep",
|
|
616
|
+
event: "PreToolUse",
|
|
617
|
+
matcher: "Read|Edit|Write",
|
|
618
|
+
command: "haus guard file-access --from-hook"
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
id: "haus.guard-bash",
|
|
622
|
+
gate: "keep",
|
|
623
|
+
event: "PreToolUse",
|
|
624
|
+
matcher: "Bash",
|
|
625
|
+
command: "haus guard bash --from-hook"
|
|
626
|
+
}
|
|
627
|
+
];
|
|
628
|
+
async function readProjectSettings(root) {
|
|
629
|
+
const parsed = await readJson(claudePath(root, "settings.json"));
|
|
630
|
+
return parsed ?? {};
|
|
631
|
+
}
|
|
632
|
+
async function writeProjectSettings(root, settings) {
|
|
633
|
+
await writeJson(claudePath(root, "settings.json"), settings);
|
|
634
|
+
}
|
|
635
|
+
async function mergeProjectSettings(root) {
|
|
636
|
+
const base = await readProjectSettings(root);
|
|
637
|
+
const { settings: withHooks } = mergeHooks(base, PROJECT_HOOK_FRAGMENTS);
|
|
638
|
+
const { settings: withDeny } = mergeDenyRules(withHooks, buildDenyRules());
|
|
639
|
+
const { settings: merged } = mergeAllowRules(withDeny, buildAllowRules());
|
|
640
|
+
return merged;
|
|
641
|
+
}
|
|
642
|
+
async function applyProjectSettingsMerge(root) {
|
|
643
|
+
const merged = await mergeProjectSettings(root);
|
|
644
|
+
await writeProjectSettings(root, merged);
|
|
645
|
+
return merged;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/claude/write-claude-files.ts
|
|
649
|
+
import path12 from "path";
|
|
650
|
+
import fs10 from "fs-extra";
|
|
651
|
+
|
|
652
|
+
// src/catalog/load-catalog.ts
|
|
653
|
+
import path6 from "path";
|
|
654
|
+
async function loadCatalogContext(root) {
|
|
655
|
+
const envPath = process.env["HAUS_FIXTURE_CATALOG"];
|
|
656
|
+
if (envPath) {
|
|
657
|
+
const data2 = await readJson(envPath);
|
|
658
|
+
return {
|
|
659
|
+
items: data2?.items ?? [],
|
|
660
|
+
contentRoot: path6.dirname(envPath),
|
|
661
|
+
source: "fixture"
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
const cacheDir = getCacheDir();
|
|
665
|
+
const cacheManifestPath = path6.join(cacheDir, "manifest.json");
|
|
666
|
+
const cacheData = await readJson(cacheManifestPath);
|
|
667
|
+
if (cacheData?.items?.length) {
|
|
668
|
+
return { items: cacheData.items, contentRoot: cacheDir, source: "cache" };
|
|
669
|
+
}
|
|
670
|
+
const localManifest = path6.join(root, "library/catalog/manifest.json");
|
|
671
|
+
const localData = await readJson(localManifest);
|
|
672
|
+
if (localData?.items?.length) {
|
|
673
|
+
return {
|
|
674
|
+
items: localData.items,
|
|
675
|
+
contentRoot: path6.dirname(localManifest),
|
|
676
|
+
source: "local"
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
const packageManifest = path6.join(packageRoot(), "library/catalog/manifest.json");
|
|
680
|
+
const data = await readJson(packageManifest);
|
|
681
|
+
return {
|
|
682
|
+
items: data?.items ?? [],
|
|
683
|
+
contentRoot: path6.dirname(packageManifest),
|
|
684
|
+
source: "bundled"
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
async function loadCatalog(root) {
|
|
688
|
+
const ctx = await loadCatalogContext(root);
|
|
689
|
+
return ctx.items;
|
|
690
|
+
}
|
|
691
|
+
function catalogItemContentPath(contentRoot, item) {
|
|
692
|
+
return path6.join(contentRoot, item.path);
|
|
693
|
+
}
|
|
694
|
+
|
|
256
695
|
// src/update/hash-installed.ts
|
|
696
|
+
import path7 from "path";
|
|
697
|
+
import fg2 from "fast-glob";
|
|
698
|
+
import fs4 from "fs-extra";
|
|
257
699
|
var EMPTY_LOCK_PATHS_TOKEN = "haus-lock:empty-paths";
|
|
258
700
|
async function hashInstalledPaths(root, relPaths) {
|
|
259
701
|
if (relPaths.length === 0) {
|
|
@@ -262,20 +704,20 @@ async function hashInstalledPaths(root, relPaths) {
|
|
|
262
704
|
const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
|
|
263
705
|
const fileDigests = [];
|
|
264
706
|
for (const rel of normalized) {
|
|
265
|
-
const abs =
|
|
266
|
-
if (!await
|
|
267
|
-
const stat = await
|
|
707
|
+
const abs = path7.join(root, rel);
|
|
708
|
+
if (!await fs4.pathExists(abs)) continue;
|
|
709
|
+
const stat = await fs4.stat(abs);
|
|
268
710
|
if (stat.isFile()) {
|
|
269
|
-
const body = await
|
|
711
|
+
const body = await fs4.readFile(abs, "utf8");
|
|
270
712
|
fileDigests.push({ rel, digest: hashText(body) });
|
|
271
713
|
continue;
|
|
272
714
|
}
|
|
273
715
|
if (!stat.isDirectory()) continue;
|
|
274
716
|
const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
|
|
275
717
|
for (const sub of inner.sort()) {
|
|
276
|
-
const relFile =
|
|
277
|
-
const absFile =
|
|
278
|
-
const body = await
|
|
718
|
+
const relFile = path7.join(rel, sub).replace(/\\/g, "/");
|
|
719
|
+
const absFile = path7.join(abs, sub);
|
|
720
|
+
const body = await fs4.readFile(absFile, "utf8");
|
|
279
721
|
fileDigests.push({ rel: relFile, digest: hashText(body) });
|
|
280
722
|
}
|
|
281
723
|
}
|
|
@@ -308,54 +750,8 @@ function summarizeDiff(diffText) {
|
|
|
308
750
|
return { additions, deletions };
|
|
309
751
|
}
|
|
310
752
|
|
|
311
|
-
// src/utils/paths.ts
|
|
312
|
-
import { existsSync, readFileSync } from "fs";
|
|
313
|
-
import os2 from "os";
|
|
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);
|
|
319
|
-
}
|
|
320
|
-
function claudePath(root, ...parts) {
|
|
321
|
-
return path4.join(root, ".claude", ...parts);
|
|
322
|
-
}
|
|
323
|
-
function displayPath(root, targetPath) {
|
|
324
|
-
const rel = path4.relative(root, targetPath).replace(/\\/g, "/");
|
|
325
|
-
if (rel && !rel.startsWith("../") && rel !== "..") {
|
|
326
|
-
return rel.startsWith("./") ? rel : `./${rel}`;
|
|
327
|
-
}
|
|
328
|
-
const home = os2.homedir();
|
|
329
|
-
const normalized = targetPath.replace(/\\/g, "/");
|
|
330
|
-
if (home && home.trim().length > 0) {
|
|
331
|
-
const homeRel = path4.relative(home, targetPath).replace(/\\/g, "/");
|
|
332
|
-
if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
|
|
333
|
-
return `~/${homeRel}`;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
return normalized;
|
|
337
|
-
}
|
|
338
|
-
function packageRoot() {
|
|
339
|
-
let dir = path4.dirname(fileURLToPath(import.meta.url));
|
|
340
|
-
for (let i = 0; i < 12; i++) {
|
|
341
|
-
const pkgPath = path4.join(dir, "package.json");
|
|
342
|
-
if (existsSync(pkgPath)) {
|
|
343
|
-
try {
|
|
344
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
345
|
-
if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
|
|
346
|
-
} catch {
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
const parent = path4.dirname(dir);
|
|
350
|
-
if (parent === dir) break;
|
|
351
|
-
dir = parent;
|
|
352
|
-
}
|
|
353
|
-
const file = fileURLToPath(import.meta.url);
|
|
354
|
-
return path4.resolve(path4.dirname(file), "../..");
|
|
355
|
-
}
|
|
356
|
-
|
|
357
753
|
// src/claude/load-hooks-config.ts
|
|
358
|
-
import
|
|
754
|
+
import path8 from "path";
|
|
359
755
|
var CONFIG_PATH = ".haus-workflow/config.json";
|
|
360
756
|
var DEFAULT_HOOKS_CONFIG = {
|
|
361
757
|
hooks: {
|
|
@@ -363,125 +759,37 @@ var DEFAULT_HOOKS_CONFIG = {
|
|
|
363
759
|
}
|
|
364
760
|
};
|
|
365
761
|
async function isHookEnabled(root, key) {
|
|
366
|
-
const cfg = await readJson(
|
|
762
|
+
const cfg = await readJson(path8.join(root, CONFIG_PATH));
|
|
367
763
|
return cfg?.hooks?.[key]?.enabled === true;
|
|
368
764
|
}
|
|
369
765
|
|
|
370
|
-
// src/
|
|
371
|
-
|
|
372
|
-
"rm -rf",
|
|
373
|
-
"sudo",
|
|
374
|
-
"chmod -R 777",
|
|
375
|
-
"chown -R",
|
|
376
|
-
"git push --force",
|
|
377
|
-
"git reset --hard",
|
|
378
|
-
"docker system prune",
|
|
379
|
-
"drop database",
|
|
380
|
-
"truncate table",
|
|
381
|
-
"php artisan migrate --force",
|
|
382
|
-
"npm publish",
|
|
383
|
-
"yarn npm publish",
|
|
384
|
-
"pnpm publish"
|
|
385
|
-
];
|
|
386
|
-
|
|
387
|
-
// src/security/sensitive-paths.ts
|
|
388
|
-
var SENSITIVE_PATHS = [
|
|
389
|
-
".env",
|
|
390
|
-
".env.*",
|
|
391
|
-
"*.pem",
|
|
392
|
-
"*.key",
|
|
393
|
-
"*.p12",
|
|
394
|
-
"*.pfx",
|
|
395
|
-
"id_rsa",
|
|
396
|
-
"id_ed25519",
|
|
397
|
-
"*.sql",
|
|
398
|
-
"*.dump",
|
|
399
|
-
"*.backup",
|
|
400
|
-
"*.bak",
|
|
401
|
-
"storage/logs",
|
|
402
|
-
"wp-content/uploads",
|
|
403
|
-
"uploads",
|
|
404
|
-
"customer-data",
|
|
405
|
-
"exports",
|
|
406
|
-
"secrets",
|
|
407
|
-
"certs"
|
|
408
|
-
];
|
|
409
|
-
var SENSITIVE_PATH_REGEXES = [
|
|
410
|
-
/^\.env(\.|$)/,
|
|
411
|
-
/(^|\/)\.env(\.|$)/,
|
|
412
|
-
/\.pem$/,
|
|
413
|
-
/\.key$/,
|
|
414
|
-
/\.p12$/,
|
|
415
|
-
/\.pfx$/,
|
|
416
|
-
/\.sql$/,
|
|
417
|
-
/\.dump$/,
|
|
418
|
-
/customer-data/,
|
|
419
|
-
/exports/,
|
|
420
|
-
/certs/,
|
|
421
|
-
/secrets/,
|
|
422
|
-
/(^|\/)storage\/logs(\/|$)/,
|
|
423
|
-
/(^|\/)wp-content\/uploads(\/|$)/,
|
|
424
|
-
/(^|\/)uploads(\/|$)/
|
|
425
|
-
];
|
|
426
|
-
var SENSITIVE_ITEM_KEYWORDS = [
|
|
427
|
-
".env",
|
|
428
|
-
"secrets",
|
|
429
|
-
"certs",
|
|
430
|
-
"customer-data",
|
|
431
|
-
"exports",
|
|
432
|
-
".pem",
|
|
433
|
-
".key"
|
|
434
|
-
];
|
|
435
|
-
|
|
436
|
-
// src/security/deny-rules.ts
|
|
437
|
-
var SENSITIVE_DIRS = /* @__PURE__ */ new Set([
|
|
438
|
-
"storage/logs",
|
|
439
|
-
"wp-content/uploads",
|
|
440
|
-
"uploads",
|
|
441
|
-
"customer-data",
|
|
442
|
-
"exports",
|
|
443
|
-
"secrets",
|
|
444
|
-
"certs"
|
|
445
|
-
]);
|
|
446
|
-
var FILE_TOOLS = ["Read", "Edit", "Write"];
|
|
447
|
-
function buildDenyRules() {
|
|
448
|
-
const rules = [];
|
|
449
|
-
for (const command of DANGEROUS_COMMANDS) {
|
|
450
|
-
rules.push(`Bash(${command}:*)`);
|
|
451
|
-
}
|
|
452
|
-
for (const path35 of SENSITIVE_PATHS) {
|
|
453
|
-
const pattern = SENSITIVE_DIRS.has(path35) ? `${path35}/**` : path35;
|
|
454
|
-
for (const tool of FILE_TOOLS) {
|
|
455
|
-
rules.push(`${tool}(${pattern})`);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
return [...new Set(rules)];
|
|
459
|
-
}
|
|
766
|
+
// src/claude/verify-hooks-contract.ts
|
|
767
|
+
import fs5 from "fs-extra";
|
|
460
768
|
|
|
461
769
|
// src/claude/load-hooks.ts
|
|
462
770
|
var CANONICAL_HOOKS = {
|
|
463
771
|
hooks: {
|
|
464
772
|
UserPromptSubmit: [
|
|
465
773
|
{
|
|
466
|
-
hooks: [{ type: "command", command: "haus context --from-hook
|
|
774
|
+
hooks: [{ type: "command", command: "haus context --from-hook" }]
|
|
467
775
|
}
|
|
468
776
|
],
|
|
469
777
|
PreToolUse: [
|
|
470
778
|
{
|
|
471
779
|
matcher: "Read|Edit|Write",
|
|
472
|
-
hooks: [{ type: "command", command: "haus guard file-access --from-hook
|
|
780
|
+
hooks: [{ type: "command", command: "haus guard file-access --from-hook" }]
|
|
473
781
|
},
|
|
474
782
|
{
|
|
475
783
|
matcher: "Bash",
|
|
476
|
-
hooks: [{ type: "command", command: "haus guard bash --from-hook
|
|
784
|
+
hooks: [{ type: "command", command: "haus guard bash --from-hook" }]
|
|
477
785
|
}
|
|
478
786
|
]
|
|
479
787
|
}
|
|
480
788
|
};
|
|
481
789
|
var STABLE_HOOK_IDS = {
|
|
482
|
-
"haus context --from-hook
|
|
483
|
-
"haus guard file-access --from-hook
|
|
484
|
-
"haus guard bash --from-hook
|
|
790
|
+
"haus context --from-hook": "haus.context-hook",
|
|
791
|
+
"haus guard file-access --from-hook": "haus.guard-file",
|
|
792
|
+
"haus guard bash --from-hook": "haus.guard-bash"
|
|
485
793
|
};
|
|
486
794
|
async function loadClaudeHooksSettings() {
|
|
487
795
|
return { ...CANONICAL_HOOKS, permissions: { deny: buildDenyRules() } };
|
|
@@ -505,24 +813,52 @@ function flattenRecommendedHooks(settings) {
|
|
|
505
813
|
}
|
|
506
814
|
|
|
507
815
|
// src/claude/verify-hooks-contract.ts
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
816
|
+
function collectHookCommands(settings) {
|
|
817
|
+
const cmds = [];
|
|
818
|
+
for (const entries of Object.values(settings.hooks ?? {})) {
|
|
819
|
+
for (const entry of entries) {
|
|
820
|
+
for (const h of entry.hooks ?? []) {
|
|
821
|
+
if (h.command) cmds.push(h.command);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return cmds;
|
|
826
|
+
}
|
|
827
|
+
function hausHookContractSatisfied(project, canonical) {
|
|
828
|
+
const present = new Set(collectHookCommands(project));
|
|
829
|
+
for (const block of canonical.hooks.UserPromptSubmit) {
|
|
830
|
+
for (const h of block.hooks) {
|
|
831
|
+
if (!present.has(h.command)) return false;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
for (const block of canonical.hooks.PreToolUse) {
|
|
835
|
+
for (const h of block.hooks) {
|
|
836
|
+
if (!present.has(h.command)) return false;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
const denySet = new Set(project.permissions?.deny ?? []);
|
|
840
|
+
for (const rule of canonical.permissions?.deny ?? []) {
|
|
841
|
+
if (!denySet.has(rule)) return false;
|
|
842
|
+
}
|
|
843
|
+
return true;
|
|
844
|
+
}
|
|
845
|
+
async function assertPostApplySettingsHausContract(root) {
|
|
846
|
+
const canonical = await loadClaudeHooksSettings();
|
|
511
847
|
const written = await readJson(claudePath(root, "settings.json"));
|
|
512
848
|
if (written == null || typeof written !== "object") {
|
|
513
849
|
throw new Error(
|
|
514
850
|
"haus: post-apply self-check failed: .claude/settings.json missing or unreadable"
|
|
515
851
|
);
|
|
516
852
|
}
|
|
517
|
-
if (!
|
|
853
|
+
if (!hausHookContractSatisfied(written, canonical)) {
|
|
518
854
|
throw new Error(
|
|
519
|
-
"haus: post-apply self-check failed: .claude/settings.json
|
|
855
|
+
"haus: post-apply self-check failed: .claude/settings.json missing required haus hooks or deny rules"
|
|
520
856
|
);
|
|
521
857
|
}
|
|
522
858
|
}
|
|
523
859
|
async function verifyProjectSettingsHooksContract(root) {
|
|
524
860
|
const settingsPath = claudePath(root, "settings.json");
|
|
525
|
-
if (!await
|
|
861
|
+
if (!await fs5.pathExists(settingsPath)) {
|
|
526
862
|
return {
|
|
527
863
|
ok: true,
|
|
528
864
|
skipped: true,
|
|
@@ -539,18 +875,18 @@ async function verifyProjectSettingsHooksContract(root) {
|
|
|
539
875
|
if (project == null || typeof project !== "object") {
|
|
540
876
|
return { ok: false, message: ".claude/settings.json is unreadable." };
|
|
541
877
|
}
|
|
542
|
-
if (!
|
|
878
|
+
if (!hausHookContractSatisfied(project, canonical)) {
|
|
543
879
|
return {
|
|
544
880
|
ok: false,
|
|
545
|
-
message: ".claude/settings.json
|
|
881
|
+
message: ".claude/settings.json missing required haus hooks or deny rules (regenerate with `haus apply --write`)."
|
|
546
882
|
};
|
|
547
883
|
}
|
|
548
|
-
return { ok: true, message: "settings.json
|
|
884
|
+
return { ok: true, message: "settings.json carries required haus hook contract." };
|
|
549
885
|
}
|
|
550
886
|
|
|
551
887
|
// src/claude/write-root-claude-md.ts
|
|
552
|
-
import
|
|
553
|
-
import
|
|
888
|
+
import path9 from "path";
|
|
889
|
+
import fs6 from "fs-extra";
|
|
554
890
|
var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
|
|
555
891
|
var BLOCK_END = "<!-- HAUS:END haus-imports -->";
|
|
556
892
|
var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
|
|
@@ -560,6 +896,16 @@ function buildImportBlock() {
|
|
|
560
896
|
${IMPORT_CONTENT}
|
|
561
897
|
${BLOCK_END}`;
|
|
562
898
|
}
|
|
899
|
+
function stripHausBlock(existing) {
|
|
900
|
+
const beginIdx = existing.indexOf(BLOCK_BEGIN);
|
|
901
|
+
const endIdx = existing.indexOf(BLOCK_END);
|
|
902
|
+
if (beginIdx === -1 || endIdx === -1 || endIdx <= beginIdx) return existing;
|
|
903
|
+
const before = existing.slice(0, beginIdx);
|
|
904
|
+
const after = existing.slice(endIdx + BLOCK_END.length);
|
|
905
|
+
const merged = `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
906
|
+
return merged.length > 0 ? `${merged}
|
|
907
|
+
` : "";
|
|
908
|
+
}
|
|
563
909
|
function injectHausBlock(existing, block) {
|
|
564
910
|
const beginIdx = existing.indexOf(BLOCK_BEGIN);
|
|
565
911
|
const endIdx = existing.indexOf(BLOCK_END);
|
|
@@ -579,9 +925,9 @@ ${block}
|
|
|
579
925
|
`;
|
|
580
926
|
}
|
|
581
927
|
async function writeRootClaudeMd(root, dryRun) {
|
|
582
|
-
const filePath =
|
|
928
|
+
const filePath = path9.join(root, "CLAUDE.md");
|
|
583
929
|
const block = buildImportBlock();
|
|
584
|
-
const prev = await
|
|
930
|
+
const prev = await fs6.pathExists(filePath) ? await fs6.readFile(filePath, "utf8") : "";
|
|
585
931
|
const next = injectHausBlock(prev, block);
|
|
586
932
|
const printable = displayPath(root, filePath);
|
|
587
933
|
if (dryRun) {
|
|
@@ -604,12 +950,12 @@ async function writeRootClaudeMd(root, dryRun) {
|
|
|
604
950
|
}
|
|
605
951
|
|
|
606
952
|
// src/claude/write-workflow-config.ts
|
|
607
|
-
import
|
|
608
|
-
import
|
|
953
|
+
import path11 from "path";
|
|
954
|
+
import fs8 from "fs-extra";
|
|
609
955
|
|
|
610
956
|
// src/claude/derive-workflow-config.ts
|
|
611
|
-
import
|
|
612
|
-
import
|
|
957
|
+
import path10 from "path";
|
|
958
|
+
import fs7 from "fs-extra";
|
|
613
959
|
function binCmd(pm, bin, args) {
|
|
614
960
|
const tail = args ? ` ${args}` : "";
|
|
615
961
|
if (pm === "yarn") return `yarn ${bin}${tail}`;
|
|
@@ -618,7 +964,7 @@ function binCmd(pm, bin, args) {
|
|
|
618
964
|
}
|
|
619
965
|
async function deriveWorkflowConfig(root, ctx) {
|
|
620
966
|
const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
|
|
621
|
-
const pkg = await readJson(
|
|
967
|
+
const pkg = await readJson(path10.join(root, "package.json"));
|
|
622
968
|
const scripts = pkg?.scripts ?? {};
|
|
623
969
|
const deps = new Set(ctx.dependencies);
|
|
624
970
|
const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
|
|
@@ -628,7 +974,7 @@ async function deriveWorkflowConfig(root, ctx) {
|
|
|
628
974
|
return null;
|
|
629
975
|
};
|
|
630
976
|
const hasDep = (name) => deps.has(name);
|
|
631
|
-
const exists = (rel) =>
|
|
977
|
+
const exists = (rel) => fs7.pathExistsSync(path10.join(root, rel));
|
|
632
978
|
const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
|
|
633
979
|
const hasCypress = hasDep("cypress");
|
|
634
980
|
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 +1043,7 @@ var FALLBACK_CONTEXT = {
|
|
|
697
1043
|
async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
698
1044
|
const destPath = hausPath(root, "workflow-config.md");
|
|
699
1045
|
const printable = displayPath(root, destPath);
|
|
700
|
-
const exists = await
|
|
1046
|
+
const exists = await fs8.pathExists(destPath);
|
|
701
1047
|
if (exists && !opts.refill) {
|
|
702
1048
|
if (dryRun) log(printable + ": exists (project-owned, skipping)");
|
|
703
1049
|
return null;
|
|
@@ -705,11 +1051,11 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
|
705
1051
|
const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
|
|
706
1052
|
...FALLBACK_CONTEXT,
|
|
707
1053
|
root,
|
|
708
|
-
repoName:
|
|
1054
|
+
repoName: path11.basename(root)
|
|
709
1055
|
};
|
|
710
1056
|
const values = await deriveWorkflowConfig(root, ctx);
|
|
711
1057
|
if (exists) {
|
|
712
|
-
const current = await
|
|
1058
|
+
const current = await fs8.readFile(destPath, "utf8");
|
|
713
1059
|
const refilled = refillContent(current, values);
|
|
714
1060
|
if (refilled === current) {
|
|
715
1061
|
if (dryRun) log(printable + ": no blank fields to refill");
|
|
@@ -731,7 +1077,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
|
731
1077
|
}
|
|
732
1078
|
|
|
733
1079
|
// src/claude/write-workflow.ts
|
|
734
|
-
import
|
|
1080
|
+
import fs9 from "fs-extra";
|
|
735
1081
|
|
|
736
1082
|
// src/claude/managed-template.ts
|
|
737
1083
|
function normaliseLF(content2) {
|
|
@@ -764,8 +1110,8 @@ async function writeWorkflow(root, pkgVersion, dryRun) {
|
|
|
764
1110
|
${templateContent}`;
|
|
765
1111
|
const destPath = hausPath(root, "WORKFLOW.md");
|
|
766
1112
|
const printable = displayPath(root, destPath);
|
|
767
|
-
if (await
|
|
768
|
-
const existing = await
|
|
1113
|
+
if (await fs9.pathExists(destPath)) {
|
|
1114
|
+
const existing = await fs9.readFile(destPath, "utf8");
|
|
769
1115
|
const firstLine = existing.split("\n")[0] ?? "";
|
|
770
1116
|
const parsed = parseHausManagedHeader(firstLine);
|
|
771
1117
|
if (!parsed) {
|
|
@@ -787,7 +1133,7 @@ ${templateContent}`;
|
|
|
787
1133
|
}
|
|
788
1134
|
}
|
|
789
1135
|
if (dryRun) {
|
|
790
|
-
const prev = await
|
|
1136
|
+
const prev = await fs9.pathExists(destPath) ? await fs9.readFile(destPath, "utf8") : "";
|
|
791
1137
|
if (!prev) {
|
|
792
1138
|
log(createUnifiedDiff(printable, "", next));
|
|
793
1139
|
} else {
|
|
@@ -814,7 +1160,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
814
1160
|
estimatedTokenReductionPct: 0
|
|
815
1161
|
};
|
|
816
1162
|
const pkgRoot = packageRoot();
|
|
817
|
-
const hausVersion2 = (await readJson(
|
|
1163
|
+
const hausVersion2 = (await readJson(path12.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
|
|
818
1164
|
const coreFiles = [
|
|
819
1165
|
claudePath(root, "settings.json"),
|
|
820
1166
|
claudePath(root, "rules", "haus.md"),
|
|
@@ -838,11 +1184,15 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
838
1184
|
hausPath(root, "selected-context.json"),
|
|
839
1185
|
hausPath(root, "haus.lock.json")
|
|
840
1186
|
];
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1187
|
+
if (dryRun) {
|
|
1188
|
+
const mergedSettings = await mergeProjectSettings(root);
|
|
1189
|
+
await writeManagedJson(root, claudePath(root, "settings.json"), mergedSettings, true);
|
|
1190
|
+
} else {
|
|
1191
|
+
await applyProjectSettingsMerge(root);
|
|
1192
|
+
await assertPostApplySettingsHausContract(root);
|
|
1193
|
+
}
|
|
844
1194
|
const configPath = hausPath(root, "config.json");
|
|
845
|
-
if (!await
|
|
1195
|
+
if (!await fs10.pathExists(configPath)) {
|
|
846
1196
|
await writeManagedJson(root, configPath, DEFAULT_HOOKS_CONFIG, dryRun);
|
|
847
1197
|
}
|
|
848
1198
|
await writeManagedText(
|
|
@@ -869,15 +1219,8 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
869
1219
|
"- Never read secrets.\n- Block dangerous shell commands.\n",
|
|
870
1220
|
dryRun
|
|
871
1221
|
);
|
|
872
|
-
const
|
|
873
|
-
const
|
|
874
|
-
const manifestDir = path9.dirname(manifestPath2);
|
|
875
|
-
const manifest = await readJson(manifestPath2) ?? { items: [] };
|
|
876
|
-
const manifestById = new Map((manifest.items ?? []).map((item) => [item.id, item]));
|
|
877
|
-
const cacheManifest = await readJson(
|
|
878
|
-
path9.join(CACHE_DIR, "manifest.json")
|
|
879
|
-
);
|
|
880
|
-
const cacheManifestById = new Map((cacheManifest?.items ?? []).map((item) => [item.id, item]));
|
|
1222
|
+
const { items: manifestItems, contentRoot } = await loadCatalogContext(root);
|
|
1223
|
+
const manifestById = new Map(manifestItems.map((item) => [item.id, item]));
|
|
881
1224
|
const installedPathsByItem = /* @__PURE__ */ new Map();
|
|
882
1225
|
const installedIds = /* @__PURE__ */ new Set();
|
|
883
1226
|
const catalogItems = selectedIds !== void 0 ? rec.recommended.filter((r) => selectedIds.includes(r.id)) : rec.recommended;
|
|
@@ -896,24 +1239,22 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
896
1239
|
continue;
|
|
897
1240
|
}
|
|
898
1241
|
}
|
|
899
|
-
const
|
|
900
|
-
const cachePath = cachedItem?.path ? path9.join(CACHE_DIR, cachedItem.path) : null;
|
|
901
|
-
const sourcePath = cachePath && await fs9.pathExists(cachePath) ? cachePath : path9.join(manifestDir, manifestItem.path);
|
|
1242
|
+
const sourcePath = catalogItemContentPath(contentRoot, manifestItem);
|
|
902
1243
|
const target = item.type === "agent" ? "agents" : item.type === "template" ? "templates" : "skills";
|
|
903
|
-
const destination = claudePath(root, target,
|
|
904
|
-
if (await
|
|
1244
|
+
const destination = claudePath(root, target, path12.basename(sourcePath));
|
|
1245
|
+
if (await fs10.pathExists(sourcePath)) {
|
|
905
1246
|
if (dryRun) {
|
|
906
|
-
const exists = await
|
|
1247
|
+
const exists = await fs10.pathExists(destination);
|
|
907
1248
|
log(
|
|
908
1249
|
`${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
|
|
909
1250
|
);
|
|
910
1251
|
} else {
|
|
911
|
-
await
|
|
912
|
-
await
|
|
1252
|
+
await fs10.ensureDir(path12.dirname(destination));
|
|
1253
|
+
await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
|
|
913
1254
|
}
|
|
914
1255
|
files.push(destination);
|
|
915
1256
|
const current = installedPathsByItem.get(item.id) ?? [];
|
|
916
|
-
installedPathsByItem.set(item.id, [...current,
|
|
1257
|
+
installedPathsByItem.set(item.id, [...current, path12.relative(root, destination)]);
|
|
917
1258
|
installedIds.add(item.id);
|
|
918
1259
|
} else {
|
|
919
1260
|
warn(
|
|
@@ -964,7 +1305,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
964
1305
|
return [...new Set(files)];
|
|
965
1306
|
}
|
|
966
1307
|
async function writeManagedText(root, filePath, nextText, dryRun) {
|
|
967
|
-
const prev = await
|
|
1308
|
+
const prev = await fs10.pathExists(filePath) ? await fs10.readFile(filePath, "utf8") : "";
|
|
968
1309
|
const printable = displayPath(root, filePath);
|
|
969
1310
|
if (dryRun) {
|
|
970
1311
|
if (!prev) {
|
|
@@ -991,7 +1332,7 @@ async function writeManagedJson(root, filePath, value, dryRun) {
|
|
|
991
1332
|
|
|
992
1333
|
// src/commands/apply.ts
|
|
993
1334
|
async function cacheHasItems() {
|
|
994
|
-
const data = await readJson(
|
|
1335
|
+
const data = await readJson(path13.join(getCacheDir(), "manifest.json"));
|
|
995
1336
|
return Array.isArray(data?.items) && data.items.length > 0;
|
|
996
1337
|
}
|
|
997
1338
|
async function runApply(options) {
|
|
@@ -1035,17 +1376,9 @@ async function runApply(options) {
|
|
|
1035
1376
|
const rec = await readJson(hausPath(root, "recommendation.json"));
|
|
1036
1377
|
const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : rec?.recommended.length ?? 0;
|
|
1037
1378
|
if (catalogItemCount > 0 && !await cacheHasItems()) {
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
);
|
|
1042
|
-
} else {
|
|
1043
|
-
error(
|
|
1044
|
-
"Catalog cache is empty \u2014 cannot install catalog items. Run `haus update` first, or pass --allow-empty-cache to apply core files only."
|
|
1045
|
-
);
|
|
1046
|
-
process.exitCode = 1;
|
|
1047
|
-
return;
|
|
1048
|
-
}
|
|
1379
|
+
warn(
|
|
1380
|
+
isDryRun ? "Catalog cache is empty \u2014 `haus apply --write` will skip catalog items. Run `haus update` first." : "Catalog cache is empty \u2014 catalog items will be skipped. Run `haus update` first, or pass --allow-empty-cache to silence this warning."
|
|
1381
|
+
);
|
|
1049
1382
|
}
|
|
1050
1383
|
}
|
|
1051
1384
|
const files = await writeClaudeFiles(root, isDryRun, selectedIds, {
|
|
@@ -1058,25 +1391,17 @@ async function runApply(options) {
|
|
|
1058
1391
|
files.forEach((f) => log(`- ${displayPath(root, f)}`));
|
|
1059
1392
|
}
|
|
1060
1393
|
}
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
async function loadCatalog(root) {
|
|
1067
|
-
const envPath = process.env["HAUS_FIXTURE_CATALOG"];
|
|
1068
|
-
if (envPath) {
|
|
1069
|
-
const data2 = await readJson(envPath);
|
|
1070
|
-
return data2?.items ?? [];
|
|
1394
|
+
async function isHausProject(root) {
|
|
1395
|
+
if (await fs11.pathExists(hausPath(root, "recommendation.json"))) return true;
|
|
1396
|
+
if (await fs11.pathExists(claudePath(root, "settings.json"))) {
|
|
1397
|
+
const settings = await readProjectSettings(root);
|
|
1398
|
+
if (settings._haus != null) return true;
|
|
1071
1399
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
const packageManifest = path11.join(packageRoot(), "library/catalog/manifest.json");
|
|
1078
|
-
const data = await readJson(packageManifest);
|
|
1079
|
-
return data?.items ?? [];
|
|
1400
|
+
return false;
|
|
1401
|
+
}
|
|
1402
|
+
async function refreshProjectApply(root) {
|
|
1403
|
+
if (!await isHausProject(root)) return [];
|
|
1404
|
+
return writeClaudeFiles(root, false, void 0, { refillConfig: false });
|
|
1080
1405
|
}
|
|
1081
1406
|
|
|
1082
1407
|
// library/catalog/validation-rules.json
|
|
@@ -1284,7 +1609,7 @@ async function runCatalogAudit() {
|
|
|
1284
1609
|
}
|
|
1285
1610
|
|
|
1286
1611
|
// src/commands/config.ts
|
|
1287
|
-
import
|
|
1612
|
+
import path14 from "path";
|
|
1288
1613
|
var CONFIG_PATH2 = ".haus-workflow/config.json";
|
|
1289
1614
|
var HOOK_ALIASES = {
|
|
1290
1615
|
"hook.context": "context"
|
|
@@ -1297,7 +1622,7 @@ async function runConfig(key, action) {
|
|
|
1297
1622
|
);
|
|
1298
1623
|
}
|
|
1299
1624
|
const root = process.cwd();
|
|
1300
|
-
const configPath =
|
|
1625
|
+
const configPath = path14.join(root, CONFIG_PATH2);
|
|
1301
1626
|
const existing = await readJson(configPath);
|
|
1302
1627
|
const cfg = existing ?? structuredClone(DEFAULT_HOOKS_CONFIG);
|
|
1303
1628
|
cfg.hooks ??= {};
|
|
@@ -1669,7 +1994,7 @@ function selectRules(recommended, task, taskIntents) {
|
|
|
1669
1994
|
|
|
1670
1995
|
// src/scanner/scan-project.ts
|
|
1671
1996
|
import { readFile as readFile2 } from "fs/promises";
|
|
1672
|
-
import
|
|
1997
|
+
import path18 from "path";
|
|
1673
1998
|
|
|
1674
1999
|
// src/utils/audit-checks.ts
|
|
1675
2000
|
function isRecord(v) {
|
|
@@ -1696,8 +2021,8 @@ function compareVersions(a, b) {
|
|
|
1696
2021
|
}
|
|
1697
2022
|
|
|
1698
2023
|
// src/scanner/detect-package-manager.ts
|
|
1699
|
-
import
|
|
1700
|
-
import
|
|
2024
|
+
import path15 from "path";
|
|
2025
|
+
import fs12 from "fs-extra";
|
|
1701
2026
|
function detectPackageManager(root, packageManagerField) {
|
|
1702
2027
|
const field = String(packageManagerField ?? "").trim();
|
|
1703
2028
|
if (field.startsWith("yarn@")) {
|
|
@@ -1715,9 +2040,9 @@ function detectPackageManager(root, packageManagerField) {
|
|
|
1715
2040
|
if (satisfiesVersion(version, ">=9")) return "npm";
|
|
1716
2041
|
return "unknown";
|
|
1717
2042
|
}
|
|
1718
|
-
if (
|
|
1719
|
-
if (
|
|
1720
|
-
if (
|
|
2043
|
+
if (fs12.existsSync(path15.join(root, "yarn.lock"))) return "yarn";
|
|
2044
|
+
if (fs12.existsSync(path15.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
2045
|
+
if (fs12.existsSync(path15.join(root, "package-lock.json"))) return "npm";
|
|
1721
2046
|
return "unknown";
|
|
1722
2047
|
}
|
|
1723
2048
|
|
|
@@ -1890,7 +2215,7 @@ function runDetection(ctx, rules = STACK_RULES) {
|
|
|
1890
2215
|
}
|
|
1891
2216
|
|
|
1892
2217
|
// src/scanner/detection.ts
|
|
1893
|
-
import
|
|
2218
|
+
import path16 from "path";
|
|
1894
2219
|
var UNSUPPORTED_MARKERS = {
|
|
1895
2220
|
"requirements.txt": "python",
|
|
1896
2221
|
"pyproject.toml": "python",
|
|
@@ -1944,14 +2269,14 @@ function finalizeRoles(registryRoles, deps, files) {
|
|
|
1944
2269
|
function collectUnsupportedSignals(files) {
|
|
1945
2270
|
return [
|
|
1946
2271
|
...new Set(
|
|
1947
|
-
files.map((f) => UNSUPPORTED_MARKERS[
|
|
2272
|
+
files.map((f) => UNSUPPORTED_MARKERS[path16.basename(f)]).filter((s) => Boolean(s))
|
|
1948
2273
|
)
|
|
1949
2274
|
].sort();
|
|
1950
2275
|
}
|
|
1951
2276
|
|
|
1952
2277
|
// src/scanner/render.ts
|
|
1953
2278
|
import { readFile } from "fs/promises";
|
|
1954
|
-
import
|
|
2279
|
+
import path17 from "path";
|
|
1955
2280
|
|
|
1956
2281
|
// src/scanner/role-labels.ts
|
|
1957
2282
|
var ROLE_LABELS = {
|
|
@@ -2013,7 +2338,7 @@ async function buildContentBlob(root, files) {
|
|
|
2013
2338
|
const slice = candidates.slice(0, 300);
|
|
2014
2339
|
const parts = await mapWithConcurrency(slice, async (rel) => {
|
|
2015
2340
|
try {
|
|
2016
|
-
return await readFile(
|
|
2341
|
+
return await readFile(path17.join(root, rel), "utf8");
|
|
2017
2342
|
} catch {
|
|
2018
2343
|
return "";
|
|
2019
2344
|
}
|
|
@@ -2032,6 +2357,38 @@ ${describeRepo(context)}
|
|
|
2032
2357
|
`;
|
|
2033
2358
|
}
|
|
2034
2359
|
|
|
2360
|
+
// src/scanner/write-sources-report.ts
|
|
2361
|
+
function buildSourcesReport(items) {
|
|
2362
|
+
const statusBySource = /* @__PURE__ */ new Map();
|
|
2363
|
+
for (const item of items) {
|
|
2364
|
+
const src = item.source?.trim();
|
|
2365
|
+
if (!src || src === "haus") continue;
|
|
2366
|
+
if (src === "curated") {
|
|
2367
|
+
if (item.reviewStatus === "approved" && item.riskLevel !== "blocked") {
|
|
2368
|
+
statusBySource.set("curated", "approved");
|
|
2369
|
+
} else if (!statusBySource.has("curated")) {
|
|
2370
|
+
statusBySource.set("curated", "candidate");
|
|
2371
|
+
}
|
|
2372
|
+
continue;
|
|
2373
|
+
}
|
|
2374
|
+
if (item.reviewStatus === "approved" && item.riskLevel !== "blocked") {
|
|
2375
|
+
statusBySource.set(src, "approved");
|
|
2376
|
+
} else if (!statusBySource.has(src)) {
|
|
2377
|
+
statusBySource.set(src, "candidate");
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
return {
|
|
2381
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2382
|
+
items: [...statusBySource.entries()].map(([id, status]) => ({ id, status })).sort((a, b) => a.id.localeCompare(b.id))
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
async function writeSourcesReport(root) {
|
|
2386
|
+
const items = await loadCatalog(root);
|
|
2387
|
+
const report = buildSourcesReport(items);
|
|
2388
|
+
await writeJson(hausPath(root, "sources-report.json"), report);
|
|
2389
|
+
return report;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2035
2392
|
// src/scanner/scan-project.ts
|
|
2036
2393
|
var SAFE_FILES = [
|
|
2037
2394
|
"package.json",
|
|
@@ -2079,8 +2436,8 @@ var SAFE_FILES = [
|
|
|
2079
2436
|
"Gemfile"
|
|
2080
2437
|
];
|
|
2081
2438
|
async function scanProject(root, mode = "fast") {
|
|
2082
|
-
const pkg = await readJson(
|
|
2083
|
-
const composer = await readJson(
|
|
2439
|
+
const pkg = await readJson(path18.join(root, "package.json"));
|
|
2440
|
+
const composer = await readJson(path18.join(root, "composer.json"));
|
|
2084
2441
|
const files = await listFiles(root, SAFE_FILES);
|
|
2085
2442
|
const safeFiles = files.filter((f) => !blocked(f));
|
|
2086
2443
|
const deps = dependencySet(pkg, composer);
|
|
@@ -2114,7 +2471,7 @@ async function scanProject(root, mode = "fast") {
|
|
|
2114
2471
|
mode,
|
|
2115
2472
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2116
2473
|
root,
|
|
2117
|
-
repoName: String(pkg?.name ??
|
|
2474
|
+
repoName: String(pkg?.name ?? path18.basename(root)),
|
|
2118
2475
|
packageManager,
|
|
2119
2476
|
repoRoles: roles,
|
|
2120
2477
|
detectedStacks: stacks,
|
|
@@ -2132,7 +2489,7 @@ async function scanProject(root, mode = "fast") {
|
|
|
2132
2489
|
const scanHashes = Object.fromEntries(
|
|
2133
2490
|
await mapWithConcurrency(
|
|
2134
2491
|
safeFiles,
|
|
2135
|
-
async (f) => [f, hashText(await readFile2(
|
|
2492
|
+
async (f) => [f, hashText(await readFile2(path18.join(root, f), "utf8"))]
|
|
2136
2493
|
)
|
|
2137
2494
|
);
|
|
2138
2495
|
const repoSummary = renderSummary(context);
|
|
@@ -2140,6 +2497,7 @@ async function scanProject(root, mode = "fast") {
|
|
|
2140
2497
|
await writeJson(hausPath(root, "dependency-map.json"), dependencyMap);
|
|
2141
2498
|
await writeJson(hausPath(root, "scan-hashes.json"), scanHashes);
|
|
2142
2499
|
await writeText(hausPath(root, "repo-summary.md"), repoSummary);
|
|
2500
|
+
await writeSourcesReport(root);
|
|
2143
2501
|
return { ...context, dependencyMap, scanHashes, repoSummary };
|
|
2144
2502
|
}
|
|
2145
2503
|
|
|
@@ -2151,6 +2509,18 @@ async function readContextOrScan(root) {
|
|
|
2151
2509
|
return scan;
|
|
2152
2510
|
}
|
|
2153
2511
|
|
|
2512
|
+
// src/security/secret-patterns.ts
|
|
2513
|
+
var SECRET_PATTERNS = [
|
|
2514
|
+
/api[_-]?key\s*[:=]\s*\S+/i,
|
|
2515
|
+
/token\s*[:=]\s*\S+/i,
|
|
2516
|
+
/password\s*[:=]\s*\S+/i
|
|
2517
|
+
];
|
|
2518
|
+
|
|
2519
|
+
// src/security/redact-sensitive.ts
|
|
2520
|
+
function redactSensitive(input2) {
|
|
2521
|
+
return SECRET_PATTERNS.reduce((acc, pattern) => acc.replace(pattern, "[REDACTED]"), input2);
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2154
2524
|
// src/commands/context.ts
|
|
2155
2525
|
async function runContext(options) {
|
|
2156
2526
|
const root = process.cwd();
|
|
@@ -2199,13 +2569,13 @@ async function runContext(options) {
|
|
|
2199
2569
|
}),
|
|
2200
2570
|
summary
|
|
2201
2571
|
];
|
|
2202
|
-
const text = lines.join("\n");
|
|
2572
|
+
const text = redactSensitive(lines.join("\n"));
|
|
2203
2573
|
log(options.fromHook ? text.slice(0, 3e3) : text);
|
|
2204
2574
|
}
|
|
2205
2575
|
|
|
2206
2576
|
// src/commands/doctor.ts
|
|
2207
|
-
import
|
|
2208
|
-
import
|
|
2577
|
+
import path19 from "path";
|
|
2578
|
+
import fs13 from "fs-extra";
|
|
2209
2579
|
|
|
2210
2580
|
// src/update/npm-version.ts
|
|
2211
2581
|
var NPM_PACKAGE_NAME = "@haus-tech/haus-workflow";
|
|
@@ -2285,7 +2655,7 @@ async function runDoctor(options) {
|
|
|
2285
2655
|
const enabled = await isHookEnabled(root, key);
|
|
2286
2656
|
ok(`- HOOK ${key}: ${enabled ? "enabled" : "disabled (default)"}`);
|
|
2287
2657
|
}
|
|
2288
|
-
const rootClaudeMdPath =
|
|
2658
|
+
const rootClaudeMdPath = path19.join(root, "CLAUDE.md");
|
|
2289
2659
|
const rootClaudeMdContent = await readText(rootClaudeMdPath);
|
|
2290
2660
|
if (!rootClaudeMdContent) {
|
|
2291
2661
|
flag(
|
|
@@ -2313,7 +2683,7 @@ async function runDoctor(options) {
|
|
|
2313
2683
|
const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
|
|
2314
2684
|
const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
|
|
2315
2685
|
for (const target of importTargets) {
|
|
2316
|
-
if (!await
|
|
2686
|
+
if (!await fs13.pathExists(hausPath(root, target))) {
|
|
2317
2687
|
flag(
|
|
2318
2688
|
`- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
|
|
2319
2689
|
`A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
|
|
@@ -2324,7 +2694,7 @@ async function runDoctor(options) {
|
|
|
2324
2694
|
}
|
|
2325
2695
|
}
|
|
2326
2696
|
const workflowPath = hausPath(root, "WORKFLOW.md");
|
|
2327
|
-
const workflowExists = await
|
|
2697
|
+
const workflowExists = await fs13.pathExists(workflowPath);
|
|
2328
2698
|
if (!workflowExists) {
|
|
2329
2699
|
flag(
|
|
2330
2700
|
"- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
|
|
@@ -2338,15 +2708,15 @@ async function runDoctor(options) {
|
|
|
2338
2708
|
ok("- .haus-workflow/WORKFLOW.md: OK (user-owned)");
|
|
2339
2709
|
} else {
|
|
2340
2710
|
const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
|
|
2341
|
-
const cachePath =
|
|
2342
|
-
const bundledPath =
|
|
2711
|
+
const cachePath = path19.join(getCacheDir(), "templates/agentic-workflow-standard.md");
|
|
2712
|
+
const bundledPath = path19.join(
|
|
2343
2713
|
packageRoot(),
|
|
2344
2714
|
"library",
|
|
2345
2715
|
"global",
|
|
2346
2716
|
"templates",
|
|
2347
2717
|
"agentic-workflow-standard.md"
|
|
2348
2718
|
);
|
|
2349
|
-
const templatePath = await
|
|
2719
|
+
const templatePath = await fs13.pathExists(cachePath) ? cachePath : bundledPath;
|
|
2350
2720
|
const templateContent = await readText(templatePath);
|
|
2351
2721
|
if (storedHashMatch && templateContent) {
|
|
2352
2722
|
const currentHash = hashText(normaliseLF(templateContent));
|
|
@@ -2365,7 +2735,7 @@ async function runDoctor(options) {
|
|
|
2365
2735
|
}
|
|
2366
2736
|
}
|
|
2367
2737
|
const workflowConfigPath = hausPath(root, "workflow-config.md");
|
|
2368
|
-
const workflowConfigExists = await
|
|
2738
|
+
const workflowConfigExists = await fs13.pathExists(workflowConfigPath);
|
|
2369
2739
|
if (!workflowConfigExists) {
|
|
2370
2740
|
flag(
|
|
2371
2741
|
"- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
|
|
@@ -2373,7 +2743,7 @@ async function runDoctor(options) {
|
|
|
2373
2743
|
"haus apply --write"
|
|
2374
2744
|
);
|
|
2375
2745
|
} else {
|
|
2376
|
-
const cfg = await
|
|
2746
|
+
const cfg = await fs13.readFile(workflowConfigPath, "utf8");
|
|
2377
2747
|
const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
|
|
2378
2748
|
if (unfilled > 0) {
|
|
2379
2749
|
flag(
|
|
@@ -2404,7 +2774,7 @@ async function runDoctor(options) {
|
|
|
2404
2774
|
ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
|
|
2405
2775
|
}
|
|
2406
2776
|
}
|
|
2407
|
-
const pkgJson = await readJson(
|
|
2777
|
+
const pkgJson = await readJson(path19.join(packageRoot(), "package.json"));
|
|
2408
2778
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
2409
2779
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
2410
2780
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
@@ -2413,7 +2783,9 @@ async function runDoctor(options) {
|
|
|
2413
2783
|
`A newer haus (${npmStatus.latest}) is available`,
|
|
2414
2784
|
`npm install -g ${NPM_PACKAGE_NAME}`
|
|
2415
2785
|
);
|
|
2416
|
-
process.
|
|
2786
|
+
if (!process.env["HAUS_FIXTURE_CATALOG"]) {
|
|
2787
|
+
process.exitCode = 1;
|
|
2788
|
+
}
|
|
2417
2789
|
} else if (npmStatus.latest !== null) {
|
|
2418
2790
|
ok(`- CLI: ${currentVersion} (up to date)`);
|
|
2419
2791
|
} else {
|
|
@@ -2545,8 +2917,8 @@ async function runGuard(kind, _options) {
|
|
|
2545
2917
|
}
|
|
2546
2918
|
|
|
2547
2919
|
// src/commands/init.ts
|
|
2548
|
-
import
|
|
2549
|
-
import
|
|
2920
|
+
import path20 from "path";
|
|
2921
|
+
import fs14 from "fs-extra";
|
|
2550
2922
|
|
|
2551
2923
|
// src/utils/prompts.ts
|
|
2552
2924
|
import { stdin as input, stdout as output } from "process";
|
|
@@ -2606,23 +2978,7 @@ async function readChangedFiles(root) {
|
|
|
2606
2978
|
}
|
|
2607
2979
|
|
|
2608
2980
|
// src/recommender/policies.ts
|
|
2609
|
-
var UNSUPPORTED =
|
|
2610
|
-
"python",
|
|
2611
|
-
"django",
|
|
2612
|
-
"go",
|
|
2613
|
-
"rust",
|
|
2614
|
-
"java",
|
|
2615
|
-
"spring",
|
|
2616
|
-
"kotlin",
|
|
2617
|
-
"swift",
|
|
2618
|
-
"android",
|
|
2619
|
-
"flutter",
|
|
2620
|
-
"dart",
|
|
2621
|
-
"c++",
|
|
2622
|
-
"perl",
|
|
2623
|
-
"defi",
|
|
2624
|
-
"trading"
|
|
2625
|
-
];
|
|
2981
|
+
var UNSUPPORTED = FORBIDDEN_TAGS;
|
|
2626
2982
|
function matchRequiresAny(clauses, ctx) {
|
|
2627
2983
|
for (const clause of clauses) {
|
|
2628
2984
|
if ("stack" in clause) {
|
|
@@ -2908,6 +3264,15 @@ async function runSetupCore(root, opts) {
|
|
|
2908
3264
|
return baseResult;
|
|
2909
3265
|
}
|
|
2910
3266
|
}
|
|
3267
|
+
if (!dryRun && !process.env["HAUS_FIXTURE_CATALOG"]) {
|
|
3268
|
+
log("Syncing remote catalog...");
|
|
3269
|
+
const sync = await syncRemoteCatalog();
|
|
3270
|
+
if (sync.newItems.length > 0) {
|
|
3271
|
+
log(`Catalog cache populated: ${sync.newItems.length} new item(s).`);
|
|
3272
|
+
} else if (sync.refreshed.length > 0) {
|
|
3273
|
+
log(`Catalog cache refreshed: ${sync.refreshed.length} updated item(s).`);
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
2911
3276
|
const files = await writeClaudeFiles(root, dryRun ?? false);
|
|
2912
3277
|
log("Applied files:");
|
|
2913
3278
|
files.forEach((f) => log(`- ${displayPath(root, f)}`));
|
|
@@ -2974,35 +3339,22 @@ async function runSetupProject(options) {
|
|
|
2974
3339
|
// src/commands/init.ts
|
|
2975
3340
|
async function runInit(options) {
|
|
2976
3341
|
const root = process.cwd();
|
|
2977
|
-
const hausDir =
|
|
2978
|
-
const alreadyInit = await
|
|
3342
|
+
const hausDir = path20.join(root, ".haus-workflow");
|
|
3343
|
+
const alreadyInit = await fs14.pathExists(hausDir);
|
|
2979
3344
|
if (alreadyInit) {
|
|
2980
|
-
log("Haus AI already initialized in this project.");
|
|
2981
|
-
log("Run `haus setup-project` to reconfigure.");
|
|
2982
|
-
return;
|
|
2983
|
-
}
|
|
2984
|
-
log("Welcome to Haus AI. Initializing this project for the first time.");
|
|
2985
|
-
await runSetupProject(options);
|
|
2986
|
-
}
|
|
2987
|
-
|
|
2988
|
-
// src/install/apply.ts
|
|
2989
|
-
import crypto2 from "crypto";
|
|
2990
|
-
import path21 from "path";
|
|
2991
|
-
import fs14 from "fs-extra";
|
|
2992
|
-
|
|
2993
|
-
// src/install/allow-rules.ts
|
|
2994
|
-
var ALLOWED_SUBCOMMANDS = [
|
|
2995
|
-
"setup-project",
|
|
2996
|
-
"apply",
|
|
2997
|
-
"doctor",
|
|
2998
|
-
"scan",
|
|
2999
|
-
"context",
|
|
3000
|
-
"recommend"
|
|
3001
|
-
];
|
|
3002
|
-
function buildAllowRules() {
|
|
3003
|
-
return [...new Set(ALLOWED_SUBCOMMANDS.map((sub) => `Bash(haus ${sub}:*)`))];
|
|
3345
|
+
log("Haus AI already initialized in this project.");
|
|
3346
|
+
log("Run `haus setup-project` to reconfigure.");
|
|
3347
|
+
return;
|
|
3348
|
+
}
|
|
3349
|
+
log("Welcome to Haus AI. Initializing this project for the first time.");
|
|
3350
|
+
await runSetupProject(options);
|
|
3004
3351
|
}
|
|
3005
3352
|
|
|
3353
|
+
// src/install/apply.ts
|
|
3354
|
+
import crypto2 from "crypto";
|
|
3355
|
+
import path21 from "path";
|
|
3356
|
+
import fs15 from "fs-extra";
|
|
3357
|
+
|
|
3006
3358
|
// src/install/header.ts
|
|
3007
3359
|
var MD_PREFIX = "<!-- HAUS-MANAGED";
|
|
3008
3360
|
var MD_SUFFIX = " -->";
|
|
@@ -3033,185 +3385,6 @@ ${rest}`;
|
|
|
3033
3385
|
${content2}`;
|
|
3034
3386
|
}
|
|
3035
3387
|
|
|
3036
|
-
// src/install/manifest.ts
|
|
3037
|
-
import os4 from "os";
|
|
3038
|
-
import path19 from "path";
|
|
3039
|
-
var MANIFEST_SCHEMA = "haus-install-manifest/1";
|
|
3040
|
-
function globalClaudeDir() {
|
|
3041
|
-
return path19.join(os4.homedir(), ".claude");
|
|
3042
|
-
}
|
|
3043
|
-
function hausManifestPath() {
|
|
3044
|
-
return path19.join(globalClaudeDir(), "haus", "install-manifest.json");
|
|
3045
|
-
}
|
|
3046
|
-
async function readManifest() {
|
|
3047
|
-
return readJson(hausManifestPath());
|
|
3048
|
-
}
|
|
3049
|
-
async function writeManifest(manifest) {
|
|
3050
|
-
await writeJson(hausManifestPath(), manifest);
|
|
3051
|
-
}
|
|
3052
|
-
function buildManifest(source, files, hooks) {
|
|
3053
|
-
return {
|
|
3054
|
-
_schema: MANIFEST_SCHEMA,
|
|
3055
|
-
source,
|
|
3056
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3057
|
-
files,
|
|
3058
|
-
hooks
|
|
3059
|
-
};
|
|
3060
|
-
}
|
|
3061
|
-
|
|
3062
|
-
// src/install/settings-merge.ts
|
|
3063
|
-
import path20 from "path";
|
|
3064
|
-
import fs13 from "fs-extra";
|
|
3065
|
-
function settingsJsonPath() {
|
|
3066
|
-
return path20.join(globalClaudeDir(), "settings.json");
|
|
3067
|
-
}
|
|
3068
|
-
async function readSettings() {
|
|
3069
|
-
const parsed = await readJson(settingsJsonPath());
|
|
3070
|
-
return parsed ?? {};
|
|
3071
|
-
}
|
|
3072
|
-
async function writeSettings(settings) {
|
|
3073
|
-
await writeJson(settingsJsonPath(), settings);
|
|
3074
|
-
}
|
|
3075
|
-
function mergeHooks(settings, fragments) {
|
|
3076
|
-
const existing = settings._haus?.hooks ?? [];
|
|
3077
|
-
const existingCommands = settings._haus?.hookCommands ?? [];
|
|
3078
|
-
const existingSet = new Set(existing);
|
|
3079
|
-
const updated = { ...settings };
|
|
3080
|
-
updated.hooks = { ...settings.hooks ?? {} };
|
|
3081
|
-
const addedIds = [];
|
|
3082
|
-
const addedCommands = [];
|
|
3083
|
-
for (const fragment of fragments) {
|
|
3084
|
-
if (fragment.gate !== "keep") continue;
|
|
3085
|
-
if (existingSet.has(fragment.id)) continue;
|
|
3086
|
-
const event = fragment.event;
|
|
3087
|
-
if (!updated.hooks[event]) updated.hooks[event] = [];
|
|
3088
|
-
const entry = {
|
|
3089
|
-
hooks: [{ type: "command", command: fragment.command }]
|
|
3090
|
-
};
|
|
3091
|
-
if (fragment.matcher) entry.matcher = fragment.matcher;
|
|
3092
|
-
updated.hooks[event] = [...updated.hooks[event] ?? [], entry];
|
|
3093
|
-
addedIds.push(fragment.id);
|
|
3094
|
-
addedCommands.push(fragment.command);
|
|
3095
|
-
}
|
|
3096
|
-
updated._haus = {
|
|
3097
|
-
hooks: [...existing, ...addedIds],
|
|
3098
|
-
hookCommands: [...existingCommands, ...addedCommands],
|
|
3099
|
-
// Preserve deny/allow tracking so hook, deny, and allow merges are order-independent.
|
|
3100
|
-
...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
|
|
3101
|
-
...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
|
|
3102
|
-
};
|
|
3103
|
-
return { settings: updated, addedIds };
|
|
3104
|
-
}
|
|
3105
|
-
function mergeDenyRules(settings, rules) {
|
|
3106
|
-
const existingDeny = settings.permissions?.deny ?? [];
|
|
3107
|
-
const seen = new Set(existingDeny);
|
|
3108
|
-
const trackedDeny = settings._haus?.denyRules ?? [];
|
|
3109
|
-
const addedRules = [];
|
|
3110
|
-
for (const rule of rules) {
|
|
3111
|
-
if (seen.has(rule)) continue;
|
|
3112
|
-
seen.add(rule);
|
|
3113
|
-
addedRules.push(rule);
|
|
3114
|
-
}
|
|
3115
|
-
const updated = { ...settings };
|
|
3116
|
-
updated.permissions = {
|
|
3117
|
-
...settings.permissions ?? {},
|
|
3118
|
-
deny: [...existingDeny, ...addedRules]
|
|
3119
|
-
};
|
|
3120
|
-
updated._haus = {
|
|
3121
|
-
hooks: settings._haus?.hooks ?? [],
|
|
3122
|
-
...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
|
|
3123
|
-
denyRules: [...trackedDeny, ...addedRules],
|
|
3124
|
-
...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {}
|
|
3125
|
-
};
|
|
3126
|
-
return { settings: updated, addedRules };
|
|
3127
|
-
}
|
|
3128
|
-
function mergeAllowRules(settings, rules) {
|
|
3129
|
-
const existingAllow = settings.permissions?.allow ?? [];
|
|
3130
|
-
const seen = new Set(existingAllow);
|
|
3131
|
-
const trackedAllow = settings._haus?.allowRules ?? [];
|
|
3132
|
-
const addedRules = [];
|
|
3133
|
-
for (const rule of rules) {
|
|
3134
|
-
if (seen.has(rule)) continue;
|
|
3135
|
-
seen.add(rule);
|
|
3136
|
-
addedRules.push(rule);
|
|
3137
|
-
}
|
|
3138
|
-
const updated = { ...settings };
|
|
3139
|
-
updated.permissions = {
|
|
3140
|
-
...settings.permissions ?? {},
|
|
3141
|
-
allow: [...existingAllow, ...addedRules]
|
|
3142
|
-
};
|
|
3143
|
-
updated._haus = {
|
|
3144
|
-
hooks: settings._haus?.hooks ?? [],
|
|
3145
|
-
...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
|
|
3146
|
-
...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
|
|
3147
|
-
allowRules: [...trackedAllow, ...addedRules]
|
|
3148
|
-
};
|
|
3149
|
-
return { settings: updated, addedRules };
|
|
3150
|
-
}
|
|
3151
|
-
function stripHausAllow(settings) {
|
|
3152
|
-
const prevHaus = settings._haus;
|
|
3153
|
-
if (!prevHaus?.allowRules || prevHaus.allowRules.length === 0) return settings;
|
|
3154
|
-
const ownedSet = new Set(prevHaus.allowRules);
|
|
3155
|
-
const updated = { ...settings };
|
|
3156
|
-
const remainingAllow = (settings.permissions?.allow ?? []).filter((rule) => !ownedSet.has(rule));
|
|
3157
|
-
const permissions = { ...settings.permissions ?? {} };
|
|
3158
|
-
if (remainingAllow.length > 0) permissions.allow = remainingAllow;
|
|
3159
|
-
else delete permissions.allow;
|
|
3160
|
-
if (Object.keys(permissions).length > 0) updated.permissions = permissions;
|
|
3161
|
-
else delete updated.permissions;
|
|
3162
|
-
const haus = { ...prevHaus };
|
|
3163
|
-
delete haus.allowRules;
|
|
3164
|
-
const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0;
|
|
3165
|
-
if (stillTracking) updated._haus = haus;
|
|
3166
|
-
else delete updated._haus;
|
|
3167
|
-
return updated;
|
|
3168
|
-
}
|
|
3169
|
-
function stripHausDeny(settings) {
|
|
3170
|
-
const prevHaus = settings._haus;
|
|
3171
|
-
if (!prevHaus?.denyRules || prevHaus.denyRules.length === 0) return settings;
|
|
3172
|
-
const ownedSet = new Set(prevHaus.denyRules);
|
|
3173
|
-
const updated = { ...settings };
|
|
3174
|
-
const remainingDeny = (settings.permissions?.deny ?? []).filter((rule) => !ownedSet.has(rule));
|
|
3175
|
-
const permissions = { ...settings.permissions ?? {} };
|
|
3176
|
-
if (remainingDeny.length > 0) permissions.deny = remainingDeny;
|
|
3177
|
-
else delete permissions.deny;
|
|
3178
|
-
if (Object.keys(permissions).length > 0) updated.permissions = permissions;
|
|
3179
|
-
else delete updated.permissions;
|
|
3180
|
-
const haus = { ...prevHaus };
|
|
3181
|
-
delete haus.denyRules;
|
|
3182
|
-
const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0;
|
|
3183
|
-
if (stillTracking) updated._haus = haus;
|
|
3184
|
-
else delete updated._haus;
|
|
3185
|
-
return updated;
|
|
3186
|
-
}
|
|
3187
|
-
function stripHausHooks(settings) {
|
|
3188
|
-
if (!settings._haus) return settings;
|
|
3189
|
-
const ownedCommands = new Set(settings._haus.hookCommands ?? []);
|
|
3190
|
-
const usePrefix = ownedCommands.size === 0;
|
|
3191
|
-
const updated = { ...settings };
|
|
3192
|
-
updated.hooks = {};
|
|
3193
|
-
for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
|
|
3194
|
-
const kept = entries.filter((entry) => {
|
|
3195
|
-
const cmd = entry.hooks[0]?.command ?? "";
|
|
3196
|
-
return usePrefix ? !cmd.startsWith("haus ") : !ownedCommands.has(cmd);
|
|
3197
|
-
});
|
|
3198
|
-
if (kept.length > 0) updated.hooks[event] = kept;
|
|
3199
|
-
}
|
|
3200
|
-
const { _haus: _, ...rest } = updated;
|
|
3201
|
-
void _;
|
|
3202
|
-
return rest;
|
|
3203
|
-
}
|
|
3204
|
-
async function loadHooksFragment(fragmentPath) {
|
|
3205
|
-
let raw;
|
|
3206
|
-
try {
|
|
3207
|
-
raw = await fs13.readJson(fragmentPath);
|
|
3208
|
-
} catch {
|
|
3209
|
-
return [];
|
|
3210
|
-
}
|
|
3211
|
-
const data = raw;
|
|
3212
|
-
return Array.isArray(data?.hooks) ? data.hooks : [];
|
|
3213
|
-
}
|
|
3214
|
-
|
|
3215
3388
|
// src/install/apply.ts
|
|
3216
3389
|
var SCHEMA_VERSION2 = "1";
|
|
3217
3390
|
function hashContent(content2) {
|
|
@@ -3220,7 +3393,7 @@ function hashContent(content2) {
|
|
|
3220
3393
|
function sourceVersion() {
|
|
3221
3394
|
try {
|
|
3222
3395
|
const pkgPath = path21.join(packageRoot(), "package.json");
|
|
3223
|
-
const pkg = JSON.parse(
|
|
3396
|
+
const pkg = JSON.parse(fs15.readFileSync(pkgPath, "utf8"));
|
|
3224
3397
|
return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
|
|
3225
3398
|
} catch {
|
|
3226
3399
|
return "haus@0.0.0";
|
|
@@ -3232,10 +3405,10 @@ function globalSrcDir() {
|
|
|
3232
3405
|
function collectSourceFiles(srcDir, claudeDir) {
|
|
3233
3406
|
const entries = [];
|
|
3234
3407
|
const skillsDir = path21.join(srcDir, "skills");
|
|
3235
|
-
if (
|
|
3236
|
-
for (const skillName of
|
|
3408
|
+
if (fs15.pathExistsSync(skillsDir)) {
|
|
3409
|
+
for (const skillName of fs15.readdirSync(skillsDir)) {
|
|
3237
3410
|
const skillFile = path21.join(skillsDir, skillName, "SKILL.md");
|
|
3238
|
-
if (
|
|
3411
|
+
if (fs15.pathExistsSync(skillFile)) {
|
|
3239
3412
|
entries.push({
|
|
3240
3413
|
stableId: `skill.${skillName}`,
|
|
3241
3414
|
srcRelPath: path21.join("library", "global", "skills", skillName, "SKILL.md"),
|
|
@@ -3245,8 +3418,8 @@ function collectSourceFiles(srcDir, claudeDir) {
|
|
|
3245
3418
|
}
|
|
3246
3419
|
}
|
|
3247
3420
|
const commandsDir = path21.join(srcDir, "commands");
|
|
3248
|
-
if (
|
|
3249
|
-
for (const fileName of
|
|
3421
|
+
if (fs15.pathExistsSync(commandsDir)) {
|
|
3422
|
+
for (const fileName of fs15.readdirSync(commandsDir)) {
|
|
3250
3423
|
if (!fileName.endsWith(".md")) continue;
|
|
3251
3424
|
const commandName = fileName.slice(0, -".md".length);
|
|
3252
3425
|
entries.push({
|
|
@@ -3296,7 +3469,7 @@ async function applyInstall(options = {}) {
|
|
|
3296
3469
|
}
|
|
3297
3470
|
continue;
|
|
3298
3471
|
}
|
|
3299
|
-
const destExists =
|
|
3472
|
+
const destExists = fs15.pathExistsSync(entry.destPath);
|
|
3300
3473
|
if (destExists) {
|
|
3301
3474
|
const currentContent = await readText(entry.destPath);
|
|
3302
3475
|
if (currentContent !== void 0) {
|
|
@@ -3343,13 +3516,13 @@ async function applyInstall(options = {}) {
|
|
|
3343
3516
|
const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
|
|
3344
3517
|
for (const entry of existingManifest.files) {
|
|
3345
3518
|
if (currentDestPaths.has(entry.destPath)) continue;
|
|
3346
|
-
if (!
|
|
3519
|
+
if (!fs15.pathExistsSync(entry.destPath)) continue;
|
|
3347
3520
|
const content2 = await readText(entry.destPath);
|
|
3348
3521
|
if (!content2) continue;
|
|
3349
3522
|
const hasHeader = parseMarkdownHeader(content2) !== void 0;
|
|
3350
3523
|
const currentHash = hashContent(content2);
|
|
3351
3524
|
if (hasHeader && currentHash === entry.hash) {
|
|
3352
|
-
if (!dryRun) await
|
|
3525
|
+
if (!dryRun) await fs15.remove(entry.destPath);
|
|
3353
3526
|
result.deleted.push(entry.destPath);
|
|
3354
3527
|
} else {
|
|
3355
3528
|
warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
|
|
@@ -3452,10 +3625,14 @@ async function runRecommend(options) {
|
|
|
3452
3625
|
|
|
3453
3626
|
// src/commands/refresh.ts
|
|
3454
3627
|
async function runRefresh() {
|
|
3455
|
-
const
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3628
|
+
const root = process.cwd();
|
|
3629
|
+
const context = await scanProject(root, "fast");
|
|
3630
|
+
const recommendation = await recommend(root, context);
|
|
3631
|
+
await writeJson(hausPath(root, "recommendation.json"), recommendation);
|
|
3632
|
+
log("Haus refresh complete");
|
|
3633
|
+
log(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
|
|
3634
|
+
log(`Package manager: ${context.packageManager}`);
|
|
3635
|
+
log(`Recommended items: ${recommendation.recommended.length}`);
|
|
3459
3636
|
}
|
|
3460
3637
|
|
|
3461
3638
|
// src/commands/scan.ts
|
|
@@ -3473,35 +3650,128 @@ async function runScan(options) {
|
|
|
3473
3650
|
|
|
3474
3651
|
// src/commands/undo.ts
|
|
3475
3652
|
import path22 from "path";
|
|
3476
|
-
import
|
|
3477
|
-
|
|
3653
|
+
import fs16 from "fs-extra";
|
|
3654
|
+
|
|
3655
|
+
// src/claude/managed-paths.ts
|
|
3656
|
+
var PROJECT_MANAGED_CLAUDE_REL = [
|
|
3657
|
+
"rules/haus.md",
|
|
3658
|
+
"rules/security.md",
|
|
3659
|
+
"commands/haus-doctor.md",
|
|
3660
|
+
"commands/haus-review.md"
|
|
3661
|
+
];
|
|
3662
|
+
var PROJECT_MANAGED_HAUS_REL = [
|
|
3663
|
+
"selected-context.json",
|
|
3664
|
+
"haus.lock.json",
|
|
3665
|
+
"config.json"
|
|
3666
|
+
];
|
|
3667
|
+
function coreManagedAbsolutePaths(root) {
|
|
3668
|
+
const claude = PROJECT_MANAGED_CLAUDE_REL.map((rel) => claudePath(root, rel));
|
|
3669
|
+
const haus = PROJECT_MANAGED_HAUS_REL.map((rel) => hausPath(root, rel));
|
|
3670
|
+
return [...claude, ...haus];
|
|
3671
|
+
}
|
|
3672
|
+
|
|
3673
|
+
// src/commands/undo.ts
|
|
3674
|
+
async function collectManagedPaths(root) {
|
|
3675
|
+
const paths = new Set(coreManagedAbsolutePaths(root));
|
|
3676
|
+
const lock = await readJson(hausPath(root, "haus.lock.json"));
|
|
3677
|
+
for (const row of lock ?? []) {
|
|
3678
|
+
for (const rel of row.paths ?? []) {
|
|
3679
|
+
paths.add(path22.resolve(root, rel));
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3682
|
+
const existing = [];
|
|
3683
|
+
for (const abs of paths) {
|
|
3684
|
+
if (await fs16.pathExists(abs)) existing.push(abs);
|
|
3685
|
+
}
|
|
3686
|
+
return existing;
|
|
3687
|
+
}
|
|
3688
|
+
async function settingsHasHausContent(root) {
|
|
3689
|
+
const settingsPath = claudePath(root, "settings.json");
|
|
3690
|
+
if (!await fs16.pathExists(settingsPath)) return false;
|
|
3691
|
+
const settings = await readProjectSettings(root);
|
|
3692
|
+
return settings._haus != null;
|
|
3693
|
+
}
|
|
3694
|
+
async function claudeMdHasHausBlock(root) {
|
|
3695
|
+
const filePath = path22.join(root, "CLAUDE.md");
|
|
3696
|
+
if (!await fs16.pathExists(filePath)) return false;
|
|
3697
|
+
const text = await fs16.readFile(filePath, "utf8");
|
|
3698
|
+
return text.includes(BLOCK_BEGIN);
|
|
3699
|
+
}
|
|
3700
|
+
async function stripProjectSettings(root) {
|
|
3701
|
+
const settingsPath = claudePath(root, "settings.json");
|
|
3702
|
+
if (!await fs16.pathExists(settingsPath)) return false;
|
|
3703
|
+
let settings = await readProjectSettings(root);
|
|
3704
|
+
settings = stripHausAllow(stripHausDeny(stripHausHooks(settings)));
|
|
3705
|
+
const hasContent = Object.keys(settings).length > 0;
|
|
3706
|
+
if (hasContent) {
|
|
3707
|
+
await writeProjectSettings(root, settings);
|
|
3708
|
+
log(`Stripped haus rules from ${path22.relative(root, settingsPath)} (user settings preserved).`);
|
|
3709
|
+
return true;
|
|
3710
|
+
}
|
|
3711
|
+
await fs16.remove(settingsPath);
|
|
3712
|
+
log(`Removed ${path22.relative(root, settingsPath)} (no user-owned settings remained).`);
|
|
3713
|
+
return true;
|
|
3714
|
+
}
|
|
3715
|
+
async function stripRootClaudeMd(root) {
|
|
3716
|
+
const filePath = path22.join(root, "CLAUDE.md");
|
|
3717
|
+
if (!await fs16.pathExists(filePath)) return false;
|
|
3718
|
+
const prev = await fs16.readFile(filePath, "utf8");
|
|
3719
|
+
if (!prev.includes(BLOCK_BEGIN)) return false;
|
|
3720
|
+
const next = stripHausBlock(prev);
|
|
3721
|
+
if (next.length === 0) {
|
|
3722
|
+
await fs16.remove(filePath);
|
|
3723
|
+
log("Removed CLAUDE.md (only contained haus import block).");
|
|
3724
|
+
} else {
|
|
3725
|
+
await fs16.writeFile(filePath, next, "utf8");
|
|
3726
|
+
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
3727
|
+
}
|
|
3728
|
+
return true;
|
|
3729
|
+
}
|
|
3730
|
+
async function pruneDirIfEmpty(dir) {
|
|
3731
|
+
if (!await fs16.pathExists(dir)) return;
|
|
3732
|
+
const entries = await fs16.readdir(dir);
|
|
3733
|
+
if (entries.length === 0) await fs16.remove(dir);
|
|
3734
|
+
}
|
|
3478
3735
|
async function runUndo(options) {
|
|
3479
3736
|
const root = process.cwd();
|
|
3480
|
-
const
|
|
3481
|
-
const
|
|
3482
|
-
|
|
3483
|
-
|
|
3737
|
+
const managed = await collectManagedPaths(root);
|
|
3738
|
+
const stripSettings = await settingsHasHausContent(root);
|
|
3739
|
+
const stripClaudeMd = await claudeMdHasHausBlock(root);
|
|
3740
|
+
if (managed.length === 0 && !stripSettings && !stripClaudeMd) {
|
|
3741
|
+
log("Nothing to remove: no haus-managed files found in this directory.");
|
|
3484
3742
|
return;
|
|
3485
3743
|
}
|
|
3744
|
+
const relTargets = managed.map((p) => path22.relative(root, p));
|
|
3745
|
+
const summaryParts = [...relTargets];
|
|
3746
|
+
if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
|
|
3747
|
+
if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
|
|
3486
3748
|
if (!options.yes) {
|
|
3487
3749
|
const ok = await confirm(
|
|
3488
|
-
`Remove
|
|
3750
|
+
`Remove haus-managed files?
|
|
3751
|
+
${summaryParts.join("\n ")}
|
|
3752
|
+
User-owned .claude/ files will be preserved.`
|
|
3489
3753
|
);
|
|
3490
3754
|
if (!ok) {
|
|
3491
3755
|
log("Cancelled.");
|
|
3492
3756
|
return;
|
|
3493
3757
|
}
|
|
3494
3758
|
}
|
|
3495
|
-
for (const
|
|
3496
|
-
await
|
|
3497
|
-
|
|
3759
|
+
for (const abs of managed) {
|
|
3760
|
+
if (!await fs16.pathExists(abs)) continue;
|
|
3761
|
+
await fs16.remove(abs);
|
|
3762
|
+
log(`Removed ${path22.relative(root, abs)}`);
|
|
3498
3763
|
}
|
|
3764
|
+
if (stripSettings) await stripProjectSettings(root);
|
|
3765
|
+
if (stripClaudeMd) await stripRootClaudeMd(root);
|
|
3766
|
+
await pruneDirIfEmpty(claudePath(root));
|
|
3767
|
+
await pruneDirIfEmpty(hausPath(root));
|
|
3768
|
+
log("haus undo complete. Scan artifacts under .haus-workflow/ were left in place.");
|
|
3499
3769
|
}
|
|
3500
3770
|
|
|
3501
3771
|
// src/install/uninstall.ts
|
|
3502
3772
|
import crypto3 from "crypto";
|
|
3503
3773
|
import path23 from "path";
|
|
3504
|
-
import
|
|
3774
|
+
import fs17 from "fs-extra";
|
|
3505
3775
|
async function runUninstall(options = {}) {
|
|
3506
3776
|
const { force = false } = options;
|
|
3507
3777
|
const manifest = await readManifest();
|
|
@@ -3511,7 +3781,7 @@ async function runUninstall(options = {}) {
|
|
|
3511
3781
|
return result;
|
|
3512
3782
|
}
|
|
3513
3783
|
for (const entry of manifest.files) {
|
|
3514
|
-
const exists =
|
|
3784
|
+
const exists = fs17.pathExistsSync(entry.destPath);
|
|
3515
3785
|
if (!exists) continue;
|
|
3516
3786
|
const content2 = await readText(entry.destPath);
|
|
3517
3787
|
if (content2 === void 0) continue;
|
|
@@ -3529,7 +3799,7 @@ async function runUninstall(options = {}) {
|
|
|
3529
3799
|
result.skipped.push(entry.destPath);
|
|
3530
3800
|
continue;
|
|
3531
3801
|
}
|
|
3532
|
-
await
|
|
3802
|
+
await fs17.remove(entry.destPath);
|
|
3533
3803
|
await pruneEmptyDir(path23.dirname(entry.destPath));
|
|
3534
3804
|
result.deleted.push(entry.destPath);
|
|
3535
3805
|
}
|
|
@@ -3539,12 +3809,12 @@ async function runUninstall(options = {}) {
|
|
|
3539
3809
|
result.hooksStripped = true;
|
|
3540
3810
|
const hausDir = path23.join(globalClaudeDir(), "haus");
|
|
3541
3811
|
const manifestPath2 = hausManifestPath();
|
|
3542
|
-
if (
|
|
3543
|
-
await
|
|
3812
|
+
if (fs17.pathExistsSync(manifestPath2)) {
|
|
3813
|
+
await fs17.remove(manifestPath2);
|
|
3544
3814
|
}
|
|
3545
|
-
if (
|
|
3546
|
-
const remaining = await
|
|
3547
|
-
if (remaining.length === 0) await
|
|
3815
|
+
if (fs17.pathExistsSync(hausDir)) {
|
|
3816
|
+
const remaining = await fs17.readdir(hausDir);
|
|
3817
|
+
if (remaining.length === 0) await fs17.remove(hausDir);
|
|
3548
3818
|
}
|
|
3549
3819
|
return result;
|
|
3550
3820
|
}
|
|
@@ -3563,8 +3833,8 @@ function printUninstallResult(result) {
|
|
|
3563
3833
|
}
|
|
3564
3834
|
async function pruneEmptyDir(dir) {
|
|
3565
3835
|
try {
|
|
3566
|
-
const entries = await
|
|
3567
|
-
if (entries.length === 0) await
|
|
3836
|
+
const entries = await fs17.readdir(dir);
|
|
3837
|
+
if (entries.length === 0) await fs17.remove(dir);
|
|
3568
3838
|
} catch {
|
|
3569
3839
|
}
|
|
3570
3840
|
}
|
|
@@ -3616,7 +3886,17 @@ async function checkLock(root) {
|
|
|
3616
3886
|
(item) => !item.version || normalizeVersion(item.version) !== null
|
|
3617
3887
|
);
|
|
3618
3888
|
const catalogRef = lock[0]?.catalogRef ?? null;
|
|
3619
|
-
|
|
3889
|
+
const drift = [];
|
|
3890
|
+
for (const item of lock) {
|
|
3891
|
+
if (!item.hash) continue;
|
|
3892
|
+
const paths = Array.isArray(item.paths) ? item.paths.map(String) : [];
|
|
3893
|
+
const actual = await hashInstalledPaths(root, paths);
|
|
3894
|
+
if (item.hash !== actual) {
|
|
3895
|
+
drift.push({ id: item.id, expected: item.hash, actual });
|
|
3896
|
+
}
|
|
3897
|
+
}
|
|
3898
|
+
const ok = lock.length > 0 && hasValidVersions && drift.length === 0;
|
|
3899
|
+
return { ok, count: lock.length, catalogRef, drift, driftCount: drift.length };
|
|
3620
3900
|
}
|
|
3621
3901
|
async function applyLock(root) {
|
|
3622
3902
|
const lockPath = hausPath(root, "haus.lock.json");
|
|
@@ -3666,10 +3946,11 @@ async function runUpdate(options) {
|
|
|
3666
3946
|
if (options.check) {
|
|
3667
3947
|
const pkgJson2 = await readJson(path25.join(packageRoot(), "package.json"));
|
|
3668
3948
|
const currentVersion2 = pkgJson2?.version ?? "0.0.0";
|
|
3669
|
-
const [status, npmVersion, latestCatalogTag] = await Promise.all([
|
|
3949
|
+
const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
|
|
3670
3950
|
checkLock(root),
|
|
3671
3951
|
fetchNpmVersionStatus(currentVersion2),
|
|
3672
|
-
fetchLatestCatalogTag()
|
|
3952
|
+
fetchLatestCatalogTag(),
|
|
3953
|
+
detectGlobalInstallDrift()
|
|
3673
3954
|
]);
|
|
3674
3955
|
const installedRef = status.catalogRef ?? "main";
|
|
3675
3956
|
const catalogRefBehind = latestCatalogTag !== null && installedRef !== latestCatalogTag ? `installed from ${installedRef}, latest tag is ${latestCatalogTag}` : false;
|
|
@@ -3680,6 +3961,7 @@ async function runUpdate(options) {
|
|
|
3680
3961
|
installedCatalogRef: installedRef,
|
|
3681
3962
|
latestCatalogTag,
|
|
3682
3963
|
catalogRefBehind,
|
|
3964
|
+
globalInstallDrift,
|
|
3683
3965
|
localOverrides: await hasLocalOverrides(root),
|
|
3684
3966
|
summary: diffGeneratedFiles(),
|
|
3685
3967
|
npmVersion
|
|
@@ -3689,6 +3971,7 @@ async function runUpdate(options) {
|
|
|
3689
3971
|
)
|
|
3690
3972
|
);
|
|
3691
3973
|
if (!status.ok) process.exitCode = 1;
|
|
3974
|
+
if (status.driftCount > 0) process.exitCode = 1;
|
|
3692
3975
|
return;
|
|
3693
3976
|
}
|
|
3694
3977
|
const pkgJson = await readJson(path25.join(packageRoot(), "package.json"));
|
|
@@ -3701,7 +3984,7 @@ async function runUpdate(options) {
|
|
|
3701
3984
|
log(`npm package up to date: ${currentVersion}`);
|
|
3702
3985
|
}
|
|
3703
3986
|
if (await hasLocalOverrides(root)) {
|
|
3704
|
-
log("
|
|
3987
|
+
log("Existing .claude/settings.json \u2014 haus rules will be merged, not replaced.");
|
|
3705
3988
|
}
|
|
3706
3989
|
const { before, after } = await applyLock(root);
|
|
3707
3990
|
log(diffLock(before, after));
|
|
@@ -3711,18 +3994,91 @@ async function runUpdate(options) {
|
|
|
3711
3994
|
if (sync.newItems.length > 0) {
|
|
3712
3995
|
log(`Catalog updated: ${sync.newItems.length} new item(s): ${sync.newItems.join(", ")}`);
|
|
3713
3996
|
log("Run `haus recommend && haus apply --write` to install new skills.");
|
|
3714
|
-
}
|
|
3997
|
+
}
|
|
3998
|
+
if (sync.refreshed.length > 0) {
|
|
3999
|
+
log(`Catalog refreshed: ${sync.refreshed.length} updated item(s): ${sync.refreshed.join(", ")}`);
|
|
4000
|
+
log("Run `haus apply --write` to install refreshed skill content.");
|
|
4001
|
+
}
|
|
4002
|
+
if (sync.newItems.length === 0 && sync.refreshed.length === 0 && sync.unchanged > 0) {
|
|
3715
4003
|
log(`Catalog up to date (${sync.unchanged} item(s) unchanged).`);
|
|
3716
4004
|
}
|
|
3717
4005
|
if (sync.failed.length > 0) {
|
|
3718
4006
|
warn(`Failed to fetch ${sync.failed.length} item(s): ${sync.failed.join(", ")}`);
|
|
3719
4007
|
}
|
|
4008
|
+
await refreshGlobalInstall();
|
|
4009
|
+
await refreshProjectFiles(root);
|
|
3720
4010
|
log("Update applied with backup in .haus-workflow/backups/. Run haus doctor.");
|
|
3721
4011
|
}
|
|
4012
|
+
async function refreshProjectFiles(root) {
|
|
4013
|
+
log("Refreshing project .claude/ files...");
|
|
4014
|
+
try {
|
|
4015
|
+
const files = await refreshProjectApply(root);
|
|
4016
|
+
if (files.length === 0) {
|
|
4017
|
+
log("No prior haus project setup detected \u2014 skipped project re-apply.");
|
|
4018
|
+
return;
|
|
4019
|
+
}
|
|
4020
|
+
log(`Project refreshed: ${files.length} managed path(s) updated.`);
|
|
4021
|
+
} catch (err) {
|
|
4022
|
+
warn(`Could not refresh project files: ${err instanceof Error ? err.message : String(err)}`);
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
async function refreshGlobalInstall() {
|
|
4026
|
+
log("Refreshing ~/.claude/ global files...");
|
|
4027
|
+
try {
|
|
4028
|
+
const result = await applyInstall({});
|
|
4029
|
+
const total = result.created.length + result.updated.length;
|
|
4030
|
+
if (total > 0) {
|
|
4031
|
+
log(`~/.claude refreshed: ${result.created.length} added, ${result.updated.length} updated.`);
|
|
4032
|
+
} else {
|
|
4033
|
+
log("~/.claude already up to date.");
|
|
4034
|
+
}
|
|
4035
|
+
if (result.skipped.length > 0) {
|
|
4036
|
+
log(
|
|
4037
|
+
`Preserved ${result.skipped.length} locally-edited file(s) (run \`haus install --force\` to overwrite).`
|
|
4038
|
+
);
|
|
4039
|
+
}
|
|
4040
|
+
} catch (err) {
|
|
4041
|
+
warn(`Could not refresh ~/.claude/: ${err instanceof Error ? err.message : String(err)}`);
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
async function detectGlobalInstallDrift() {
|
|
4045
|
+
try {
|
|
4046
|
+
const result = await applyInstall({ check: true });
|
|
4047
|
+
return result.drift;
|
|
4048
|
+
} catch {
|
|
4049
|
+
return null;
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
3722
4052
|
|
|
3723
4053
|
// src/commands/validate-catalog.ts
|
|
3724
|
-
import
|
|
4054
|
+
import fs18 from "fs";
|
|
3725
4055
|
import path26 from "path";
|
|
4056
|
+
|
|
4057
|
+
// src/catalog/forbidden-content.ts
|
|
4058
|
+
var PROSE_FORBIDDEN_TAGS = FORBIDDEN_TAGS.filter((t) => t.toLowerCase() !== "go");
|
|
4059
|
+
function escapeRegExp(value) {
|
|
4060
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4061
|
+
}
|
|
4062
|
+
function extractUseWhenSection(text) {
|
|
4063
|
+
const marker = "## Use when";
|
|
4064
|
+
const idx = text.toLowerCase().indexOf(marker.toLowerCase());
|
|
4065
|
+
if (idx < 0) return "";
|
|
4066
|
+
const tail = text.slice(idx + marker.length);
|
|
4067
|
+
const next = tail.search(/\n##\s+/);
|
|
4068
|
+
return next < 0 ? tail : tail.slice(0, next);
|
|
4069
|
+
}
|
|
4070
|
+
function auditForbiddenTagsInText(text, label) {
|
|
4071
|
+
const body = extractUseWhenSection(text);
|
|
4072
|
+
if (!body.trim()) return [];
|
|
4073
|
+
const failures = [];
|
|
4074
|
+
for (const word of PROSE_FORBIDDEN_TAGS) {
|
|
4075
|
+
const re = new RegExp(`\\b${escapeRegExp(word)}\\b`, "i");
|
|
4076
|
+
if (re.test(body)) failures.push(`${label}: forbidden stack/tag "${word}" in content`);
|
|
4077
|
+
}
|
|
4078
|
+
return failures;
|
|
4079
|
+
}
|
|
4080
|
+
|
|
4081
|
+
// src/commands/validate-catalog.ts
|
|
3726
4082
|
function auditForbiddenStacks(items) {
|
|
3727
4083
|
const failures = [];
|
|
3728
4084
|
for (const item of items) {
|
|
@@ -3793,20 +4149,23 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
3793
4149
|
const absPath = path26.join(manifestDir, item.path);
|
|
3794
4150
|
if (item.type === "skill") {
|
|
3795
4151
|
const skillMd = path26.join(absPath, "SKILL.md");
|
|
3796
|
-
if (!
|
|
4152
|
+
if (!fs18.existsSync(skillMd)) {
|
|
3797
4153
|
failures.push(`${item.id}: missing ${path26.relative(manifestDir, skillMd)}`);
|
|
3798
4154
|
continue;
|
|
3799
4155
|
}
|
|
3800
|
-
const text =
|
|
4156
|
+
const text = fs18.readFileSync(skillMd, "utf8");
|
|
3801
4157
|
for (const section of REQUIRED_SKILL_SECTIONS) {
|
|
3802
4158
|
if (!text.includes(section)) failures.push(`${item.id}: SKILL.md missing ${section}`);
|
|
3803
4159
|
}
|
|
4160
|
+
failures.push(
|
|
4161
|
+
...auditForbiddenTagsInText(text, `${item.id}: ${path26.relative(manifestDir, skillMd)}`)
|
|
4162
|
+
);
|
|
3804
4163
|
} else if (item.type === "agent") {
|
|
3805
|
-
if (!
|
|
4164
|
+
if (!fs18.existsSync(absPath)) {
|
|
3806
4165
|
failures.push(`${item.id}: missing agent file ${item.path}`);
|
|
3807
4166
|
continue;
|
|
3808
4167
|
}
|
|
3809
|
-
const text =
|
|
4168
|
+
const text = fs18.readFileSync(absPath, "utf8");
|
|
3810
4169
|
if (!text.startsWith("---")) failures.push(`${item.id}: agent file missing YAML frontmatter`);
|
|
3811
4170
|
for (const section of REQUIRED_AGENT_SECTIONS) {
|
|
3812
4171
|
if (!text.includes(section)) failures.push(`${item.id}: agent file missing ${section}`);
|
|
@@ -3816,22 +4175,47 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
3816
4175
|
if (lower.includes(phrase))
|
|
3817
4176
|
failures.push(`${item.id}: agent file contains disallowed phrase "${phrase}"`);
|
|
3818
4177
|
}
|
|
4178
|
+
failures.push(
|
|
4179
|
+
...auditForbiddenTagsInText(text, `${item.id}: ${path26.relative(manifestDir, absPath)}`)
|
|
4180
|
+
);
|
|
3819
4181
|
} else if (item.type === "template") {
|
|
3820
|
-
if (!
|
|
4182
|
+
if (!fs18.existsSync(absPath)) {
|
|
3821
4183
|
failures.push(`${item.id}: missing template file ${item.path}`);
|
|
4184
|
+
continue;
|
|
3822
4185
|
}
|
|
4186
|
+
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
3823
4187
|
}
|
|
3824
4188
|
}
|
|
3825
4189
|
return failures;
|
|
3826
4190
|
}
|
|
4191
|
+
function auditTemplateContent(manifestDir, absPath, itemId) {
|
|
4192
|
+
const rel = path26.relative(manifestDir, absPath);
|
|
4193
|
+
const text = fs18.readFileSync(absPath, "utf8");
|
|
4194
|
+
const failures = [];
|
|
4195
|
+
const lines = text.split(/\r?\n/);
|
|
4196
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4197
|
+
const line2 = lines[i] ?? "";
|
|
4198
|
+
if (PLACEHOLDER_PATTERN.test(line2)) {
|
|
4199
|
+
failures.push(`${rel}:${i + 1}: TODO or placeholder in shipped content`);
|
|
4200
|
+
}
|
|
4201
|
+
if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2))) {
|
|
4202
|
+
failures.push(`${rel}:${i + 1}: risky install pattern`);
|
|
4203
|
+
}
|
|
4204
|
+
if (ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
|
|
4205
|
+
failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
|
|
4206
|
+
}
|
|
4207
|
+
}
|
|
4208
|
+
failures.push(...auditForbiddenTagsInText(text, `${itemId}: ${rel}`));
|
|
4209
|
+
return failures;
|
|
4210
|
+
}
|
|
3827
4211
|
function auditMarkdownContent(manifestDir) {
|
|
3828
4212
|
const failures = [];
|
|
3829
|
-
const dirs = ["skills", "agents"];
|
|
4213
|
+
const dirs = ["skills", "agents", "templates"];
|
|
3830
4214
|
for (const dir of dirs) {
|
|
3831
4215
|
const abs = path26.join(manifestDir, dir);
|
|
3832
|
-
if (!
|
|
4216
|
+
if (!fs18.existsSync(abs)) continue;
|
|
3833
4217
|
walkMd(abs, (file) => {
|
|
3834
|
-
const text =
|
|
4218
|
+
const text = fs18.readFileSync(file, "utf8");
|
|
3835
4219
|
const rel = path26.relative(manifestDir, file);
|
|
3836
4220
|
const lines = text.split(/\r?\n/);
|
|
3837
4221
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -3846,12 +4230,15 @@ function auditMarkdownContent(manifestDir) {
|
|
|
3846
4230
|
failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
|
|
3847
4231
|
}
|
|
3848
4232
|
}
|
|
4233
|
+
if (!rel.includes("/references/")) {
|
|
4234
|
+
failures.push(...auditForbiddenTagsInText(text, rel));
|
|
4235
|
+
}
|
|
3849
4236
|
});
|
|
3850
4237
|
}
|
|
3851
4238
|
return failures;
|
|
3852
4239
|
}
|
|
3853
4240
|
function walkMd(dir, fn) {
|
|
3854
|
-
for (const entry of
|
|
4241
|
+
for (const entry of fs18.readdirSync(dir, { withFileTypes: true })) {
|
|
3855
4242
|
const full = path26.join(dir, entry.name);
|
|
3856
4243
|
if (entry.isDirectory()) walkMd(full, fn);
|
|
3857
4244
|
else if (entry.name.endsWith(".md")) fn(full);
|
|
@@ -4254,7 +4641,7 @@ import path32 from "path";
|
|
|
4254
4641
|
|
|
4255
4642
|
// src/claude/write-workspace-claude-md.ts
|
|
4256
4643
|
import path31 from "path";
|
|
4257
|
-
import
|
|
4644
|
+
import fs19 from "fs-extra";
|
|
4258
4645
|
function buildWorkspaceImportBlock(client, members) {
|
|
4259
4646
|
const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
|
|
4260
4647
|
const body = [
|
|
@@ -4273,7 +4660,7 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
|
4273
4660
|
const block = buildWorkspaceImportBlock(opts.client, opts.members);
|
|
4274
4661
|
const dryRun = opts.dryRun ?? false;
|
|
4275
4662
|
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path31.join(workspaceRoot, "CLAUDE.md");
|
|
4276
|
-
const prev = await
|
|
4663
|
+
const prev = await fs19.pathExists(filePath) ? await fs19.readFile(filePath, "utf8") : "";
|
|
4277
4664
|
const next = opts.collision ? `${block}
|
|
4278
4665
|
` : injectHausBlock(prev, block);
|
|
4279
4666
|
const printable = displayPath(workspaceRoot, filePath);
|