goalbuddy 0.2.10
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/CONTRIBUTING.md +45 -0
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/examples/extend-catalog-workflow/goal.md +53 -0
- package/examples/extend-catalog-workflow/notes/T001-extension-model-map.md +47 -0
- package/examples/extend-catalog-workflow/notes/T002-architecture-decision.md +48 -0
- package/examples/extend-catalog-workflow/notes/T003-implementation-summary.md +43 -0
- package/examples/extend-catalog-workflow/notes/T004-root-extend-folder.md +24 -0
- package/examples/extend-catalog-workflow/notes/T005-layout-cleanup.md +46 -0
- package/examples/extend-catalog-workflow/notes/T006-catalog-location.md +50 -0
- package/examples/extend-catalog-workflow/notes/T999-completion-audit.md +36 -0
- package/examples/extend-catalog-workflow/state.yaml +327 -0
- package/examples/github-pr-workflow-extension/pr-handoff.md +46 -0
- package/examples/improve-goal-maker/goal.md +51 -0
- package/examples/improve-goal-maker/notes/T001-repo-map.md +59 -0
- package/examples/improve-goal-maker/notes/T002-risk-map.md +37 -0
- package/examples/improve-goal-maker/state.yaml +224 -0
- package/goal-maker/SKILL.md +18 -0
- package/goal-maker/agents/README.md +23 -0
- package/goal-maker/agents/config-snippet.toml +5 -0
- package/goal-maker/agents/goal_judge.toml +29 -0
- package/goal-maker/agents/goal_scout.toml +26 -0
- package/goal-maker/agents/goal_worker.toml +28 -0
- package/goal-maker/agents/openai.yaml +6 -0
- package/goal-maker/scripts/check-goal-state.mjs +370 -0
- package/goal-maker/scripts/install-agents.mjs +28 -0
- package/goal-maker/templates/agents.md +48 -0
- package/goal-maker/templates/goal-prompt.txt +1 -0
- package/goal-maker/templates/goal.md +71 -0
- package/goal-maker/templates/note.md +22 -0
- package/goal-maker/templates/state.yaml +125 -0
- package/goalbuddy/SKILL.md +484 -0
- package/goalbuddy/agents/README.md +23 -0
- package/goalbuddy/agents/config-snippet.toml +5 -0
- package/goalbuddy/agents/goal_judge.toml +29 -0
- package/goalbuddy/agents/goal_scout.toml +26 -0
- package/goalbuddy/agents/goal_worker.toml +28 -0
- package/goalbuddy/agents/openai.yaml +6 -0
- package/goalbuddy/scripts/check-goal-state.mjs +370 -0
- package/goalbuddy/scripts/install-agents.mjs +28 -0
- package/goalbuddy/templates/agents.md +48 -0
- package/goalbuddy/templates/goal-prompt.txt +1 -0
- package/goalbuddy/templates/goal.md +71 -0
- package/goalbuddy/templates/note.md +22 -0
- package/goalbuddy/templates/state.yaml +125 -0
- package/internal/assets/extend-release.png +0 -0
- package/internal/assets/extend-release.svg +83 -0
- package/internal/assets/goal-maker-flow.png +0 -0
- package/internal/cli/check-publish-version.mjs +86 -0
- package/internal/cli/goal-maker.mjs +1061 -0
- package/package.json +65 -0
- package/plugins/goalbuddy/.codex-plugin/plugin.json +48 -0
- package/plugins/goalbuddy/README.md +29 -0
- package/plugins/goalbuddy/assets/goalbuddy-icon.svg +8 -0
- package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +484 -0
- package/plugins/goalbuddy/skills/goalbuddy/agents/README.md +23 -0
- package/plugins/goalbuddy/skills/goalbuddy/agents/config-snippet.toml +5 -0
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +29 -0
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_scout.toml +26 -0
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +28 -0
- package/plugins/goalbuddy/skills/goalbuddy/agents/openai.yaml +6 -0
- package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +370 -0
- package/plugins/goalbuddy/skills/goalbuddy/scripts/install-agents.mjs +28 -0
- package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +48 -0
- package/plugins/goalbuddy/skills/goalbuddy/templates/goal-prompt.txt +1 -0
- package/plugins/goalbuddy/skills/goalbuddy/templates/goal.md +71 -0
- package/plugins/goalbuddy/skills/goalbuddy/templates/note.md +22 -0
- package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +125 -0
|
@@ -0,0 +1,1061 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
cpSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { basename, dirname, join, normalize, resolve } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const packageRoot = resolve(__dirname, "../..");
|
|
20
|
+
const canonicalProductName = "GoalBuddy";
|
|
21
|
+
const canonicalCliName = "goalbuddy";
|
|
22
|
+
const canonicalSkillName = "goalbuddy";
|
|
23
|
+
const legacyCliName = "goal-maker";
|
|
24
|
+
const legacySkillName = "goal-maker";
|
|
25
|
+
const skillSource = join(packageRoot, canonicalSkillName);
|
|
26
|
+
const packageInfo = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
27
|
+
const defaultCodexHome = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
28
|
+
const defaultCatalogUrl = "https://raw.githubusercontent.com/tolibear/goalbuddy/main/extend/catalog.json";
|
|
29
|
+
const requiredAgentFiles = [
|
|
30
|
+
"goal_judge.toml",
|
|
31
|
+
"goal_scout.toml",
|
|
32
|
+
"goal_worker.toml",
|
|
33
|
+
];
|
|
34
|
+
const optionsWithValues = new Set([
|
|
35
|
+
"--catalog",
|
|
36
|
+
"--catalog-url",
|
|
37
|
+
"--codex-home",
|
|
38
|
+
"--kind",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const args = process.argv.slice(2);
|
|
42
|
+
const command = args[0] === "--help" || args[0] === "-h"
|
|
43
|
+
? "help"
|
|
44
|
+
: args[0] && !args[0].startsWith("-")
|
|
45
|
+
? args[0]
|
|
46
|
+
: "install";
|
|
47
|
+
const invokedAs = invokedCommandName();
|
|
48
|
+
|
|
49
|
+
main().catch((error) => {
|
|
50
|
+
console.error(error.message);
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
async function main() {
|
|
55
|
+
maybePrintLegacyNotice();
|
|
56
|
+
switch (command) {
|
|
57
|
+
case "install":
|
|
58
|
+
case "update":
|
|
59
|
+
await installAll();
|
|
60
|
+
break;
|
|
61
|
+
case "agents":
|
|
62
|
+
installAgents();
|
|
63
|
+
break;
|
|
64
|
+
case "doctor":
|
|
65
|
+
doctor();
|
|
66
|
+
break;
|
|
67
|
+
case "extend":
|
|
68
|
+
await extend();
|
|
69
|
+
break;
|
|
70
|
+
case "help":
|
|
71
|
+
case "--help":
|
|
72
|
+
case "-h":
|
|
73
|
+
usage();
|
|
74
|
+
break;
|
|
75
|
+
default:
|
|
76
|
+
console.error(`Unknown command: ${command}`);
|
|
77
|
+
usage();
|
|
78
|
+
process.exit(2);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function invokedCommandName() {
|
|
83
|
+
if (process.env.GOALBUDDY_INVOKED_AS) return process.env.GOALBUDDY_INVOKED_AS;
|
|
84
|
+
return basename(process.argv[1] || "");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function invokedThroughLegacyName() {
|
|
88
|
+
return invokedAs === legacyCliName;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function maybePrintLegacyNotice() {
|
|
92
|
+
if (!invokedThroughLegacyName() || hasFlag("--json")) return;
|
|
93
|
+
console.error(`${legacyCliName} has been rebranded to ${canonicalCliName}.`);
|
|
94
|
+
console.error(`Use: npx ${canonicalCliName}`);
|
|
95
|
+
console.error(`${legacyCliName} remains available temporarily for compatibility.`);
|
|
96
|
+
console.error("");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function optionValue(name) {
|
|
100
|
+
const exact = args.indexOf(name);
|
|
101
|
+
if (exact !== -1) return args[exact + 1];
|
|
102
|
+
const prefixed = args.find((arg) => arg.startsWith(`${name}=`));
|
|
103
|
+
return prefixed ? prefixed.slice(name.length + 1) : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function hasFlag(name) {
|
|
107
|
+
return args.includes(name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function positional(index) {
|
|
111
|
+
return positionalArgs()[index] || "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function positionalArgs() {
|
|
115
|
+
const values = [];
|
|
116
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
117
|
+
const arg = args[index];
|
|
118
|
+
if (optionsWithValues.has(arg)) {
|
|
119
|
+
index += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (arg.startsWith("-")) continue;
|
|
123
|
+
values.push(arg);
|
|
124
|
+
}
|
|
125
|
+
return values;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function usage() {
|
|
129
|
+
console.log(`Codex ${canonicalProductName}
|
|
130
|
+
|
|
131
|
+
Usage:
|
|
132
|
+
${canonicalCliName} install [--codex-home <path>] [--force] [--json]
|
|
133
|
+
${canonicalCliName} update [--codex-home <path>] [--json]
|
|
134
|
+
${canonicalCliName} agents [--codex-home <path>] [--force]
|
|
135
|
+
${canonicalCliName} doctor [--codex-home <path>] [--goal-ready]
|
|
136
|
+
${canonicalCliName} extend [--catalog-url <url-or-path>] [--kind <kind>] [--json]
|
|
137
|
+
${canonicalCliName} extend <id> [--catalog-url <url-or-path>] [--json]
|
|
138
|
+
${canonicalCliName} extend install <id> [--catalog-url <url-or-path>] [--dry-run] [--force] [--json]
|
|
139
|
+
${canonicalCliName} extend install --all [--catalog-url <url-or-path>] [--dry-run] [--force] [--json]
|
|
140
|
+
${canonicalCliName} extend doctor [<id>] [--codex-home <path>] [--json]
|
|
141
|
+
|
|
142
|
+
Default:
|
|
143
|
+
${canonicalCliName} Installs the skill and bundled agent definitions.
|
|
144
|
+
|
|
145
|
+
Compatibility:
|
|
146
|
+
${legacyCliName} remains a temporary alias and prints the new npx command for human-facing use.
|
|
147
|
+
|
|
148
|
+
Environment:
|
|
149
|
+
CODEX_HOME Overrides the default ~/.codex target.
|
|
150
|
+
GOALBUDDY_EXTEND_CATALOG_URL Overrides the default GitHub-hosted extension catalog.
|
|
151
|
+
GOAL_MAKER_EXTEND_CATALOG_URL Legacy fallback for the extension catalog.
|
|
152
|
+
`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function codexHome() {
|
|
156
|
+
return resolve(optionValue("--codex-home") || defaultCodexHome);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function installSkill({ force = true, quiet = false } = {}) {
|
|
160
|
+
const target = installedSkillRoot();
|
|
161
|
+
const legacyTarget = legacyInstalledSkillRoot();
|
|
162
|
+
if (!existsSync(skillSource)) {
|
|
163
|
+
console.error(`Skill payload not found: ${skillSource}`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const previousMetadata = readInstallMetadata(target) || readInstallMetadata(legacyTarget);
|
|
168
|
+
const previousFingerprint = existsSync(target) ? directoryFingerprint(target, { exclude: installFingerprintExcludes() }) : "";
|
|
169
|
+
const preservedExtensions = preserveInstalledExtensions([target, legacyTarget]);
|
|
170
|
+
const extensionTempPath = preservedExtensions.tempPath;
|
|
171
|
+
const preservedExtensionIds = preservedExtensions.ids;
|
|
172
|
+
|
|
173
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
174
|
+
if (existsSync(target)) {
|
|
175
|
+
if (!force) {
|
|
176
|
+
console.error(`Refusing to overwrite existing skill: ${target}`);
|
|
177
|
+
console.error("Use --force to overwrite.");
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
rmSync(target, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
cpSync(skillSource, target, {
|
|
184
|
+
recursive: true,
|
|
185
|
+
});
|
|
186
|
+
restoreInstalledExtensions(target, extensionTempPath);
|
|
187
|
+
writeInstallMetadata(target, previousMetadata);
|
|
188
|
+
|
|
189
|
+
mkdirSync(dirname(legacyTarget), { recursive: true });
|
|
190
|
+
rmSync(legacyTarget, { recursive: true, force: true });
|
|
191
|
+
cpSync(skillSource, legacyTarget, {
|
|
192
|
+
recursive: true,
|
|
193
|
+
});
|
|
194
|
+
restoreInstalledExtensions(legacyTarget, extensionTempPath);
|
|
195
|
+
writeInstallMetadata(legacyTarget, previousMetadata);
|
|
196
|
+
cleanupPreservedExtensions([extensionTempPath]);
|
|
197
|
+
|
|
198
|
+
const currentFingerprint = directoryFingerprint(target, { exclude: installFingerprintExcludes() });
|
|
199
|
+
const status = previousFingerprint
|
|
200
|
+
? previousFingerprint === currentFingerprint ? "unchanged" : "updated"
|
|
201
|
+
: "installed";
|
|
202
|
+
if (!quiet) console.log(`Installed Codex ${canonicalProductName} skill to ${target}`);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
status,
|
|
206
|
+
path: target,
|
|
207
|
+
compatibility_path: legacyTarget,
|
|
208
|
+
previous_version: previousMetadata?.package_version || "",
|
|
209
|
+
current_version: packageInfo.version,
|
|
210
|
+
preserved_extensions: preservedExtensionIds,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function installAgents({ quiet = false } = {}) {
|
|
215
|
+
const source = join(skillSource, "agents");
|
|
216
|
+
const target = join(codexHome(), "agents");
|
|
217
|
+
const force = hasFlag("--force") || command === "update" || command === "install";
|
|
218
|
+
mkdirSync(target, { recursive: true });
|
|
219
|
+
|
|
220
|
+
const results = [];
|
|
221
|
+
for (const file of readdirSync(source)) {
|
|
222
|
+
if (!file.startsWith("goal_") || !file.endsWith(".toml")) continue;
|
|
223
|
+
const dest = join(target, file);
|
|
224
|
+
const sourceHash = sha256(readFileSync(join(source, file)));
|
|
225
|
+
const previousHash = existsSync(dest) ? sha256(readFileSync(dest)) : "";
|
|
226
|
+
if (existsSync(dest) && !force) {
|
|
227
|
+
if (!quiet) console.log(`skip existing ${dest} (use --force to overwrite)`);
|
|
228
|
+
results.push({ file, status: "skipped", path: dest });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
cpSync(join(source, file), dest);
|
|
232
|
+
const status = previousHash ? previousHash === sourceHash ? "unchanged" : "updated" : "installed";
|
|
233
|
+
if (!quiet) console.log(`installed ${dest}`);
|
|
234
|
+
results.push({ file, status, path: dest });
|
|
235
|
+
}
|
|
236
|
+
return results;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function installAll() {
|
|
240
|
+
const quiet = true;
|
|
241
|
+
const report = {
|
|
242
|
+
command,
|
|
243
|
+
package: {
|
|
244
|
+
name: packageInfo.name,
|
|
245
|
+
current_version: packageInfo.version,
|
|
246
|
+
},
|
|
247
|
+
codex_home: codexHome(),
|
|
248
|
+
skill: installSkill({ force: true, quiet }),
|
|
249
|
+
agents: installAgents({ quiet }),
|
|
250
|
+
extensions: await extensionDiscoverySummary(),
|
|
251
|
+
warnings: [],
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
report.package.previous_version = report.skill.previous_version;
|
|
255
|
+
|
|
256
|
+
if (hasFlag("--json")) {
|
|
257
|
+
printJson(report);
|
|
258
|
+
} else {
|
|
259
|
+
printInstallReport(report);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function doctor() {
|
|
264
|
+
const skillPath = join(installedSkillRoot(), "SKILL.md");
|
|
265
|
+
const legacySkillPath = join(legacyInstalledSkillRoot(), "SKILL.md");
|
|
266
|
+
const agentsPath = join(codexHome(), "agents");
|
|
267
|
+
const installed = existsSync(skillPath);
|
|
268
|
+
const legacyInstalled = existsSync(legacySkillPath);
|
|
269
|
+
const agents = existsSync(agentsPath)
|
|
270
|
+
? readdirSync(agentsPath).filter((file) => file.startsWith("goal_") && file.endsWith(".toml"))
|
|
271
|
+
: [];
|
|
272
|
+
const missingAgents = requiredAgentFiles.filter((file) => !agents.includes(file));
|
|
273
|
+
const staleAgents = requiredAgentFiles.filter((file) => {
|
|
274
|
+
const installedAgent = join(agentsPath, file);
|
|
275
|
+
const bundledAgent = join(skillSource, "agents", file);
|
|
276
|
+
if (!existsSync(installedAgent) || !existsSync(bundledAgent)) return false;
|
|
277
|
+
return sha256(readFileSync(installedAgent)) !== sha256(readFileSync(bundledAgent));
|
|
278
|
+
});
|
|
279
|
+
const goalRuntime = codexGoalRuntimeStatus();
|
|
280
|
+
const warnings = [];
|
|
281
|
+
if (!goalRuntime.ready) {
|
|
282
|
+
warnings.push("native Codex /goal runtime is not ready; run `codex login` and `codex features enable goals` before using /goal.");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
console.log(JSON.stringify({
|
|
286
|
+
codex_home: codexHome(),
|
|
287
|
+
skill_installed: installed,
|
|
288
|
+
skill_path: skillPath,
|
|
289
|
+
compatibility_skill_installed: legacyInstalled,
|
|
290
|
+
compatibility_skill_path: legacySkillPath,
|
|
291
|
+
installed_agents: agents,
|
|
292
|
+
missing_agents: missingAgents,
|
|
293
|
+
stale_agents: staleAgents,
|
|
294
|
+
goal_runtime: goalRuntime,
|
|
295
|
+
warnings,
|
|
296
|
+
}, null, 2));
|
|
297
|
+
|
|
298
|
+
const installOk = installed && missingAgents.length === 0 && staleAgents.length === 0;
|
|
299
|
+
const goalReadyOk = !hasFlag("--goal-ready") || goalRuntime.ready;
|
|
300
|
+
process.exit(installOk && goalReadyOk ? 0 : 1);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function codexGoalRuntimeStatus() {
|
|
304
|
+
const version = runCodex(["--version"]);
|
|
305
|
+
const login = version.ok ? runCodex(["login", "status"]) : { ok: false, stdout: "", stderr: "codex CLI unavailable" };
|
|
306
|
+
const features = version.ok ? runCodex(["features", "list"]) : { ok: false, stdout: "", stderr: "codex CLI unavailable" };
|
|
307
|
+
const goalFeature = parseGoalFeature(features.stdout);
|
|
308
|
+
const loggedIn = login.ok && !/not logged in/i.test(`${login.stdout}\n${login.stderr}`);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
codex_cli_available: version.ok,
|
|
312
|
+
codex_version: firstLine(version.stdout),
|
|
313
|
+
logged_in: loggedIn,
|
|
314
|
+
login_status: firstLine(login.stdout || login.stderr),
|
|
315
|
+
goals_feature_enabled: goalFeature.enabled,
|
|
316
|
+
goals_feature_stage: goalFeature.stage,
|
|
317
|
+
ready: version.ok && loggedIn && goalFeature.enabled,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function runCodex(args) {
|
|
322
|
+
const result = spawnSync("codex", args, {
|
|
323
|
+
encoding: "utf8",
|
|
324
|
+
env: { ...process.env, CODEX_HOME: codexHome() },
|
|
325
|
+
});
|
|
326
|
+
return {
|
|
327
|
+
ok: result.status === 0,
|
|
328
|
+
status: result.status,
|
|
329
|
+
stdout: result.stdout || "",
|
|
330
|
+
stderr: result.stderr || result.error?.message || "",
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function parseGoalFeature(output) {
|
|
335
|
+
const line = output.split(/\r?\n/).find((candidate) => candidate.trim().startsWith("goals"));
|
|
336
|
+
if (!line) return { enabled: false, stage: "" };
|
|
337
|
+
const parts = line.trim().split(/\s{2,}/);
|
|
338
|
+
return {
|
|
339
|
+
enabled: parts.at(-1) === "true",
|
|
340
|
+
stage: parts.slice(1, -1).join(" "),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function firstLine(value) {
|
|
345
|
+
return (value || "").split(/\r?\n/).find((line) => line.trim())?.trim() || "";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function extend() {
|
|
349
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
350
|
+
extendUsage();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const subcommand = positional(1) || "";
|
|
355
|
+
switch (subcommand) {
|
|
356
|
+
case "":
|
|
357
|
+
await extendCatalog();
|
|
358
|
+
break;
|
|
359
|
+
case "install":
|
|
360
|
+
await extendInstall();
|
|
361
|
+
break;
|
|
362
|
+
case "doctor":
|
|
363
|
+
extendDoctor();
|
|
364
|
+
break;
|
|
365
|
+
case "help":
|
|
366
|
+
case "--help":
|
|
367
|
+
case "-h":
|
|
368
|
+
extendUsage();
|
|
369
|
+
break;
|
|
370
|
+
default:
|
|
371
|
+
await extendDetails(subcommand);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function extendUsage() {
|
|
376
|
+
console.log(`${canonicalProductName} Extend
|
|
377
|
+
|
|
378
|
+
Usage:
|
|
379
|
+
${canonicalCliName} extend [--catalog-url <url-or-path>] [--kind <kind>] [--json]
|
|
380
|
+
${canonicalCliName} extend <id> [--catalog-url <url-or-path>] [--json]
|
|
381
|
+
${canonicalCliName} extend install <id> [--catalog-url <url-or-path>] [--dry-run] [--force] [--json]
|
|
382
|
+
${canonicalCliName} extend install --all [--catalog-url <url-or-path>] [--dry-run] [--force] [--json]
|
|
383
|
+
${canonicalCliName} extend doctor [<id>] [--codex-home <path>] [--json]
|
|
384
|
+
|
|
385
|
+
States:
|
|
386
|
+
available Listed in the catalog.
|
|
387
|
+
installed Copied into the local ${canonicalProductName} skill install.
|
|
388
|
+
enabled Allowed by a goal or task. Not implemented by this command yet.
|
|
389
|
+
configured Required local env/provider settings are present.
|
|
390
|
+
|
|
391
|
+
Catalog:
|
|
392
|
+
Defaults to ${defaultCatalogUrl}
|
|
393
|
+
Override with --catalog-url, GOALBUDDY_EXTEND_CATALOG_URL, or legacy GOAL_MAKER_EXTEND_CATALOG_URL.
|
|
394
|
+
`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function extendCatalog() {
|
|
398
|
+
const catalog = await loadCatalog();
|
|
399
|
+
const kind = optionValue("--kind");
|
|
400
|
+
const extensions = catalog.extensions
|
|
401
|
+
.filter((extension) => !kind || extension.kind === kind)
|
|
402
|
+
.map(extensionWithLocalState);
|
|
403
|
+
|
|
404
|
+
if (hasFlag("--json")) {
|
|
405
|
+
printJson({ catalog_url: catalog.url, extensions });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
console.log("Available extensions");
|
|
410
|
+
if (extensions.length === 0) {
|
|
411
|
+
console.log("");
|
|
412
|
+
console.log(kind ? `No ${kind} extensions found.` : "No extensions found.");
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
console.log("");
|
|
416
|
+
for (const extension of extensions) {
|
|
417
|
+
console.log(extension.name || extension.id);
|
|
418
|
+
if (extension.summary) console.log(` ${extension.summary}`);
|
|
419
|
+
console.log(` id: ${extension.id}`);
|
|
420
|
+
console.log(` kind: ${extension.kind} | activation: ${extension.activation || "unspecified"}`);
|
|
421
|
+
console.log(` state: ${extension.state.installed ? "installed" : "available"} | configured: ${extension.state.configured ? "yes" : "no"}`);
|
|
422
|
+
console.log(` safe by default: ${extension.safe_by_default ? "yes" : "no"} | requires approval: ${extension.requires_approval ? "yes" : "no"}`);
|
|
423
|
+
if (extension.state.missing_env.length) {
|
|
424
|
+
console.log(` missing env: ${extension.state.missing_env.join(", ")}`);
|
|
425
|
+
}
|
|
426
|
+
console.log("");
|
|
427
|
+
}
|
|
428
|
+
console.log("View details:");
|
|
429
|
+
console.log(` npx ${canonicalCliName} extend ${extensions[0].id}`);
|
|
430
|
+
console.log("Install all:");
|
|
431
|
+
console.log(` npx ${canonicalCliName} extend install --all`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function extendDetails(id) {
|
|
435
|
+
const catalog = await loadCatalog();
|
|
436
|
+
const extension = catalog.extensions.find((candidate) => candidate.id === id);
|
|
437
|
+
if (!extension) {
|
|
438
|
+
printExtensionNotFound(id, catalog.extensions);
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
validateCatalogExtension(extension);
|
|
442
|
+
const detailed = extensionWithLocalState(extension);
|
|
443
|
+
|
|
444
|
+
if (hasFlag("--json")) {
|
|
445
|
+
printJson({ catalog_url: catalog.url, extension: detailed });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
console.log(extension.name || extension.summary || "");
|
|
450
|
+
console.log("");
|
|
451
|
+
if (extension.summary && extension.summary !== extension.name) {
|
|
452
|
+
console.log(extension.summary);
|
|
453
|
+
console.log("");
|
|
454
|
+
}
|
|
455
|
+
console.log(`Status: ${detailed.state.installed ? "installed" : "available"}`);
|
|
456
|
+
console.log(`Configured: ${detailed.state.configured ? "yes" : "no"}`);
|
|
457
|
+
console.log(`ID: ${extension.id}`);
|
|
458
|
+
console.log(`Kind: ${extension.kind}`);
|
|
459
|
+
if (extension.version) console.log(`Version: ${extension.version}`);
|
|
460
|
+
console.log(`Activation: ${extension.activation || "unspecified"}`);
|
|
461
|
+
console.log(`Safe by default: ${extension.safe_by_default ? "yes" : "no"}`);
|
|
462
|
+
console.log(`Requires approval: ${extension.requires_approval ? "yes" : "no"}`);
|
|
463
|
+
if (!detailed.state.configured && detailed.state.missing_env.length) {
|
|
464
|
+
console.log(`Missing env: ${detailed.state.missing_env.join(", ")}`);
|
|
465
|
+
}
|
|
466
|
+
printListSection("Use when", extension.use_when);
|
|
467
|
+
printListSection("Outputs", extension.outputs);
|
|
468
|
+
printListSection("Reads", extension.reads);
|
|
469
|
+
printListSection("Writes", extension.writes);
|
|
470
|
+
printListSection("Side effects", extension.side_effects);
|
|
471
|
+
printListSection("Auth env", extension.auth?.env);
|
|
472
|
+
printSupports(extension.supports);
|
|
473
|
+
console.log("");
|
|
474
|
+
console.log("Local use prompt:");
|
|
475
|
+
console.log(` Use the ${extension.name || extension.id} extension for docs/goals/<slug>/goal.md and write its ${firstValue(extension.outputs, "artifact")} as Markdown.`);
|
|
476
|
+
console.log("");
|
|
477
|
+
console.log("Install:");
|
|
478
|
+
console.log(` npx ${canonicalCliName} extend install ${extension.id}`);
|
|
479
|
+
console.log("");
|
|
480
|
+
console.log("Preview install:");
|
|
481
|
+
console.log(` npx ${canonicalCliName} extend install ${extension.id} --dry-run`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function printListSection(title, values) {
|
|
485
|
+
if (!Array.isArray(values) || values.length === 0) return;
|
|
486
|
+
console.log("");
|
|
487
|
+
console.log(`${title}:`);
|
|
488
|
+
for (const value of values) console.log(` - ${value}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function printSupports(supports) {
|
|
492
|
+
if (!supports || typeof supports !== "object") return;
|
|
493
|
+
const entries = Object.entries(supports);
|
|
494
|
+
if (entries.length === 0) return;
|
|
495
|
+
console.log("");
|
|
496
|
+
console.log("Supports:");
|
|
497
|
+
for (const [key, value] of entries) console.log(` - ${key}: ${value}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function firstValue(values, fallback) {
|
|
501
|
+
return Array.isArray(values) && values.length ? values[0] : fallback;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function extendInstall() {
|
|
505
|
+
const id = positional(2);
|
|
506
|
+
const catalog = await loadCatalog();
|
|
507
|
+
if (hasFlag("--all")) {
|
|
508
|
+
await extendInstallAll(catalog);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (!id) throw new Error(`Missing extension id. Usage: ${canonicalCliName} extend install <id>`);
|
|
513
|
+
const extension = catalog.extensions.find((candidate) => candidate.id === id);
|
|
514
|
+
if (!extension) {
|
|
515
|
+
printExtensionNotFound(id, catalog.extensions);
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
const result = await installCatalogExtension(catalog, extension);
|
|
519
|
+
|
|
520
|
+
if (hasFlag("--dry-run")) {
|
|
521
|
+
if (hasFlag("--json")) {
|
|
522
|
+
printJson({ dry_run: true, extension: extensionWithLocalState(extension), target: result.target, files: result.plan });
|
|
523
|
+
} else {
|
|
524
|
+
console.log(`Would install ${extension.id} to ${result.target}`);
|
|
525
|
+
for (const file of result.plan) console.log(` ${file.path}`);
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (hasFlag("--json")) {
|
|
531
|
+
printJson({ installed: true, extension: extension.id, target: result.target });
|
|
532
|
+
} else {
|
|
533
|
+
console.log(`Installed ${extension.id} to ${result.target}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function extendInstallAll(catalog) {
|
|
538
|
+
const results = [];
|
|
539
|
+
for (const extension of catalog.extensions) {
|
|
540
|
+
results.push(await installCatalogExtension(catalog, extension));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (hasFlag("--dry-run")) {
|
|
544
|
+
if (hasFlag("--json")) {
|
|
545
|
+
printJson({
|
|
546
|
+
dry_run: true,
|
|
547
|
+
extensions: results.map(({ extension, target, plan }) => ({
|
|
548
|
+
extension: extensionWithLocalState(extension),
|
|
549
|
+
target,
|
|
550
|
+
files: plan,
|
|
551
|
+
})),
|
|
552
|
+
});
|
|
553
|
+
} else {
|
|
554
|
+
console.log(`Would install ${results.length} extensions`);
|
|
555
|
+
for (const { extension, target, plan } of results) {
|
|
556
|
+
console.log(`${extension.id} -> ${target}`);
|
|
557
|
+
for (const file of plan) console.log(` ${file.path}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (hasFlag("--json")) {
|
|
564
|
+
printJson({
|
|
565
|
+
installed: true,
|
|
566
|
+
count: results.length,
|
|
567
|
+
extensions: results.map(({ extension, target }) => ({ id: extension.id, target })),
|
|
568
|
+
});
|
|
569
|
+
} else {
|
|
570
|
+
console.log(`Installed ${results.length} extensions`);
|
|
571
|
+
for (const { extension, target } of results) console.log(` ${extension.id} -> ${target}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function installCatalogExtension(catalog, extension) {
|
|
576
|
+
validateCatalogExtension(extension);
|
|
577
|
+
const target = extensionTarget(extension.id);
|
|
578
|
+
const plan = installPlan(catalog, extension, target);
|
|
579
|
+
|
|
580
|
+
if (hasFlag("--dry-run")) return { extension, target, plan };
|
|
581
|
+
|
|
582
|
+
assertSkillInstalledForExtensionInstall();
|
|
583
|
+
if (existsSync(target) && !hasFlag("--force")) {
|
|
584
|
+
throw new Error(`Extension already installed: ${target}. Use --force to overwrite.`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
588
|
+
rmSync(temp, { recursive: true, force: true });
|
|
589
|
+
mkdirSync(temp, { recursive: true });
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
for (const file of plan) {
|
|
593
|
+
const content = await readResource(file.url);
|
|
594
|
+
const actualSha = sha256(content);
|
|
595
|
+
if (actualSha !== file.sha256) {
|
|
596
|
+
throw new Error(`Checksum mismatch for ${file.path}: expected ${file.sha256}, got ${actualSha}`);
|
|
597
|
+
}
|
|
598
|
+
const destination = join(temp, file.path);
|
|
599
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
600
|
+
writeFileSync(destination, content);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
writeFileSync(join(temp, ".installed.json"), `${JSON.stringify({
|
|
604
|
+
id: extension.id,
|
|
605
|
+
version: extension.version || "",
|
|
606
|
+
kind: extension.kind,
|
|
607
|
+
catalog_url: catalog.url,
|
|
608
|
+
installed_at: new Date().toISOString(),
|
|
609
|
+
manifest: publicExtension(extension),
|
|
610
|
+
files: plan.map(({ path, sha256: digest }) => ({ path, sha256: digest })),
|
|
611
|
+
}, null, 2)}\n`);
|
|
612
|
+
|
|
613
|
+
rmSync(target, { recursive: true, force: true });
|
|
614
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
615
|
+
renameSync(temp, target);
|
|
616
|
+
} catch (error) {
|
|
617
|
+
rmSync(temp, { recursive: true, force: true });
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return { extension, target, plan };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function extendDoctor() {
|
|
625
|
+
const id = positional(2);
|
|
626
|
+
const targets = installedExtensions().filter((extension) => !id || extension.id === id);
|
|
627
|
+
if (id && targets.length === 0) throw new Error(`Extension is not installed: ${id}`);
|
|
628
|
+
|
|
629
|
+
const reports = targets.map(doctorInstalledExtension);
|
|
630
|
+
const ok = reports.every((report) => report.ok);
|
|
631
|
+
|
|
632
|
+
if (hasFlag("--json")) {
|
|
633
|
+
printJson({ ok, extensions: reports });
|
|
634
|
+
} else if (reports.length === 0) {
|
|
635
|
+
console.log("No extensions installed.");
|
|
636
|
+
} else {
|
|
637
|
+
for (const report of reports) {
|
|
638
|
+
console.log(`${report.ok ? "ok" : "not ok"}\t${report.id}`);
|
|
639
|
+
for (const issue of report.issues) console.log(` - ${issue}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
process.exit(ok ? 0 : 1);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function installedSkillRoot() {
|
|
647
|
+
return join(codexHome(), "skills", canonicalSkillName);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function legacyInstalledSkillRoot() {
|
|
651
|
+
return join(codexHome(), "skills", legacySkillName);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function extendRoot() {
|
|
655
|
+
return join(installedSkillRoot(), "extend");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function extensionTarget(id) {
|
|
659
|
+
return join(extendRoot(), id);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function catalogUrl() {
|
|
663
|
+
return optionValue("--catalog-url")
|
|
664
|
+
|| optionValue("--catalog")
|
|
665
|
+
|| process.env.GOALBUDDY_EXTEND_CATALOG_URL
|
|
666
|
+
|| process.env.GOAL_MAKER_EXTEND_CATALOG_URL
|
|
667
|
+
|| defaultCatalogUrl;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function loadCatalog() {
|
|
671
|
+
const url = catalogUrl();
|
|
672
|
+
const text = await readResource(url);
|
|
673
|
+
const catalog = JSON.parse(text);
|
|
674
|
+
if (!Array.isArray(catalog.extensions)) {
|
|
675
|
+
throw new Error("Extension catalog must contain an extensions array.");
|
|
676
|
+
}
|
|
677
|
+
return { ...catalog, url };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function printExtensionNotFound(id, extensions) {
|
|
681
|
+
console.error(`Extension not found: ${id}`);
|
|
682
|
+
if (extensions.length) {
|
|
683
|
+
console.error("");
|
|
684
|
+
console.error("Available extensions:");
|
|
685
|
+
for (const extension of extensions) {
|
|
686
|
+
console.error(` ${extension.id}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
console.error("");
|
|
690
|
+
console.error("Try:");
|
|
691
|
+
console.error(` npx ${canonicalCliName} extend`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function validateCatalogExtension(extension) {
|
|
695
|
+
if (!extension.id || !/^[a-z0-9][a-z0-9-]*$/.test(extension.id)) {
|
|
696
|
+
throw new Error(`Invalid extension id: ${extension.id || "<missing>"}`);
|
|
697
|
+
}
|
|
698
|
+
if (!extension.kind) throw new Error(`Extension ${extension.id} missing kind.`);
|
|
699
|
+
if (!Array.isArray(extension.files) || extension.files.length === 0) {
|
|
700
|
+
throw new Error(`Extension ${extension.id} must list files.`);
|
|
701
|
+
}
|
|
702
|
+
for (const file of extension.files) {
|
|
703
|
+
validateCatalogFile(extension, file);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function validateCatalogFile(extension, file) {
|
|
708
|
+
if (!file.path) throw new Error(`Extension ${extension.id} has a file without path.`);
|
|
709
|
+
if (!file.url) throw new Error(`Extension ${extension.id} file ${file.path} missing url.`);
|
|
710
|
+
if (!/^[a-f0-9]{64}$/i.test(file.sha256 || "")) {
|
|
711
|
+
throw new Error(`Extension ${extension.id} file ${file.path} must include sha256.`);
|
|
712
|
+
}
|
|
713
|
+
safeRelativePath(file.path);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function installPlan(catalog, extension, target) {
|
|
717
|
+
return extension.files.map((file) => ({
|
|
718
|
+
path: safeRelativePath(file.path),
|
|
719
|
+
url: resolveResourceUrl(catalog.url, file.url),
|
|
720
|
+
sha256: file.sha256.toLowerCase(),
|
|
721
|
+
target: join(target, safeRelativePath(file.path)),
|
|
722
|
+
}));
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function safeRelativePath(path) {
|
|
726
|
+
const normalized = normalize(path).replaceAll("\\", "/");
|
|
727
|
+
if (!normalized || normalized.startsWith("../") || normalized === ".." || normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
|
|
728
|
+
throw new Error(`Unsafe extension file path: ${path}`);
|
|
729
|
+
}
|
|
730
|
+
return normalized;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function resolveResourceUrl(base, value) {
|
|
734
|
+
if (/^https?:\/\//.test(value) || value.startsWith("file://") || value.startsWith("/")) return value;
|
|
735
|
+
if (/^https?:\/\//.test(base) || base.startsWith("file://")) {
|
|
736
|
+
return new URL(value, base).href;
|
|
737
|
+
}
|
|
738
|
+
return resolve(dirname(resolve(base)), value);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function readResource(location) {
|
|
742
|
+
if (/^https?:\/\//.test(location)) {
|
|
743
|
+
if (!globalThis.fetch) throw new Error("This Node runtime does not provide fetch.");
|
|
744
|
+
const response = await globalThis.fetch(location, {
|
|
745
|
+
headers: {
|
|
746
|
+
"accept-encoding": "identity",
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
if (!response.ok) throw new Error(`Failed to fetch ${location}: HTTP ${response.status}`);
|
|
750
|
+
return Buffer.from(await response.arrayBuffer());
|
|
751
|
+
}
|
|
752
|
+
const path = location.startsWith("file://") ? fileURLToPath(location) : resolve(location);
|
|
753
|
+
return readFileSync(path);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function sha256(content) {
|
|
757
|
+
return createHash("sha256").update(content).digest("hex");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function directoryFingerprint(root, { exclude = new Set() } = {}) {
|
|
761
|
+
if (!existsSync(root)) return "";
|
|
762
|
+
const hash = createHash("sha256");
|
|
763
|
+
for (const file of listFiles(root, { exclude })) {
|
|
764
|
+
hash.update(file);
|
|
765
|
+
hash.update("\0");
|
|
766
|
+
hash.update(readFileSync(join(root, file)));
|
|
767
|
+
hash.update("\0");
|
|
768
|
+
}
|
|
769
|
+
return hash.digest("hex");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function listFiles(root, { exclude = new Set(), prefix = "" } = {}) {
|
|
773
|
+
const entries = readdirSync(join(root, prefix), { withFileTypes: true })
|
|
774
|
+
.filter((entry) => !exclude.has(prefix ? `${prefix}/${entry.name}` : entry.name))
|
|
775
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
776
|
+
const files = [];
|
|
777
|
+
for (const entry of entries) {
|
|
778
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
779
|
+
if (entry.isDirectory()) {
|
|
780
|
+
files.push(...listFiles(root, { exclude, prefix: relative }));
|
|
781
|
+
} else if (entry.isFile()) {
|
|
782
|
+
files.push(relative);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return files;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function preserveInstalledExtensions(targets) {
|
|
789
|
+
const ids = [];
|
|
790
|
+
const tempPath = join(codexHome(), `.goalbuddy-preserved-extend-${process.pid}-${Date.now()}`);
|
|
791
|
+
let hasExtensions = false;
|
|
792
|
+
for (const target of targets) {
|
|
793
|
+
const source = join(target, "extend");
|
|
794
|
+
if (!existsSync(source)) continue;
|
|
795
|
+
mkdirSync(tempPath, { recursive: true });
|
|
796
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
797
|
+
const from = join(source, entry.name);
|
|
798
|
+
const to = join(tempPath, entry.name);
|
|
799
|
+
cpSync(from, to, { recursive: true, force: true });
|
|
800
|
+
if (entry.isDirectory()) ids.push(entry.name);
|
|
801
|
+
hasExtensions = true;
|
|
802
|
+
}
|
|
803
|
+
rmSync(source, { recursive: true, force: true });
|
|
804
|
+
}
|
|
805
|
+
return { tempPath: hasExtensions ? tempPath : "", ids: uniqueSorted(ids) };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function restoreInstalledExtensions(target, tempPath) {
|
|
809
|
+
if (!tempPath) return;
|
|
810
|
+
rmSync(join(target, "extend"), { recursive: true, force: true });
|
|
811
|
+
mkdirSync(target, { recursive: true });
|
|
812
|
+
cpSync(tempPath, join(target, "extend"), { recursive: true });
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function cleanupPreservedExtensions(paths) {
|
|
816
|
+
for (const path of uniqueSorted(paths.filter(Boolean))) {
|
|
817
|
+
rmSync(path, { recursive: true, force: true });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function uniqueSorted(values) {
|
|
822
|
+
return [...new Set(values)].sort();
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function installFingerprintExcludes() {
|
|
826
|
+
return new Set(["extend", ".goalbuddy-install.json", ".goal-maker-install.json"]);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function installMetadataPath(target) {
|
|
830
|
+
return join(target, ".goalbuddy-install.json");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function legacyInstallMetadataPath(target) {
|
|
834
|
+
return join(target, ".goal-maker-install.json");
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function readInstallMetadata(target) {
|
|
838
|
+
for (const path of [installMetadataPath(target), legacyInstallMetadataPath(target)]) {
|
|
839
|
+
if (!existsSync(path)) continue;
|
|
840
|
+
try {
|
|
841
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
842
|
+
} catch {
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function writeInstallMetadata(target, previousMetadata) {
|
|
850
|
+
writeFileSync(installMetadataPath(target), `${JSON.stringify({
|
|
851
|
+
package_name: packageInfo.name,
|
|
852
|
+
package_version: packageInfo.version,
|
|
853
|
+
previous_package_version: previousMetadata?.package_version || "",
|
|
854
|
+
installed_at: new Date().toISOString(),
|
|
855
|
+
}, null, 2)}\n`);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function extensionDiscoverySummary() {
|
|
859
|
+
try {
|
|
860
|
+
const catalog = await loadCatalog();
|
|
861
|
+
const extensions = catalog.extensions.map(extensionWithLocalState);
|
|
862
|
+
return {
|
|
863
|
+
catalog_url: catalog.url,
|
|
864
|
+
catalog_version: catalog.version || null,
|
|
865
|
+
available_count: extensions.length,
|
|
866
|
+
installed_count: extensions.filter((extension) => extension.state.installed).length,
|
|
867
|
+
available: extensions.map((extension) => ({
|
|
868
|
+
id: extension.id,
|
|
869
|
+
name: extension.name,
|
|
870
|
+
kind: extension.kind,
|
|
871
|
+
version: extension.version,
|
|
872
|
+
summary: extension.summary,
|
|
873
|
+
activation: extension.activation,
|
|
874
|
+
safe_by_default: extension.safe_by_default,
|
|
875
|
+
installed: extension.state.installed,
|
|
876
|
+
configured: extension.state.configured,
|
|
877
|
+
use_when: extension.use_when,
|
|
878
|
+
next_command: `${canonicalCliName} extend ${extension.id}`,
|
|
879
|
+
})),
|
|
880
|
+
recommended: extensions
|
|
881
|
+
.filter((extension) => extension.safe_by_default && !extension.state.installed)
|
|
882
|
+
.map((extension) => ({
|
|
883
|
+
id: extension.id,
|
|
884
|
+
name: extension.name,
|
|
885
|
+
kind: extension.kind,
|
|
886
|
+
activation: extension.activation,
|
|
887
|
+
summary: extension.summary,
|
|
888
|
+
use_when: extension.use_when.slice(0, 1),
|
|
889
|
+
next_command: `${canonicalCliName} extend ${extension.id}`,
|
|
890
|
+
})),
|
|
891
|
+
};
|
|
892
|
+
} catch (error) {
|
|
893
|
+
return {
|
|
894
|
+
catalog_url: catalogUrl(),
|
|
895
|
+
catalog_version: null,
|
|
896
|
+
available_count: 0,
|
|
897
|
+
installed_count: 0,
|
|
898
|
+
available: [],
|
|
899
|
+
recommended: [],
|
|
900
|
+
error: error.message,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function printInstallReport(report) {
|
|
906
|
+
const verb = report.command === "update" ? "Updated" : "Installed";
|
|
907
|
+
const previous = report.package.previous_version && report.package.previous_version !== report.package.current_version
|
|
908
|
+
? ` ${report.package.previous_version} -> ${report.package.current_version}`
|
|
909
|
+
: ` ${report.package.current_version}`;
|
|
910
|
+
console.log("");
|
|
911
|
+
console.log(`${verb} ${canonicalProductName}${previous}`);
|
|
912
|
+
console.log("");
|
|
913
|
+
console.log(`Skill: ${report.skill.status} at ${report.skill.path}`);
|
|
914
|
+
console.log(`Compatibility skill: ${report.skill.compatibility_path}`);
|
|
915
|
+
const agentSummary = summarizeStatuses(report.agents);
|
|
916
|
+
console.log(`Agents: ${agentSummary}`);
|
|
917
|
+
if (report.skill.preserved_extensions.length) {
|
|
918
|
+
console.log(`Preserved extensions: ${report.skill.preserved_extensions.join(", ")}`);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (report.extensions.error) {
|
|
922
|
+
console.log("");
|
|
923
|
+
console.log(`Extensions: unavailable (${report.extensions.error})`);
|
|
924
|
+
} else {
|
|
925
|
+
console.log("");
|
|
926
|
+
console.log(`Extensions: ${report.extensions.available_count} available from ${report.extensions.catalog_url}`);
|
|
927
|
+
if (report.extensions.recommended.length) {
|
|
928
|
+
console.log("");
|
|
929
|
+
console.log("Recommended:");
|
|
930
|
+
for (const extension of report.extensions.recommended.slice(0, 3)) {
|
|
931
|
+
console.log(` ${extension.name || extension.id}`);
|
|
932
|
+
if (extension.summary) console.log(` ${extension.summary}`);
|
|
933
|
+
console.log(` Details: npx ${extension.next_command}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
console.log("");
|
|
939
|
+
console.log("Next:");
|
|
940
|
+
console.log(" $goalbuddy");
|
|
941
|
+
console.log(` ${canonicalCliName} extend`);
|
|
942
|
+
console.log(` ${legacyCliName} remains a temporary compatibility alias.`);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function summarizeStatuses(items) {
|
|
946
|
+
const counts = items.reduce((memo, item) => {
|
|
947
|
+
memo[item.status] = (memo[item.status] || 0) + 1;
|
|
948
|
+
return memo;
|
|
949
|
+
}, {});
|
|
950
|
+
return Object.entries(counts)
|
|
951
|
+
.map(([status, count]) => `${count} ${status}`)
|
|
952
|
+
.join(", ");
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function assertSkillInstalledForExtensionInstall() {
|
|
956
|
+
if (!existsSync(join(installedSkillRoot(), "SKILL.md"))) {
|
|
957
|
+
throw new Error(`${canonicalProductName} skill is not installed at ${installedSkillRoot()}. Run: npx ${canonicalCliName}`);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function installedExtensions() {
|
|
962
|
+
const root = extendRoot();
|
|
963
|
+
if (!existsSync(root)) return [];
|
|
964
|
+
return readdirSync(root, { withFileTypes: true })
|
|
965
|
+
.filter((entry) => entry.isDirectory())
|
|
966
|
+
.map((entry) => readInstalledExtension(join(root, entry.name)))
|
|
967
|
+
.filter(Boolean);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function readInstalledExtension(path) {
|
|
971
|
+
const installedPath = join(path, ".installed.json");
|
|
972
|
+
if (!existsSync(installedPath)) {
|
|
973
|
+
return { id: basename(path), path, issues: ["missing .installed.json"] };
|
|
974
|
+
}
|
|
975
|
+
const data = JSON.parse(readFileSync(installedPath, "utf8"));
|
|
976
|
+
return {
|
|
977
|
+
...data,
|
|
978
|
+
path,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function doctorInstalledExtension(extension) {
|
|
983
|
+
const issues = [];
|
|
984
|
+
for (const file of extension.files || []) {
|
|
985
|
+
const path = join(extension.path, safeRelativePath(file.path));
|
|
986
|
+
if (!existsSync(path)) {
|
|
987
|
+
issues.push(`missing file: ${file.path}`);
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
const actualSha = sha256(readFileSync(path));
|
|
991
|
+
if (actualSha !== file.sha256) {
|
|
992
|
+
issues.push(`checksum mismatch: ${file.path}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
for (const envName of extension.manifest?.auth?.env || []) {
|
|
996
|
+
if (!process.env[envName]) issues.push(`missing env: ${envName}`);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
return {
|
|
1000
|
+
id: extension.id,
|
|
1001
|
+
version: extension.version || "",
|
|
1002
|
+
kind: extension.kind || "",
|
|
1003
|
+
path: extension.path,
|
|
1004
|
+
ok: issues.length === 0,
|
|
1005
|
+
issues,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function publicExtension(extension) {
|
|
1010
|
+
return {
|
|
1011
|
+
id: extension.id,
|
|
1012
|
+
name: extension.name || "",
|
|
1013
|
+
kind: extension.kind || "",
|
|
1014
|
+
version: extension.version || "",
|
|
1015
|
+
summary: extension.summary || "",
|
|
1016
|
+
description: extension.description || "",
|
|
1017
|
+
source: extension.source || "",
|
|
1018
|
+
docs: extension.docs || "",
|
|
1019
|
+
use_when: extension.use_when || [],
|
|
1020
|
+
activation: extension.activation || "",
|
|
1021
|
+
outputs: extension.outputs || [],
|
|
1022
|
+
requires_approval: extension.requires_approval || false,
|
|
1023
|
+
safe_by_default: extension.safe_by_default || false,
|
|
1024
|
+
applies_to: extension.applies_to || {},
|
|
1025
|
+
reads: extension.reads || [],
|
|
1026
|
+
writes: extension.writes || [],
|
|
1027
|
+
side_effects: extension.side_effects || [],
|
|
1028
|
+
auth: extension.auth || { env: [] },
|
|
1029
|
+
supports: extension.supports || {},
|
|
1030
|
+
source_of_truth: extension.source_of_truth || "local",
|
|
1031
|
+
files: (extension.files || []).map((file) => ({
|
|
1032
|
+
path: file.path,
|
|
1033
|
+
sha256: file.sha256,
|
|
1034
|
+
})),
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function extensionWithLocalState(extension) {
|
|
1039
|
+
return {
|
|
1040
|
+
...publicExtension(extension),
|
|
1041
|
+
state: {
|
|
1042
|
+
available: true,
|
|
1043
|
+
installed: existsSync(extensionTarget(extension.id)),
|
|
1044
|
+
enabled: false,
|
|
1045
|
+
configured: configuredFor(extension),
|
|
1046
|
+
missing_env: missingEnv(extension),
|
|
1047
|
+
},
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function configuredFor(extension) {
|
|
1052
|
+
return missingEnv(extension).length === 0;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function missingEnv(extension) {
|
|
1056
|
+
return (extension.auth?.env || []).filter((envName) => !process.env[envName]);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function printJson(value) {
|
|
1060
|
+
console.log(JSON.stringify(value, null, 2));
|
|
1061
|
+
}
|