oh-my-muse 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +26 -0
- package/README.md +100 -0
- package/bin/lib.mjs +606 -0
- package/bin/omm.mjs +311 -0
- package/hooks/notify.mjs +133 -0
- package/models.json +38 -0
- package/omm.jsonc +25 -0
- package/pack/agents/architect.ts +26 -0
- package/pack/agents/critic.ts +26 -0
- package/pack/agents/data-scientist.ts +26 -0
- package/pack/agents/debugger.ts +26 -0
- package/pack/agents/designer.ts +25 -0
- package/pack/agents/docs-writer.ts +26 -0
- package/pack/agents/file-picker.ts +24 -0
- package/pack/agents/implementer.ts +26 -0
- package/pack/agents/muse-advisor.ts +63 -0
- package/pack/agents/muse-autopilot.ts +65 -0
- package/pack/agents/muse-deep-interview.ts +71 -0
- package/pack/agents/muse-pipeline.ts +49 -0
- package/pack/agents/muse-ralph.ts +48 -0
- package/pack/agents/muse-ralplan.ts +70 -0
- package/pack/agents/muse-team.ts +95 -0
- package/pack/agents/muse-ultraqa.ts +55 -0
- package/pack/agents/muse-ultrawork.ts +46 -0
- package/pack/agents/planner.ts +26 -0
- package/pack/agents/refactorer.ts +26 -0
- package/pack/agents/researcher.ts +25 -0
- package/pack/agents/reviewer.ts +26 -0
- package/pack/agents/security-reviewer.ts +27 -0
- package/pack/agents/tester.ts +27 -0
- package/pack/skills/design/SKILL.md +24 -0
- package/pack/skills/docs/SKILL.md +23 -0
- package/pack/skills/harness/SKILL.md +24 -0
- package/pack/skills/security/SKILL.md +24 -0
- package/pack/skills/tdd/SKILL.md +24 -0
- package/pack/skills/verify/SKILL.md +28 -0
- package/package.json +48 -0
- package/src/harness/DESIGN.md +29 -0
- package/src/harness/HARNESS.md +42 -0
- package/types/agent-definition.ts +18 -0
- package/types/orchestrator.ts +37 -0
package/bin/lib.mjs
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
export const TIERS = ["budget", "balanced", "premium"];
|
|
7
|
+
|
|
8
|
+
export const TIER_MODELS = {
|
|
9
|
+
budget: "muse-spark-fast",
|
|
10
|
+
balanced: "muse-spark",
|
|
11
|
+
premium: "muse-spark-reasoning",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const INSTALL_DIRNAME = path.join(".claude", "oh-my-muse");
|
|
15
|
+
export const CONFIG_FILENAME = "omm.jsonc";
|
|
16
|
+
export const MANAGED_FILENAME = "omm-managed.json";
|
|
17
|
+
export const CONFIG_MODE = 0o600;
|
|
18
|
+
|
|
19
|
+
export function repoRootFromHere(importMetaUrl) {
|
|
20
|
+
return path.resolve(path.dirname(new URL(importMetaUrl).pathname), "..");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveTargetDir(cwd, target) {
|
|
24
|
+
if (!target) return path.resolve(cwd);
|
|
25
|
+
return path.isAbsolute(target) ? path.normalize(target) : path.resolve(cwd, target);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function installDirFor(targetDir) {
|
|
29
|
+
return path.join(path.resolve(targetDir), INSTALL_DIRNAME);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function stripJsonc(text) {
|
|
33
|
+
let out = "";
|
|
34
|
+
let inStr = false;
|
|
35
|
+
let inLine = false;
|
|
36
|
+
let inBlock = false;
|
|
37
|
+
for (let i = 0; i < text.length; i++) {
|
|
38
|
+
const c = text[i];
|
|
39
|
+
const n = text[i + 1];
|
|
40
|
+
if (inLine) {
|
|
41
|
+
if (c === "\n") { inLine = false; out += c; }
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (inBlock) {
|
|
45
|
+
if (c === "*" && n === "/") { inBlock = false; i++; }
|
|
46
|
+
else if (c === "\n") out += c;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (inStr) {
|
|
50
|
+
out += c;
|
|
51
|
+
if (c === "\\") { out += n ?? ""; i++; }
|
|
52
|
+
else if (c === '"') inStr = false;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (c === '"') { inStr = true; out += c; continue; }
|
|
56
|
+
if (c === "/" && n === "/") { inLine = true; i++; continue; }
|
|
57
|
+
if (c === "/" && n === "*") { inBlock = true; i++; continue; }
|
|
58
|
+
out += c;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseJsonc(text, sourceLabel = "config") {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(stripJsonc(text));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new Error(`Invalid JSONC in ${sourceLabel}: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function loadJsoncFile(filePath) {
|
|
72
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
73
|
+
return parseJsonc(text, filePath);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function writeFileMode(filePath, content, mode = CONFIG_MODE) {
|
|
77
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
78
|
+
fs.writeFileSync(filePath, content, { mode });
|
|
79
|
+
fs.chmodSync(filePath, mode);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function expandEnvInString(str, env = process.env) {
|
|
83
|
+
return str.replace(/\$(\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}|([A-Za-z_][A-Za-z0-9_]*))/g, (m, _g, braced, def, plain) => {
|
|
84
|
+
const name = braced ?? plain;
|
|
85
|
+
const val = env[name];
|
|
86
|
+
if (val !== undefined) return val;
|
|
87
|
+
if (def !== undefined) return def;
|
|
88
|
+
return m;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function expandEnv(value, env = process.env) {
|
|
93
|
+
if (typeof value === "string") return expandEnvInString(value, env);
|
|
94
|
+
if (Array.isArray(value)) return value.map((v) => expandEnv(v, env));
|
|
95
|
+
if (value && typeof value === "object") {
|
|
96
|
+
const out = {};
|
|
97
|
+
for (const [k, v] of Object.entries(value)) out[k] = expandEnv(v, env);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SENSITIVE_KEY = /(token|secret|password|passwd|api[_-]?key|auth|bearer|webhook|private[_-]?key)/i;
|
|
104
|
+
|
|
105
|
+
export function redactValue(key, value) {
|
|
106
|
+
if (typeof value === "string" && SENSITIVE_KEY.test(key ?? "")) {
|
|
107
|
+
if (value.length === 0) return value;
|
|
108
|
+
return "[redacted]";
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function redactConfig(value, key = "") {
|
|
114
|
+
if (Array.isArray(value)) return value.map((v) => redactConfig(v, key));
|
|
115
|
+
if (value && typeof value === "object") {
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const [k, v] of Object.entries(value)) {
|
|
118
|
+
if (v !== null && typeof v === "object") out[k] = redactConfig(v, k);
|
|
119
|
+
else out[k] = redactValue(k, v);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
return redactValue(key, value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function configHasSecrets(value, key = "") {
|
|
127
|
+
if (Array.isArray(value)) return value.some((v) => configHasSecrets(v, key));
|
|
128
|
+
if (value && typeof value === "object") {
|
|
129
|
+
return Object.entries(value).some(([k, v]) =>
|
|
130
|
+
(typeof v === "string" && v.length > 0 && SENSITIVE_KEY.test(k)) || configHasSecrets(v, k),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function redactText(text) {
|
|
137
|
+
return String(text)
|
|
138
|
+
.replace(/(bot\d+:[A-Za-z0-9_-]{10,})/g, "[redacted]")
|
|
139
|
+
.replace(/(xox[bpas]-[A-Za-z0-9-]{8,})/g, "[redacted]")
|
|
140
|
+
.replace(/https?:\/\/(?:www\.|m\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+/g, "[redacted-discord-webhook]")
|
|
141
|
+
.replace(/https?:\/\/hooks\.slack(?:-gov)?\.com\/services\/[A-Za-z0-9]+\/[A-Za-z0-9]+\/[A-Za-z0-9]+/g, "[redacted-slack-webhook]")
|
|
142
|
+
.replace(/(-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----)/g, "[redacted]");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function assertHttps(urlStr, label = "webhook") {
|
|
146
|
+
let u;
|
|
147
|
+
try {
|
|
148
|
+
u = new URL(urlStr);
|
|
149
|
+
} catch {
|
|
150
|
+
throw new Error(`${label} URL is not a valid URL: ${redactText(urlStr)}`);
|
|
151
|
+
}
|
|
152
|
+
if (u.protocol !== "https:") throw new Error(`${label} URL must use https:, got ${u.protocol}`);
|
|
153
|
+
return u.toString();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isInside(root, candidate) {
|
|
157
|
+
const rel = path.relative(path.resolve(root), path.resolve(candidate));
|
|
158
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Resolve a notification file and keep it inside the project root unless opted out. */
|
|
162
|
+
export function resolveNotificationFile(root, configured, allowExternal = false) {
|
|
163
|
+
const target = path.resolve(path.resolve(root), String(configured));
|
|
164
|
+
if (!allowExternal && !isInside(root, target)) {
|
|
165
|
+
throw new Error("refusing notification file outside project root (set allowExternalNotificationFile=true to opt in)");
|
|
166
|
+
}
|
|
167
|
+
// If the target (or an existing parent) goes through a symlink, verify its
|
|
168
|
+
// real path as well so a path that is lexically inside the project cannot
|
|
169
|
+
// escape through a symlinked directory.
|
|
170
|
+
if (!allowExternal) {
|
|
171
|
+
const rootReal = fs.realpathSync(path.resolve(root));
|
|
172
|
+
let probe = fs.existsSync(target) ? target : path.dirname(target);
|
|
173
|
+
while (!fs.existsSync(probe)) {
|
|
174
|
+
const parent = path.dirname(probe);
|
|
175
|
+
if (parent === probe) break;
|
|
176
|
+
probe = parent;
|
|
177
|
+
}
|
|
178
|
+
if (fs.existsSync(probe)) {
|
|
179
|
+
const real = fs.realpathSync(probe);
|
|
180
|
+
if (!isInside(rootReal, real)) {
|
|
181
|
+
throw new Error("refusing notification file through a symlink outside project root");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return target;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Validate literal/resolved Slack and Discord webhook destinations. */
|
|
189
|
+
export function validateWebhookUrl(channel, raw) {
|
|
190
|
+
let url;
|
|
191
|
+
try {
|
|
192
|
+
url = new URL(String(raw));
|
|
193
|
+
} catch {
|
|
194
|
+
throw new Error(`invalid ${channel} webhook URL`);
|
|
195
|
+
}
|
|
196
|
+
if (url.protocol !== "https:") throw new Error(`${channel} webhook must use https`);
|
|
197
|
+
if (channel === "slack") {
|
|
198
|
+
const hosts = new Set(["hooks.slack.com", "hooks.slack-gov.com"]);
|
|
199
|
+
if (!hosts.has(url.hostname) || !url.pathname.startsWith("/services/")) {
|
|
200
|
+
throw new Error("slack webhook must use hooks.slack.com (or hooks.slack-gov.com) /services/...");
|
|
201
|
+
}
|
|
202
|
+
} else if (channel === "discord") {
|
|
203
|
+
const hosts = new Set(["discord.com", "www.discord.com", "discordapp.com", "www.discordapp.com"]);
|
|
204
|
+
if (!hosts.has(url.hostname) || !url.pathname.startsWith("/api/webhooks/")) {
|
|
205
|
+
throw new Error("discord webhook must use a Discord /api/webhooks/... URL");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return url.toString();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function validateNotifyHook(name, hook) {
|
|
212
|
+
if (!hook || typeof hook !== "object") throw new Error(`notify.${name} must be an object`);
|
|
213
|
+
if (hook.channel !== undefined && !["telegram", "discord", "slack", "file"].includes(hook.channel)) {
|
|
214
|
+
throw new Error(`notify.${name}.channel must be telegram|discord|slack|file`);
|
|
215
|
+
}
|
|
216
|
+
if (hook.url !== undefined) assertHttps(expandEnvInString(String(hook.url)), `notify.${name}`);
|
|
217
|
+
if (hook.webhookUrl !== undefined) {
|
|
218
|
+
const expanded = expandEnvInString(String(hook.webhookUrl));
|
|
219
|
+
if (hook.channel === "discord" || hook.channel === "slack") validateWebhookUrl(hook.channel, expanded);
|
|
220
|
+
else assertHttps(expanded, `notify.${name}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function isTier(value) {
|
|
225
|
+
return TIERS.includes(value);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function modelForTier(tier) {
|
|
229
|
+
if (!isTier(tier)) throw new Error(`Unknown tier "${tier}"; expected one of ${TIERS.join("|")}`);
|
|
230
|
+
return TIER_MODELS[tier];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function loadModelsFile(repoRoot) {
|
|
234
|
+
const file = path.join(repoRoot, "models.json");
|
|
235
|
+
if (!fs.existsSync(file)) return { tiers: {}, presets: {}, customPresets: {} };
|
|
236
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function getPreset(models, name) {
|
|
240
|
+
return resolvePreset(models, name);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolve a preset by name. Builtin presets resolve as-is. Custom presets are
|
|
245
|
+
* partial overlays: without `extends` they inherit `{ tier: "balanced" }` (or
|
|
246
|
+
* the same-name builtin when overriding one); with `extends` they inherit the
|
|
247
|
+
* named builtin or custom preset. Inheritance cycles are rejected.
|
|
248
|
+
*/
|
|
249
|
+
export function resolvePreset(models, name, seen = new Set()) {
|
|
250
|
+
const builtins = models.presets ?? {};
|
|
251
|
+
const customs = models.customPresets ?? {};
|
|
252
|
+
const custom = customs[name];
|
|
253
|
+
if (!custom) {
|
|
254
|
+
const preset = builtins[name];
|
|
255
|
+
if (!preset) {
|
|
256
|
+
const all = [...Object.keys(builtins), ...Object.keys(customs)];
|
|
257
|
+
throw new Error(`Unknown preset "${name}". Available: ${all.join(", ") || "(none)"}`);
|
|
258
|
+
}
|
|
259
|
+
return { ...preset };
|
|
260
|
+
}
|
|
261
|
+
if (seen.has(name)) throw new Error(`custom preset inheritance cycle at "${name}"`);
|
|
262
|
+
seen.add(name);
|
|
263
|
+
let base;
|
|
264
|
+
if (custom.extends === undefined) {
|
|
265
|
+
base = builtins[name] ? { ...builtins[name] } : { tier: "balanced" };
|
|
266
|
+
} else if (customs[custom.extends]) {
|
|
267
|
+
base = resolvePreset(models, custom.extends, seen);
|
|
268
|
+
} else if (builtins[custom.extends]) {
|
|
269
|
+
base = { ...builtins[custom.extends] };
|
|
270
|
+
} else {
|
|
271
|
+
throw new Error(`custom preset extends unknown preset "${custom.extends}"`);
|
|
272
|
+
}
|
|
273
|
+
const { extends: _extends, ...own } = custom;
|
|
274
|
+
return { ...base, ...own };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Per-agent model pins from config `modelOverrides`. Every key must name a
|
|
279
|
+
* configured agent and every value must be a non-empty model id string.
|
|
280
|
+
* Overrides win over any preset when applied (see applyModelOverrides).
|
|
281
|
+
*/
|
|
282
|
+
export function validateModelOverrides(config) {
|
|
283
|
+
const overrides = config.modelOverrides;
|
|
284
|
+
if (overrides === undefined) return {};
|
|
285
|
+
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) {
|
|
286
|
+
throw new Error("modelOverrides must be an object");
|
|
287
|
+
}
|
|
288
|
+
const names = new Set((config.agents ?? []).map((a) => a?.name));
|
|
289
|
+
const unknown = Object.keys(overrides).filter((k) => !names.has(k));
|
|
290
|
+
if (unknown.length > 0) throw new Error(`modelOverrides reference unknown agent(s): ${unknown.join(", ")}`);
|
|
291
|
+
const invalid = Object.entries(overrides).filter(([, v]) => typeof v !== "string" || v.length === 0);
|
|
292
|
+
if (invalid.length > 0) {
|
|
293
|
+
throw new Error(`modelOverrides contain missing/empty/non-string model id(s): ${invalid.map(([k]) => k).join(", ")}`);
|
|
294
|
+
}
|
|
295
|
+
return overrides;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function applyModelOverrides(agent, overrides = {}) {
|
|
299
|
+
const pinned = overrides?.[agent.name];
|
|
300
|
+
if (pinned === undefined) return agent;
|
|
301
|
+
return { ...agent, model: pinned };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function applyPreset(agent, preset) {
|
|
305
|
+
const merged = { ...agent };
|
|
306
|
+
if (preset.tier !== undefined) {
|
|
307
|
+
if (!isTier(preset.tier)) throw new Error(`Preset tier must be one of ${TIERS.join("|")}`);
|
|
308
|
+
merged.tier = preset.tier;
|
|
309
|
+
}
|
|
310
|
+
if (preset.model !== undefined) merged.model = preset.model;
|
|
311
|
+
if (preset.temperature !== undefined) merged.temperature = preset.temperature;
|
|
312
|
+
if (preset.maxTokens !== undefined) merged.maxTokens = preset.maxTokens;
|
|
313
|
+
if (!merged.model && merged.tier) merged.model = modelForTier(merged.tier);
|
|
314
|
+
return merged;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function validateAgent(agent, index) {
|
|
318
|
+
const where = `agents[${index}]`;
|
|
319
|
+
if (!agent || typeof agent !== "object") throw new Error(`${where} must be an object`);
|
|
320
|
+
if (typeof agent.name !== "string" || agent.name.length === 0) throw new Error(`${where}.name must be a non-empty string`);
|
|
321
|
+
if (typeof agent.systemPrompt !== "string" || agent.systemPrompt.length === 0) throw new Error(`${where}.systemPrompt must be a non-empty string`);
|
|
322
|
+
if (agent.tier !== undefined && !isTier(agent.tier)) throw new Error(`${where}.tier must be one of ${TIERS.join("|")}`);
|
|
323
|
+
if (agent.tools !== undefined && !Array.isArray(agent.tools)) throw new Error(`${where}.tools must be an array`);
|
|
324
|
+
if (agent.maxTokens !== undefined && (!Number.isInteger(agent.maxTokens) || agent.maxTokens <= 0)) {
|
|
325
|
+
throw new Error(`${where}.maxTokens must be a positive integer`);
|
|
326
|
+
}
|
|
327
|
+
if (agent.temperature !== undefined && (typeof agent.temperature !== "number" || agent.temperature < 0 || agent.temperature > 2)) {
|
|
328
|
+
throw new Error(`${where}.temperature must be a number in [0, 2]`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function validateConfig(config) {
|
|
333
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("Config root must be an object");
|
|
334
|
+
const tier = config.defaultTier ?? "balanced";
|
|
335
|
+
if (!isTier(tier)) throw new Error(`defaultTier must be one of ${TIERS.join("|")}`);
|
|
336
|
+
const agents = config.agents ?? [];
|
|
337
|
+
if (!Array.isArray(agents)) throw new Error("agents must be an array");
|
|
338
|
+
agents.forEach(validateAgent);
|
|
339
|
+
const notify = config.notify;
|
|
340
|
+
if (notify !== undefined) {
|
|
341
|
+
if (!notify || typeof notify !== "object") throw new Error("notify must be an object");
|
|
342
|
+
for (const [name, hook] of Object.entries(notify)) validateNotifyHook(name, hook);
|
|
343
|
+
}
|
|
344
|
+
if (config.allowExternalNotificationFile !== undefined && typeof config.allowExternalNotificationFile !== "boolean") {
|
|
345
|
+
throw new Error("allowExternalNotificationFile must be a boolean");
|
|
346
|
+
}
|
|
347
|
+
validateModelOverrides(config);
|
|
348
|
+
return { defaultTier: tier, agents };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function loadProjectConfig(targetDir) {
|
|
352
|
+
const file = path.join(installDirFor(targetDir), CONFIG_FILENAME);
|
|
353
|
+
if (!fs.existsSync(file)) {
|
|
354
|
+
const legacy = path.join(path.resolve(targetDir), CONFIG_FILENAME);
|
|
355
|
+
if (fs.existsSync(legacy)) return { config: expandEnv(validateConfigRaw(loadJsoncFile(legacy))), file: legacy };
|
|
356
|
+
return { config: null, file };
|
|
357
|
+
}
|
|
358
|
+
return { config: expandEnv(validateConfigRaw(loadJsoncFile(file))), file };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function validateConfigRaw(raw) {
|
|
362
|
+
if (!raw || typeof raw !== "object") throw new Error("Config root must be an object");
|
|
363
|
+
return raw;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function listManagedFiles(manifest) {
|
|
367
|
+
return [...(manifest.files ?? [])].sort();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function readManifest(targetDir) {
|
|
371
|
+
const file = path.join(installDirFor(targetDir), MANAGED_FILENAME);
|
|
372
|
+
if (!fs.existsSync(file)) return null;
|
|
373
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function collectPackFiles(repoRoot) {
|
|
377
|
+
const roots = [path.join(repoRoot, "pack"), path.join(repoRoot, "types")].filter((d) => fs.existsSync(d));
|
|
378
|
+
const files = [];
|
|
379
|
+
for (const root of roots) {
|
|
380
|
+
const stack = [root];
|
|
381
|
+
while (stack.length > 0) {
|
|
382
|
+
const cur = stack.pop();
|
|
383
|
+
for (const entry of fs.readdirSync(cur, { withFileTypes: true })) {
|
|
384
|
+
const full = path.join(cur, entry.name);
|
|
385
|
+
if (entry.isDirectory()) stack.push(full);
|
|
386
|
+
else if (entry.isFile()) files.push(path.relative(repoRoot, full));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return files.sort();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function stageAndCommit(stagingDir, destDir, operations) {
|
|
394
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
395
|
+
fs.mkdirSync(stagingDir, { recursive: true });
|
|
396
|
+
try {
|
|
397
|
+
for (const op of operations) op(stagingDir);
|
|
398
|
+
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
|
399
|
+
const backupDir = `${destDir}.backup.${process.pid}`;
|
|
400
|
+
const hadDest = fs.existsSync(destDir);
|
|
401
|
+
if (hadDest) fs.renameSync(destDir, backupDir);
|
|
402
|
+
try {
|
|
403
|
+
fs.renameSync(stagingDir, destDir);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
if (hadDest && !fs.existsSync(destDir) && fs.existsSync(backupDir)) fs.renameSync(backupDir, destDir);
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
if (hadDest) fs.rmSync(backupDir, { recursive: true, force: true });
|
|
409
|
+
} catch (err) {
|
|
410
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
411
|
+
throw err;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function copyRelative(repoRoot, stagingDir, rel) {
|
|
416
|
+
const src = path.join(repoRoot, rel);
|
|
417
|
+
const dest = path.join(stagingDir, rel);
|
|
418
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
419
|
+
fs.copyFileSync(src, dest);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function installPack({ repoRoot, targetDir, overwrite = true }) {
|
|
423
|
+
const dest = installDirFor(targetDir);
|
|
424
|
+
const relFiles = collectPackFiles(repoRoot);
|
|
425
|
+
if (relFiles.length === 0) throw new Error(`No pack files found under ${repoRoot}`);
|
|
426
|
+
const staging = `${dest}.staging.${process.pid}`;
|
|
427
|
+
stageAndCommit(staging, dest, [
|
|
428
|
+
(stage) => {
|
|
429
|
+
for (const rel of relFiles) {
|
|
430
|
+
const destFile = path.join(dest, rel);
|
|
431
|
+
if (!overwrite && fs.existsSync(destFile)) {
|
|
432
|
+
copyRelative(repoRoot, stage, rel);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
copyRelative(repoRoot, stage, rel);
|
|
436
|
+
}
|
|
437
|
+
if (overwrite || !fs.existsSync(path.join(dest, CONFIG_FILENAME))) {
|
|
438
|
+
const template = path.join(repoRoot, CONFIG_FILENAME);
|
|
439
|
+
const cfgText = fs.existsSync(template)
|
|
440
|
+
? fs.readFileSync(template, "utf8")
|
|
441
|
+
: `{\n "defaultTier": "balanced",\n "agents": []\n}\n`;
|
|
442
|
+
fs.writeFileSync(path.join(stage, CONFIG_FILENAME), cfgText);
|
|
443
|
+
} else {
|
|
444
|
+
fs.writeFileSync(path.join(stage, CONFIG_FILENAME), fs.readFileSync(path.join(dest, CONFIG_FILENAME), "utf8"));
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
]);
|
|
448
|
+
const manifest = {
|
|
449
|
+
version: 1,
|
|
450
|
+
repoRoot: path.resolve(repoRoot),
|
|
451
|
+
installedAt: new Date().toISOString(),
|
|
452
|
+
files: [...relFiles, CONFIG_FILENAME].sort(),
|
|
453
|
+
};
|
|
454
|
+
fs.writeFileSync(path.join(dest, MANAGED_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
455
|
+
writeFileMode(path.join(dest, CONFIG_FILENAME), fs.readFileSync(path.join(dest, CONFIG_FILENAME), "utf8"), CONFIG_MODE);
|
|
456
|
+
return { dest, files: manifest.files };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function updatePack({ repoRoot, targetDir }) {
|
|
460
|
+
const dest = installDirFor(targetDir);
|
|
461
|
+
if (!fs.existsSync(dest)) throw new Error(`Nothing installed at ${dest}; run install first`);
|
|
462
|
+
const prevManifest = readManifest(targetDir);
|
|
463
|
+
const prevFiles = new Set(prevManifest?.files ?? []);
|
|
464
|
+
const relFiles = collectPackFiles(repoRoot);
|
|
465
|
+
const staging = `${dest}.staging.${process.pid}`;
|
|
466
|
+
const nextFiles = [...relFiles, CONFIG_FILENAME].sort();
|
|
467
|
+
stageAndCommit(staging, dest, [
|
|
468
|
+
(stage) => {
|
|
469
|
+
for (const rel of relFiles) copyRelative(repoRoot, stage, rel);
|
|
470
|
+
const prevConfig = path.join(dest, CONFIG_FILENAME);
|
|
471
|
+
const template = path.join(repoRoot, CONFIG_FILENAME);
|
|
472
|
+
if (fs.existsSync(prevConfig)) {
|
|
473
|
+
fs.writeFileSync(path.join(stage, CONFIG_FILENAME), fs.readFileSync(prevConfig, "utf8"));
|
|
474
|
+
} else if (fs.existsSync(template)) {
|
|
475
|
+
fs.writeFileSync(path.join(stage, CONFIG_FILENAME), fs.readFileSync(template, "utf8"));
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
]);
|
|
479
|
+
for (const old of prevFiles) {
|
|
480
|
+
if (!nextFiles.includes(old) && old !== MANAGED_FILENAME) {
|
|
481
|
+
const p = path.join(dest, old);
|
|
482
|
+
if (fs.existsSync(p)) fs.rmSync(p, { force: true });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const manifest = {
|
|
486
|
+
version: 1,
|
|
487
|
+
repoRoot: path.resolve(repoRoot),
|
|
488
|
+
installedAt: new Date().toISOString(),
|
|
489
|
+
updatedAt: new Date().toISOString(),
|
|
490
|
+
files: nextFiles,
|
|
491
|
+
};
|
|
492
|
+
fs.writeFileSync(path.join(dest, MANAGED_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
493
|
+
writeFileMode(path.join(dest, CONFIG_FILENAME), fs.readFileSync(path.join(dest, CONFIG_FILENAME), "utf8"), CONFIG_MODE);
|
|
494
|
+
return { dest, files: nextFiles };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function uninstallPack({ targetDir }) {
|
|
498
|
+
const dest = installDirFor(targetDir);
|
|
499
|
+
const manifest = readManifest(targetDir);
|
|
500
|
+
if (!manifest) {
|
|
501
|
+
if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
|
|
502
|
+
return { dest, removed: [] };
|
|
503
|
+
}
|
|
504
|
+
const removed = [];
|
|
505
|
+
for (const rel of listManagedFiles(manifest)) {
|
|
506
|
+
const p = path.join(dest, rel);
|
|
507
|
+
if (fs.existsSync(p)) {
|
|
508
|
+
fs.rmSync(p, { force: true });
|
|
509
|
+
removed.push(rel);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const managedFile = path.join(dest, MANAGED_FILENAME);
|
|
513
|
+
if (fs.existsSync(managedFile)) fs.rmSync(managedFile, { force: true });
|
|
514
|
+
pruneEmptyDirs(dest);
|
|
515
|
+
if (fs.existsSync(dest) && fs.readdirSync(dest).length === 0) fs.rmdirSync(dest);
|
|
516
|
+
const parent = path.dirname(dest);
|
|
517
|
+
if (fs.existsSync(parent) && fs.readdirSync(parent).length === 0) fs.rmdirSync(parent);
|
|
518
|
+
return { dest, removed };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function pruneEmptyDirs(root) {
|
|
522
|
+
if (!fs.existsSync(root)) return;
|
|
523
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
524
|
+
if (!entry.isDirectory()) continue;
|
|
525
|
+
const full = path.join(root, entry.name);
|
|
526
|
+
pruneEmptyDirs(full);
|
|
527
|
+
if (fs.readdirSync(full).length === 0) fs.rmdirSync(full);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function doctor({ repoRoot, targetDir }) {
|
|
532
|
+
const checks = [];
|
|
533
|
+
const push = (ok, message) => checks.push({ ok: Boolean(ok), message });
|
|
534
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
535
|
+
push(major >= 20, `node >= 20 (found ${process.version})`);
|
|
536
|
+
push(fs.existsSync(path.join(repoRoot, "models.json")), `models.json present in ${repoRoot}`);
|
|
537
|
+
push(fs.existsSync(path.join(repoRoot, "pack")), `pack/ present in ${repoRoot}`);
|
|
538
|
+
const { config, file } = loadProjectConfig(targetDir);
|
|
539
|
+
push(config !== null, config ? `config loads (${file})` : `config found (${file})`);
|
|
540
|
+
if (config) {
|
|
541
|
+
try {
|
|
542
|
+
validateConfig(config);
|
|
543
|
+
push(true, "config validation passes");
|
|
544
|
+
} catch (err) {
|
|
545
|
+
push(false, `config invalid: ${err.message}`);
|
|
546
|
+
}
|
|
547
|
+
const mode = fs.existsSync(file) ? fs.statSync(file).mode & 0o777 : null;
|
|
548
|
+
if (configHasSecrets(config)) {
|
|
549
|
+
push(mode === CONFIG_MODE, mode === null ? "config permissions unknown" : `config holds secrets and mode is 0600 (found ${mode.toString(8)})`);
|
|
550
|
+
} else {
|
|
551
|
+
push(true, mode === null ? "config holds no secrets (0600 not required)" : `config holds no secrets (mode ${mode.toString(8)}, 0600 not required)`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const manifest = readManifest(targetDir);
|
|
555
|
+
if (manifest) {
|
|
556
|
+
push(true, "managed manifest present");
|
|
557
|
+
} else if (!fs.existsSync(installDirFor(targetDir))) {
|
|
558
|
+
push(true, "pack not installed here (source checkout; run omm install --dir <project>)");
|
|
559
|
+
} else {
|
|
560
|
+
push(false, "install dir exists without a manifest (partial install? reinstall)");
|
|
561
|
+
}
|
|
562
|
+
if (config?.notify) {
|
|
563
|
+
for (const [name, hook] of Object.entries(config.notify)) {
|
|
564
|
+
try {
|
|
565
|
+
validateNotifyHook(name, hook);
|
|
566
|
+
push(true, `notify.${name} webhook uses https or is unset`);
|
|
567
|
+
} catch (err) {
|
|
568
|
+
push(false, `notify.${name}: ${err.message}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (config?.agents) {
|
|
573
|
+
const bad = config.agents.filter((a) => a.tier && !isTier(a.tier));
|
|
574
|
+
push(bad.length === 0, bad.length === 0 ? "agent tiers are budget|balanced|premium" : `invalid tiers: ${bad.map((a) => a.name).join(", ")}`);
|
|
575
|
+
}
|
|
576
|
+
if (config && file) push(...gitignoreWarning(targetDir, file, configHasSecrets(config)));
|
|
577
|
+
return checks;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function gitTopLevel(dir) {
|
|
581
|
+
try {
|
|
582
|
+
return execFileSync("git", ["-C", path.resolve(dir), "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
583
|
+
} catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function gitignoreWarning(targetDir, configFile, hasSecrets) {
|
|
589
|
+
const top = gitTopLevel(targetDir);
|
|
590
|
+
if (!top) return [true, "not a git checkout (gitignore check skipped)"];
|
|
591
|
+
let ignored = false;
|
|
592
|
+
try {
|
|
593
|
+
execFileSync("git", ["-C", top, "check-ignore", "-q", path.resolve(configFile)], { stdio: "ignore" });
|
|
594
|
+
ignored = true;
|
|
595
|
+
} catch {
|
|
596
|
+
ignored = false;
|
|
597
|
+
}
|
|
598
|
+
if (hasSecrets && !ignored) {
|
|
599
|
+
return [false, `warning: ${configFile} holds secrets but is not gitignored`];
|
|
600
|
+
}
|
|
601
|
+
return [true, ignored ? "secret-bearing config is gitignored" : "config gitignore state ok"];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
export function homeConfigPath() {
|
|
605
|
+
return path.join(os.homedir(), INSTALL_DIRNAME, CONFIG_FILENAME);
|
|
606
|
+
}
|