@jabworks/condux 0.6.0 → 0.8.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/README.md +4 -3
- package/package.json +1 -1
- package/skills/condux-doctor/SKILL.md +83 -0
- package/skills/condux-doctor/conflicts.json +52 -0
- package/skills/condux-doctor/conflicts.mjs +192 -0
- package/skills/condux-doctor/doctor.mjs +540 -0
- package/skills/condux-doctor/evals/trigger_eval.json +17 -0
- package/skills/draft-plan/evals/trigger_eval.json +1 -1
- package/skills/plan-review/references/install-codex-hook.sh +89 -29
- package/skills/subagent-execution/references/install-codex-agents.mjs +49 -1
package/README.md
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
OpenCode plugin for the [condux](https://github.com/jabworks/agentic-toolkit)
|
|
4
4
|
workflow toolkit. One plugin line installs everything condux needs:
|
|
5
5
|
|
|
6
|
-
- **Skills** — bundles the
|
|
6
|
+
- **Skills** — bundles the 14 condux workflow skills (`workflow`, `discovery`,
|
|
7
7
|
`draft-plan`, `test-first-development`, `subagent-execution`,
|
|
8
|
-
`subagent-deployment`, `finalize`, `
|
|
9
|
-
`root-cause-analysis`, `plan-review`, `technical-spec
|
|
8
|
+
`subagent-deployment`, `finalize`, `live-verification`, `code-review`,
|
|
9
|
+
`preflight`, `root-cause-analysis`, `plan-review`, `technical-spec`,
|
|
10
|
+
`condux-doctor`) and registers them on
|
|
10
11
|
`config.skills.paths` via the `config` hook — no separate `npx skills add`
|
|
11
12
|
step. A skill you install yourself under the same name still wins; OpenCode
|
|
12
13
|
dedupes by name.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jabworks/condux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "OpenCode plugin for the condux workflow toolkit — bundles the condux skills and injects the specialist agents (coder, explorer, planner, researcher) plus an opt-in plan-review listener.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: condux-doctor
|
|
3
|
+
description: "Health check for the condux plugin on the host it is installed on. Runs the SessionStart routing hook on both hosts and checks each wire format, confirms Codex's hooks flag is on and plan-review's Stop hook resolves, checks the OpenCode registration and its bundled agents and skills, verifies the four specialist agents shipped, compares the installed version against the marketplace clone, and flags conflicting skill libraries installed. Offline; read-only unless --fix. Checking whether condux itself works on this machine: is condux working, the routing rule stopped appearing, /workflow is not being reached, did the SessionStart hook fire, check my condux install, the agents are missing, does condux clash with superpowers. Run it after installing or updating. Not for routing a dev task (that is workflow); not for diagnosing this repo's own build or dist drift (that is toolkit-debugging-playbook)."
|
|
4
|
+
argument-hint: "[--host claude|codex|opencode] [--fix]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /condux-doctor
|
|
8
|
+
|
|
9
|
+
Answers one question: **is condux actually working on this host?**
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
node <skill-base>/doctor.mjs # every host, every probe
|
|
13
|
+
node <skill-base>/doctor.mjs --host codex # one host
|
|
14
|
+
node <skill-base>/doctor.mjs --quiet # only what is not fine
|
|
15
|
+
node <skill-base>/doctor.mjs --fix # run the installer, then re-probe
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Exit 0 when nothing is broken, 1 when something is, 2 when the doctor cannot
|
|
19
|
+
find condux's own hooks.
|
|
20
|
+
|
|
21
|
+
`--fix` performs no registration itself — it runs condux's plugin-level
|
|
22
|
+
`install.mjs` and probes again, so idempotency, backups and atomic writes stay
|
|
23
|
+
in one place. If the installer fails, the report says how it failed rather than
|
|
24
|
+
re-probing in silence, and the re-probe still decides the exit code. Without an
|
|
25
|
+
installer beside the plugin (an `npx skills add` tree has none), it says so and
|
|
26
|
+
changes nothing.
|
|
27
|
+
|
|
28
|
+
## Reading the report
|
|
29
|
+
|
|
30
|
+
One row per probe: `host status detail`, fix on an indented continuation
|
|
31
|
+
line. `done` works · `broken` needs the fix · `absent` is not there and not a
|
|
32
|
+
failure · `skipped` deliberately needs nothing · `warn` works but something
|
|
33
|
+
else on the machine competes with it. Only `broken` affects the exit code.
|
|
34
|
+
|
|
35
|
+
## What it probes
|
|
36
|
+
|
|
37
|
+
- **The SessionStart routing hook, by running it.** Claude Code must get a
|
|
38
|
+
`hookSpecificOutput` envelope carrying non-empty context; Codex must get
|
|
39
|
+
the raw routing payload. Each host's manifest must use its own root
|
|
40
|
+
variable — `${CLAUDE_PLUGIN_ROOT}` or `${PLUGIN_ROOT}`, never the other.
|
|
41
|
+
- **Codex's `features.hooks` flag**, before anything else on that host. A
|
|
42
|
+
plugin manifest can declare hooks; nothing in a plugin can enable them. With
|
|
43
|
+
the flag off, the manifest parses, every path resolves, and no hook fires —
|
|
44
|
+
which this doctor scored `done` until it learned to read the flag. It proves
|
|
45
|
+
the flag is set, not that Codex has restarted since.
|
|
46
|
+
- **plan-review's Codex Stop hook** — resolved, never executed. It blocks a
|
|
47
|
+
turn for up to four days waiting on a human by design.
|
|
48
|
+
- **OpenCode** — `@jabworks/condux` in the plugin array, and when a local
|
|
49
|
+
copy exists, that it ships its bundled `agents/` and `skills/`.
|
|
50
|
+
- **The four specialist agents** — `coder`, `explorer`, `planner`,
|
|
51
|
+
`researcher`. They live in a plugin-level directory reached by its own sync
|
|
52
|
+
step, which is exactly the mirror that drifted once before.
|
|
53
|
+
- **Version** — installed against the local marketplace clone, with that
|
|
54
|
+
clone's own last-fetch date printed beside it. Never fetches.
|
|
55
|
+
- **Conflicting skill libraries** — anything in
|
|
56
|
+
`conflicts.json` that is already on this machine, matched by name against
|
|
57
|
+
the host plugin registrations and the loose skill directories. The one entry
|
|
58
|
+
today is `superpowers`, which condux is derived from and which registers a
|
|
59
|
+
competing `SessionStart` router. Reported `warn`, never `broken`: condux is
|
|
60
|
+
fine, and the removal command is printed rather than run.
|
|
61
|
+
|
|
62
|
+
## Why running the hook matters
|
|
63
|
+
|
|
64
|
+
`session-start.mjs` fails open on purpose: if its payload is missing it exits
|
|
65
|
+
0 and prints nothing, because a hook that breaks the session is worse than a
|
|
66
|
+
hook that does nothing. Every static check passes in that state. Only
|
|
67
|
+
executing it reveals that condux's routing rule is silently absent — which is
|
|
68
|
+
the difference between `/workflow` being the entry point and being a lucky
|
|
69
|
+
guess from the catalog.
|
|
70
|
+
|
|
71
|
+
## What it cannot prove
|
|
72
|
+
|
|
73
|
+
That the host *invoked* the hook. Nothing readable from a child process says
|
|
74
|
+
whether Claude Code or Codex loaded a registration this session. If every
|
|
75
|
+
probe is green and the routing rule still never appears, the fault is
|
|
76
|
+
host-side and this report is the evidence.
|
|
77
|
+
|
|
78
|
+
## Boundaries
|
|
79
|
+
|
|
80
|
+
Routing an actual dev task is `workflow`. Problems with this *repo's* build —
|
|
81
|
+
dist drift, a manifest that will not parse, a skill that will not trigger —
|
|
82
|
+
belong to `toolkit-debugging-playbook`: that one needs the source tree, this
|
|
83
|
+
one needs only the install.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"note": "Known-conflicting skill libraries, as data. Both plugins/condux/install.mjs and doctor.mjs read this one file — adding an entry is a data edit, not a code edit in two places. Detection is name matching against what is already on disk: no network, no semantic comparison (that is docket #10, an open research question). Every entry must be verified against an installed copy before it ships; a wrong skill name in a shipped warning is worse than no warning.",
|
|
3
|
+
"conflicts": [
|
|
4
|
+
{
|
|
5
|
+
"id": "superpowers",
|
|
6
|
+
"title": "superpowers",
|
|
7
|
+
"author": "obra (Jesse Vincent)",
|
|
8
|
+
"homepage": "https://github.com/obra/superpowers",
|
|
9
|
+
"verified": "6.2.0, read from an installed copy on 2026-08-11",
|
|
10
|
+
"relationship": "condux reworks its skill-orchestration ideas around proportional effort — tiered routing, lazy loading, soft gates — rather than always-on maximalism. The debt is real and credited; the consequence is that the two are near-complete functional twins.",
|
|
11
|
+
"impact": "Both ship a SessionStart hook on the same matcher (startup|clear|compact), and each injects a router telling the agent every dev task starts with it. Whichever is read last wins, per session, unpredictably. 11 of superpowers' 14 skills also overlap 8 of condux's 14, so the agent picks between two competing contracts at every step of a task.",
|
|
12
|
+
"recommendation": "Run one or the other. They are not layered libraries — they are two answers to the same question.",
|
|
13
|
+
"detect": {
|
|
14
|
+
"plugin": "superpowers",
|
|
15
|
+
"minSkills": 2,
|
|
16
|
+
"skills": [
|
|
17
|
+
"brainstorming",
|
|
18
|
+
"dispatching-parallel-agents",
|
|
19
|
+
"executing-plans",
|
|
20
|
+
"finishing-a-development-branch",
|
|
21
|
+
"receiving-code-review",
|
|
22
|
+
"requesting-code-review",
|
|
23
|
+
"subagent-driven-development",
|
|
24
|
+
"systematic-debugging",
|
|
25
|
+
"test-driven-development",
|
|
26
|
+
"using-git-worktrees",
|
|
27
|
+
"using-superpowers",
|
|
28
|
+
"verification-before-completion",
|
|
29
|
+
"writing-plans",
|
|
30
|
+
"writing-skills"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"overlaps": [
|
|
34
|
+
{ "theirs": "using-superpowers", "ours": "workflow", "both": "the entry-point router — each injected at SessionStart" },
|
|
35
|
+
{ "theirs": "brainstorming", "ours": "discovery", "both": "rough idea to signed-off design" },
|
|
36
|
+
{ "theirs": "writing-plans", "ours": "draft-plan", "both": "design to plan document" },
|
|
37
|
+
{ "theirs": "executing-plans", "ours": "subagent-execution", "both": "working through a plan task by task" },
|
|
38
|
+
{ "theirs": "subagent-driven-development", "ours": "subagent-execution", "both": "delegating implementation to agents" },
|
|
39
|
+
{ "theirs": "dispatching-parallel-agents", "ours": "subagent-deployment", "both": "fanning out independent tasks" },
|
|
40
|
+
{ "theirs": "test-driven-development", "ours": "test-first-development", "both": "tests before implementation" },
|
|
41
|
+
{ "theirs": "systematic-debugging", "ours": "root-cause-analysis", "both": "root-cause-first investigation" },
|
|
42
|
+
{ "theirs": "verification-before-completion", "ours": "preflight", "both": "the am-I-actually-done checklist" },
|
|
43
|
+
{ "theirs": "requesting-code-review", "ours": "code-review", "both": "review of the implemented diff" },
|
|
44
|
+
{ "theirs": "receiving-code-review", "ours": "code-review", "both": "acting on review findings" }
|
|
45
|
+
],
|
|
46
|
+
"remedy": {
|
|
47
|
+
"plugin": "/plugin uninstall {key}",
|
|
48
|
+
"skills": "remove the overlapping directories under {dir}, or uninstall the library that installed them"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Detects skill libraries that compete with condux for the same routing
|
|
2
|
+
// decisions, so the installer and the doctor can warn in the same words.
|
|
3
|
+
//
|
|
4
|
+
// The registry is data (conflicts.json) and this is the logic that reads it —
|
|
5
|
+
// the same split as routing.md against session-start.mjs, and for the same
|
|
6
|
+
// reason: adding a conflict is a data edit, and neither of the two callers
|
|
7
|
+
// grows its own copy of the table.
|
|
8
|
+
//
|
|
9
|
+
// Name matching only. No network, no version resolution, no comparing skill
|
|
10
|
+
// descriptions by meaning — that last one is a real question (docket #10) and
|
|
11
|
+
// an unanswered one, and a doctor that advertises itself as offline and
|
|
12
|
+
// read-only is not where it gets prototyped.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
// A single skill directory name proves nothing — "brainstorming" is a word
|
|
18
|
+
// before it is a library. The floor is per-entry so a future entry with one
|
|
19
|
+
// very distinctive skill name can lower it deliberately.
|
|
20
|
+
const DEFAULT_MIN_SKILLS = 2;
|
|
21
|
+
|
|
22
|
+
export function loadRegistry(file) {
|
|
23
|
+
if (!file || !fs.existsSync(file)) return { error: 'no conflicts.json beside this script' };
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
27
|
+
if (!Array.isArray(parsed?.conflicts)) return { error: 'conflicts.json has no conflicts array' };
|
|
28
|
+
|
|
29
|
+
return { entries: parsed.conflicts };
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return { error: String(err?.message ?? err) };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Claude Code records installed plugins as JSON keys, Codex as TOML table
|
|
36
|
+
// headers, and both spell the key the same way: <name>@<marketplace>. Reading
|
|
37
|
+
// the registration rather than the plugins/cache/ directory is deliberate — a
|
|
38
|
+
// cache entry outlives the install that created it.
|
|
39
|
+
function installedPluginKeys(hosts) {
|
|
40
|
+
const keys = [];
|
|
41
|
+
|
|
42
|
+
if (hosts.claude) {
|
|
43
|
+
const file = path.join(hosts.claude, 'plugins', 'installed_plugins.json');
|
|
44
|
+
if (fs.existsSync(file)) {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
47
|
+
for (const key of Object.keys(parsed?.plugins ?? {})) keys.push({ key, host: 'claude' });
|
|
48
|
+
} catch {
|
|
49
|
+
// An unreadable host registry is that host's problem, not a conflict.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (hosts.codex) {
|
|
55
|
+
const file = path.join(hosts.codex, 'config.toml');
|
|
56
|
+
if (fs.existsSync(file)) {
|
|
57
|
+
try {
|
|
58
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
59
|
+
const match = /^\s*\[plugins\."([^"]+)"\]/.exec(line);
|
|
60
|
+
if (match) keys.push({ key: match[1], host: 'codex' });
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Same.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return keys;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Every place a host will load a loose skill from. `npx skills add` and the
|
|
72
|
+
// shared ~/.agents tree both land here, which is how a machine carries the
|
|
73
|
+
// conflict without the plugin ever being installed.
|
|
74
|
+
function skillDirs(hosts, home) {
|
|
75
|
+
return [
|
|
76
|
+
hosts.claude && path.join(hosts.claude, 'skills'),
|
|
77
|
+
home && path.join(home, '.agents', 'skills'),
|
|
78
|
+
hosts.codex && path.join(hosts.codex, 'skills'),
|
|
79
|
+
hosts.opencode && path.join(hosts.opencode, 'skills'),
|
|
80
|
+
].filter((dir) => dir && fs.existsSync(dir));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ~/.claude/skills/<name> is routinely a symlink into ~/.agents/skills/<name>,
|
|
84
|
+
// so one skill is visible from two directories. Resolving is what counts it
|
|
85
|
+
// once, and the resolved path is also the one the report quotes — see where
|
|
86
|
+
// `home` is set below for why that, and not the directory it was listed in.
|
|
87
|
+
function realPath(file) {
|
|
88
|
+
try {
|
|
89
|
+
return fs.realpathSync(file);
|
|
90
|
+
} catch {
|
|
91
|
+
return file;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function detectConflicts(entries, hosts, home) {
|
|
96
|
+
const pluginKeys = installedPluginKeys(hosts);
|
|
97
|
+
const dirs = skillDirs(hosts, home);
|
|
98
|
+
const findings = [];
|
|
99
|
+
|
|
100
|
+
for (const entry of entries) {
|
|
101
|
+
const wanted = entry?.detect?.plugin;
|
|
102
|
+
const plugin = wanted ? pluginKeys.find(({ key }) => key.split('@')[0] === wanted) ?? null : null;
|
|
103
|
+
|
|
104
|
+
const seen = new Map();
|
|
105
|
+
for (const dir of dirs) {
|
|
106
|
+
let names = [];
|
|
107
|
+
try {
|
|
108
|
+
names = fs.readdirSync(dir);
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const name of names) {
|
|
114
|
+
if (!(entry?.detect?.skills ?? []).includes(name)) continue;
|
|
115
|
+
|
|
116
|
+
// existsSync follows the link, so a dangling one is false. That is the
|
|
117
|
+
// point: ~/.claude/skills is full of symlinks into a shared tree, and
|
|
118
|
+
// when that tree is pruned the links stay behind. A host cannot load a
|
|
119
|
+
// skill through a broken link, so neither may this count it as one —
|
|
120
|
+
// warning about a library that is not really there is the fastest way
|
|
121
|
+
// to teach someone to ignore the warning.
|
|
122
|
+
const entryPath = path.join(dir, name);
|
|
123
|
+
if (!fs.existsSync(entryPath)) continue;
|
|
124
|
+
|
|
125
|
+
const resolved = realPath(entryPath);
|
|
126
|
+
// Report where removal would actually have an effect. Deleting
|
|
127
|
+
// ~/.claude/skills/<name> when it is a symlink removes the link and
|
|
128
|
+
// leaves the skill sitting in ~/.agents/skills, which is both useless
|
|
129
|
+
// and confusing when the next run warns about it again.
|
|
130
|
+
if (!seen.has(resolved)) seen.set(resolved, { name, dir, home: path.dirname(resolved) });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const floor = entry?.detect?.minSkills ?? DEFAULT_MIN_SKILLS;
|
|
135
|
+
const skills = seen.size >= floor ? [...seen.values()] : [];
|
|
136
|
+
|
|
137
|
+
if (plugin || skills.length > 0) findings.push({ entry, plugin, skills });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return findings;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function fill(template, values) {
|
|
144
|
+
return String(template ?? '').replace(/\{(\w+)\}/g, (whole, key) => values[key] ?? whole);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// One row, phrased identically wherever it is printed. The status is `warn`
|
|
148
|
+
// and never `broken`: condux is installed correctly, and the installer's own
|
|
149
|
+
// verify beat keys its exit code on `broken` — a conflict must be loud without
|
|
150
|
+
// failing an install that in fact succeeded.
|
|
151
|
+
export function describe(findings) {
|
|
152
|
+
if (findings.length === 0) {
|
|
153
|
+
return { status: 'done', detail: 'no known-conflicting skill library found on this machine' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const details = [];
|
|
157
|
+
const fixes = [];
|
|
158
|
+
|
|
159
|
+
for (const { entry, plugin, skills } of findings) {
|
|
160
|
+
const where = [];
|
|
161
|
+
if (plugin) where.push(`installed as ${plugin.key} on ${plugin.host}`);
|
|
162
|
+
if (skills.length > 0) {
|
|
163
|
+
const dirs = [...new Set(skills.map(({ home }) => home))];
|
|
164
|
+
where.push(`${skills.length} of its skills present in ${dirs.join(', ')}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const pairs = (entry.overlaps ?? []).length;
|
|
168
|
+
details.push(`${entry.title} — ${where.join('; ')}${pairs > 0 ? `; ${pairs} skills overlap condux` : ''}`);
|
|
169
|
+
|
|
170
|
+
if (plugin) fixes.push(fill(entry.remedy?.plugin, { key: plugin.key }));
|
|
171
|
+
if (skills.length > 0) {
|
|
172
|
+
const dirs = [...new Set(skills.map(({ home }) => home))];
|
|
173
|
+
fixes.push(fill(entry.remedy?.skills, { dir: dirs.join(', ') }));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
status: 'warn',
|
|
179
|
+
detail: details.join(' · '),
|
|
180
|
+
// Printed, never run. Removing another vendor's registration is outside
|
|
181
|
+
// what this installer registered, and so outside what it may reverse.
|
|
182
|
+
fix: `${fixes.filter(Boolean).join(' — or ')} (run one library or the other; condux does not remove it for you)`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The whole probe, so a caller adds a host label and nothing else.
|
|
187
|
+
export function probe(file, hosts, home) {
|
|
188
|
+
const { entries, error } = loadRegistry(file);
|
|
189
|
+
if (error) return { status: 'skipped', detail: `conflict registry unavailable: ${error}` };
|
|
190
|
+
|
|
191
|
+
return describe(detectConflicts(entries, hosts, home));
|
|
192
|
+
}
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// condux's health check — "is condux actually working on this host?"
|
|
3
|
+
//
|
|
4
|
+
// Follows the toolkit health-check convention (toolkit-skill-standards):
|
|
5
|
+
// detect → probe → report → fix. Probes are static parse PLUS execution, and
|
|
6
|
+
// condux is the case that needs it — session-start.mjs fails open by design,
|
|
7
|
+
// so a hook whose payload has gone missing exits 0 and says nothing. Static
|
|
8
|
+
// checks call that healthy; running it does not.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import process from 'node:process';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const SKILL_BASE = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const PLUGIN_ROOT = path.resolve(SKILL_BASE, '..', '..', '..');
|
|
19
|
+
const HOME = os.homedir();
|
|
20
|
+
const EXEC_TIMEOUT = 5000;
|
|
21
|
+
const PACKAGE = '@jabworks/condux';
|
|
22
|
+
|
|
23
|
+
// Imported dynamically so a doctor whose sibling has gone missing still runs.
|
|
24
|
+
// A health check is what you reach for when an install is already suspect —
|
|
25
|
+
// dying at module load is the one failure mode it cannot afford, and a static
|
|
26
|
+
// import would do exactly that.
|
|
27
|
+
let conflicts = null;
|
|
28
|
+
try {
|
|
29
|
+
conflicts = await import('./conflicts.mjs');
|
|
30
|
+
} catch {
|
|
31
|
+
conflicts = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const USAGE = `usage: doctor [options]
|
|
35
|
+
|
|
36
|
+
--host <claude|codex|opencode> probe one host only
|
|
37
|
+
--quiet print only broken and absent rows
|
|
38
|
+
--fix run condux's installer, then re-probe
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
function parseFlags(argv) {
|
|
42
|
+
const flags = {};
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
if (!argv[i].startsWith('--')) continue;
|
|
46
|
+
const key = argv[i].slice(2);
|
|
47
|
+
const next = argv[i + 1];
|
|
48
|
+
|
|
49
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
50
|
+
flags[key] = next;
|
|
51
|
+
i++;
|
|
52
|
+
} else {
|
|
53
|
+
flags[key] = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return flags;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function firstExisting(candidates) {
|
|
61
|
+
return candidates.find((candidate) => candidate && fs.existsSync(candidate)) ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The hooks directory is plugin-level in an install and skill-level in the
|
|
65
|
+
// source tree (workflow owns the canonical copy), so search both.
|
|
66
|
+
function findHook(file) {
|
|
67
|
+
return firstExisting([
|
|
68
|
+
path.join(PLUGIN_ROOT, 'hooks', file),
|
|
69
|
+
path.join(SKILL_BASE, '..', 'workflow', 'hooks', file),
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function detectHosts() {
|
|
74
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(HOME, '.config');
|
|
75
|
+
const dirs = {
|
|
76
|
+
claude: path.join(HOME, '.claude'),
|
|
77
|
+
// CODEX_HOME is honoured by both sub-installers and by install.mjs. Without
|
|
78
|
+
// it here, the installer's verify beat would probe a different directory
|
|
79
|
+
// than the one it just wrote to, and report a pass for the wrong config.
|
|
80
|
+
codex: process.env.CODEX_HOME || path.join(HOME, '.codex'),
|
|
81
|
+
opencode: path.join(configHome, 'opencode'),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return Object.fromEntries(
|
|
85
|
+
Object.entries(dirs).map(([host, dir]) => [host, fs.existsSync(dir) ? dir : null]),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readJson(file) {
|
|
90
|
+
try {
|
|
91
|
+
return { value: JSON.parse(fs.readFileSync(file, 'utf8')) };
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return { error: String(err?.message ?? err) };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Pulls the first hook command for an event out of either host's manifest —
|
|
98
|
+
// the two dialects differ only in the root variable they interpolate.
|
|
99
|
+
function hookCommand(manifest, event) {
|
|
100
|
+
const entries = manifest?.hooks?.[event] ?? [];
|
|
101
|
+
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
for (const hook of entry.hooks ?? []) {
|
|
104
|
+
if (typeof hook.command === 'string') return hook.command;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolveCommandPath(command, rootVariable) {
|
|
112
|
+
const match = command.match(/"([^"]+)"/);
|
|
113
|
+
const raw = match ? match[1] : command.split(/\s+/).slice(1)[0] ?? '';
|
|
114
|
+
|
|
115
|
+
return raw.replace(rootVariable, PLUGIN_ROOT);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function runSessionStart(script, hostFlag) {
|
|
119
|
+
const res = spawnSync(process.execPath, [script, hostFlag], {
|
|
120
|
+
timeout: EXEC_TIMEOUT,
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (res.error) {
|
|
125
|
+
const why = res.error.code === 'ETIMEDOUT' ? 'no answer within 5s' : String(res.error.message);
|
|
126
|
+
|
|
127
|
+
return { ok: false, why };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const stdout = (res.stdout ?? '').trim();
|
|
131
|
+
if (stdout === '') {
|
|
132
|
+
// The documented fail-open path: routing.md missing or empty. Harmless to
|
|
133
|
+
// the session and invisible without this probe.
|
|
134
|
+
return { ok: false, why: 'hook produced no output — it is failing open, so routing.md is missing or empty' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (hostFlag === '--codex') {
|
|
138
|
+
return stdout.includes('/condux:workflow')
|
|
139
|
+
? { ok: true, why: 'SessionStart emits the routing payload' }
|
|
140
|
+
: { ok: false, why: 'hook output does not carry the routing rule' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const payload = JSON.parse(stdout);
|
|
145
|
+
const context = payload?.hookSpecificOutput?.additionalContext ?? '';
|
|
146
|
+
|
|
147
|
+
if (payload?.hookSpecificOutput?.hookEventName !== 'SessionStart') {
|
|
148
|
+
return { ok: false, why: 'hook output is JSON but not a SessionStart envelope' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return context.trim() === ''
|
|
152
|
+
? { ok: false, why: 'SessionStart envelope carries an empty context' }
|
|
153
|
+
: { ok: true, why: 'SessionStart emits a valid envelope with the routing payload' };
|
|
154
|
+
} catch {
|
|
155
|
+
return { ok: false, why: 'hook output is not the JSON envelope Claude Code expects' };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Hooks are registered by the plugin manifest, so outside a plugin install
|
|
160
|
+
// there is nothing registered to probe — and the root variables the manifest
|
|
161
|
+
// interpolates have no meaning there either.
|
|
162
|
+
const IS_PLUGIN = fs.existsSync(path.join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json'));
|
|
163
|
+
|
|
164
|
+
function probeHookManifest(host, { file, event, rootVariable, foreignVariable, hostFlag }) {
|
|
165
|
+
if (!IS_PLUGIN) {
|
|
166
|
+
return { host, status: 'absent', detail: 'not a plugin install — this tree registers no hooks' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const manifest = findHook(file);
|
|
170
|
+
if (!manifest) {
|
|
171
|
+
return { host, status: 'absent', detail: `not a plugin install — no hooks/${file}` };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const { value, error } = readJson(manifest);
|
|
175
|
+
if (error) {
|
|
176
|
+
return { host, status: 'broken', detail: `${file} is unparseable: ${error}`, fix: 'reinstall the plugin' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const command = hookCommand(value, event);
|
|
180
|
+
if (!command) return { host, status: 'broken', detail: `${file} declares no ${event} hook`, fix: 'reinstall the plugin' };
|
|
181
|
+
|
|
182
|
+
if (command.includes(foreignVariable)) {
|
|
183
|
+
return {
|
|
184
|
+
host,
|
|
185
|
+
status: 'broken',
|
|
186
|
+
detail: `${event} uses ${foreignVariable}, which this host never expands`,
|
|
187
|
+
fix: `the manifest must use ${rootVariable} — reinstall the plugin`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const script = resolveCommandPath(command, rootVariable);
|
|
192
|
+
if (!fs.existsSync(script)) {
|
|
193
|
+
return { host, status: 'broken', detail: `${event} points at a missing script: ${script}`, fix: 'reinstall the plugin' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const verdict = runSessionStart(script, hostFlag);
|
|
197
|
+
|
|
198
|
+
return verdict.ok
|
|
199
|
+
? { host, status: 'done', detail: verdict.why }
|
|
200
|
+
: { host, status: 'broken', detail: verdict.why, fix: 'reinstall the plugin — its routing payload did not ship' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function probeClaude(hosts) {
|
|
204
|
+
if (!hosts.claude) return { host: 'claude', status: 'absent', detail: 'no ~/.claude on this machine' };
|
|
205
|
+
|
|
206
|
+
return probeHookManifest('claude', {
|
|
207
|
+
file: 'hooks.json',
|
|
208
|
+
event: 'SessionStart',
|
|
209
|
+
rootVariable: '${CLAUDE_PLUGIN_ROOT}',
|
|
210
|
+
foreignVariable: '${PLUGIN_ROOT}',
|
|
211
|
+
hostFlag: '--claude',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Codex's hook support sits behind an experimental feature flag. A plugin
|
|
216
|
+
// manifest can declare hooks; nothing in a plugin can enable them. With the
|
|
217
|
+
// flag off, the manifest parses, every path resolves, and no hook fires — which
|
|
218
|
+
// this doctor scored `done` until docket #9. It is the static-parse blind spot
|
|
219
|
+
// the probe-depth decision names, applied to an input rather than to a path.
|
|
220
|
+
//
|
|
221
|
+
// The claim is narrow on purpose: the flag is set. Nothing on disk records
|
|
222
|
+
// whether Codex has restarted since, and a running Codex does not re-read it.
|
|
223
|
+
function probeCodexFeatureFlag(codexHome) {
|
|
224
|
+
const config = path.join(codexHome, 'config.toml');
|
|
225
|
+
const fix = `run condux's install.mjs, or add [features] hooks = true to ${config}`;
|
|
226
|
+
|
|
227
|
+
if (!fs.existsSync(config)) {
|
|
228
|
+
return { host: 'codex', status: 'broken', detail: 'no config.toml — the hooks feature is not enabled', fix };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let text = '';
|
|
232
|
+
try {
|
|
233
|
+
text = fs.readFileSync(config, 'utf8');
|
|
234
|
+
} catch (err) {
|
|
235
|
+
return { host: 'codex', status: 'broken', detail: `cannot read config.toml: ${err.message}`, fix };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const lines = text.split('\n');
|
|
239
|
+
const start = lines.findIndex((line) => /^\s*\[features\]\s*(#.*)?$/.test(line));
|
|
240
|
+
if (start === -1) {
|
|
241
|
+
return { host: 'codex', status: 'broken', detail: 'config.toml declares no [features] table — hooks cannot fire', fix };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let end = lines.length;
|
|
245
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
246
|
+
if (/^\s*\[/.test(lines[i])) {
|
|
247
|
+
end = i;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const enabled = lines.slice(start + 1, end).some((line) => /^\s*hooks\s*=\s*true\s*(#.*)?$/.test(line));
|
|
253
|
+
|
|
254
|
+
return enabled
|
|
255
|
+
? null
|
|
256
|
+
: { host: 'codex', status: 'broken', detail: 'features.hooks is not true — the manifest resolves but no hook fires', fix };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function probeCodex(hosts) {
|
|
260
|
+
if (!hosts.codex) return { host: 'codex', status: 'absent', detail: 'no ~/.codex on this machine' };
|
|
261
|
+
|
|
262
|
+
// Runs first: with the flag off, how well the manifest parses is moot.
|
|
263
|
+
const flag = probeCodexFeatureFlag(hosts.codex);
|
|
264
|
+
if (flag) return flag;
|
|
265
|
+
|
|
266
|
+
const row = probeHookManifest('codex', {
|
|
267
|
+
file: 'codex-hooks.json',
|
|
268
|
+
event: 'SessionStart',
|
|
269
|
+
rootVariable: '${PLUGIN_ROOT}',
|
|
270
|
+
foreignVariable: '${CLAUDE_PLUGIN_ROOT}',
|
|
271
|
+
hostFlag: '--codex',
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (row.status !== 'done') return row;
|
|
275
|
+
|
|
276
|
+
// plan-review's Stop hook blocks a turn for up to four days waiting on a
|
|
277
|
+
// human, so it is resolved and never executed.
|
|
278
|
+
const manifest = findHook('codex-hooks.json');
|
|
279
|
+
const stop = hookCommand(readJson(manifest).value, 'Stop');
|
|
280
|
+
if (!stop) return { ...row, detail: `${row.detail}; no Stop hook for plan-review` };
|
|
281
|
+
|
|
282
|
+
const server = resolveCommandPath(stop, '${PLUGIN_ROOT}');
|
|
283
|
+
|
|
284
|
+
return fs.existsSync(server)
|
|
285
|
+
? { ...row, detail: `${row.detail}; plan-review Stop hook resolves` }
|
|
286
|
+
: {
|
|
287
|
+
host: 'codex',
|
|
288
|
+
status: 'broken',
|
|
289
|
+
detail: `SessionStart is fine but the Stop hook points at a missing annotate-server.js: ${server}`,
|
|
290
|
+
fix: 'reinstall the plugin',
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function findPackage(configDir) {
|
|
295
|
+
return firstExisting([
|
|
296
|
+
path.join(configDir, 'node_modules', ...PACKAGE.split('/')),
|
|
297
|
+
path.join(HOME, '.local', 'share', 'opencode', 'node_modules', ...PACKAGE.split('/')),
|
|
298
|
+
path.join(HOME, '.cache', 'opencode', 'node_modules', ...PACKAGE.split('/')),
|
|
299
|
+
]);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function probeOpencode(hosts) {
|
|
303
|
+
if (!hosts.opencode) return { host: 'opencode', status: 'absent', detail: 'no opencode config dir on this machine' };
|
|
304
|
+
|
|
305
|
+
const config = path.join(hosts.opencode, 'opencode.json');
|
|
306
|
+
const fix = `add ${PACKAGE} to the plugin array in ${config}`;
|
|
307
|
+
if (!fs.existsSync(config)) return { host: 'opencode', status: 'absent', detail: 'no opencode.json — condux is not registered', fix };
|
|
308
|
+
|
|
309
|
+
const { value, error } = readJson(config);
|
|
310
|
+
if (error) return { host: 'opencode', status: 'broken', detail: `opencode.json is unparseable: ${error}`, fix };
|
|
311
|
+
|
|
312
|
+
const registered = (value?.plugin ?? []).some((entry) => String(entry).startsWith(PACKAGE));
|
|
313
|
+
if (!registered) return { host: 'opencode', status: 'absent', detail: `${PACKAGE} is not in the plugin array`, fix };
|
|
314
|
+
|
|
315
|
+
const pkg = findPackage(hosts.opencode);
|
|
316
|
+
if (!pkg) {
|
|
317
|
+
return {
|
|
318
|
+
host: 'opencode',
|
|
319
|
+
status: 'absent',
|
|
320
|
+
detail: 'registered, but no local copy to verify — OpenCode fetches plugins at startup',
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
for (const dir of ['agents', 'skills']) {
|
|
325
|
+
const bundled = path.join(pkg, dir);
|
|
326
|
+
if (!fs.existsSync(bundled) || fs.readdirSync(bundled).length === 0) {
|
|
327
|
+
return { host: 'opencode', status: 'broken', detail: `the installed package ships no ${dir}/`, fix: `reinstall ${PACKAGE}` };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const check = spawnSync(process.execPath, ['--check', path.join(pkg, 'index.js')], { timeout: EXEC_TIMEOUT, encoding: 'utf8' });
|
|
332
|
+
if (check.status !== 0) {
|
|
333
|
+
return { host: 'opencode', status: 'broken', detail: 'the installed package entry point does not parse', fix: `reinstall ${PACKAGE}` };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return { host: 'opencode', status: 'done', detail: 'registered, and the installed package ships its agents and skills' };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// The four specialist agents are plugin-level and reached by their own sync
|
|
340
|
+
// step, not by the skill copy — the mirror that drifted in 6ba6572.
|
|
341
|
+
function probeAgents() {
|
|
342
|
+
const dir = firstExisting([
|
|
343
|
+
path.join(PLUGIN_ROOT, 'agents'),
|
|
344
|
+
path.join(SKILL_BASE, '..', 'subagent-execution', 'agents'),
|
|
345
|
+
]);
|
|
346
|
+
|
|
347
|
+
if (!dir) return { host: 'all', status: 'absent', detail: 'no agents/ directory — not a plugin install' };
|
|
348
|
+
|
|
349
|
+
const agents = fs.readdirSync(dir).filter((entry) => entry.endsWith('.md'));
|
|
350
|
+
const expected = ['coder', 'explorer', 'planner', 'researcher'];
|
|
351
|
+
const missing = expected.filter((name) => !agents.includes(`${name}.md`));
|
|
352
|
+
|
|
353
|
+
return missing.length === 0
|
|
354
|
+
? { host: 'all', status: 'done', detail: `all four specialist agents present (${dir === path.join(PLUGIN_ROOT, 'agents') ? 'plugin-level' : 'source tree'})` }
|
|
355
|
+
: { host: 'all', status: 'broken', detail: `missing agent definitions: ${missing.join(', ')}`, fix: 'reinstall the plugin' };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Not a condux fault, so never `broken` — the exit code stays clean and the
|
|
359
|
+
// installer's verify beat keeps passing. It is reported because two libraries
|
|
360
|
+
// racing to route the same task is invisible from inside a session: nothing
|
|
361
|
+
// errors, the agent simply follows whichever contract it read last.
|
|
362
|
+
function probeConflicts(hosts) {
|
|
363
|
+
if (!conflicts) return { host: 'conflicts', status: 'skipped', detail: 'conflicts.mjs is not beside this doctor' };
|
|
364
|
+
|
|
365
|
+
return { host: 'conflicts', ...conflicts.probe(path.join(SKILL_BASE, 'conflicts.json'), hosts, HOME) };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function compareVersions(a, b) {
|
|
369
|
+
const pa = String(a).split('.').map(Number);
|
|
370
|
+
const pb = String(b).split('.').map(Number);
|
|
371
|
+
|
|
372
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
373
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
374
|
+
if (diff !== 0) return diff < 0 ? -1 : 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Offline by design: the clone the host already fetched is the only reference,
|
|
381
|
+
// and its age is printed so a stale answer reads as stale.
|
|
382
|
+
function marketplaceVersion(name) {
|
|
383
|
+
const root = path.join(HOME, '.claude', 'plugins', 'marketplaces');
|
|
384
|
+
if (!fs.existsSync(root)) return null;
|
|
385
|
+
|
|
386
|
+
for (const entry of fs.readdirSync(root)) {
|
|
387
|
+
const manifest = path.join(root, entry, '.claude-plugin', 'marketplace.json');
|
|
388
|
+
if (!fs.existsSync(manifest)) continue;
|
|
389
|
+
|
|
390
|
+
const { value } = readJson(manifest);
|
|
391
|
+
const plugin = (value?.plugins ?? []).find((candidate) => candidate.name === name);
|
|
392
|
+
if (!plugin) continue;
|
|
393
|
+
|
|
394
|
+
const clone = path.join(root, entry);
|
|
395
|
+
const log = spawnSync('git', ['-C', clone, 'log', '-1', '--format=%cs'], { timeout: EXEC_TIMEOUT, encoding: 'utf8' });
|
|
396
|
+
const fetched = log.status === 0 && log.stdout.trim()
|
|
397
|
+
? log.stdout.trim()
|
|
398
|
+
: fs.statSync(manifest).mtime.toISOString().slice(0, 10);
|
|
399
|
+
|
|
400
|
+
const source = String(plugin.source ?? '');
|
|
401
|
+
const offered = source.startsWith('.')
|
|
402
|
+
? readJson(path.join(clone, source, '.claude-plugin', 'plugin.json')).value?.version ?? null
|
|
403
|
+
: null;
|
|
404
|
+
|
|
405
|
+
return { version: offered, clone: entry, fetched, remote: !source.startsWith('.') };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function probeVersion() {
|
|
412
|
+
const manifest = path.join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json');
|
|
413
|
+
if (!fs.existsSync(manifest)) {
|
|
414
|
+
return { host: 'all', status: 'absent', detail: 'not a plugin install — nothing to compare a version against' };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const installed = readJson(manifest).value?.version ?? null;
|
|
418
|
+
const market = marketplaceVersion('condux');
|
|
419
|
+
|
|
420
|
+
if (!installed) return { host: 'all', status: 'broken', detail: 'plugin.json carries no version', fix: 'reinstall the plugin' };
|
|
421
|
+
if (!market) return { host: 'all', status: 'absent', detail: `installed ${installed}; no local marketplace clone to compare against` };
|
|
422
|
+
if (!market.version) {
|
|
423
|
+
const why = market.remote ? 'its source is remote and this check never fetches' : 'its manifest carries no version';
|
|
424
|
+
|
|
425
|
+
return { host: 'all', status: 'absent', detail: `installed ${installed}; marketplace clone ${market.clone} offers no comparable version — ${why}` };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const order = compareVersions(installed, market.version);
|
|
429
|
+
const stamp = `marketplace clone ${market.clone} as of ${market.fetched}`;
|
|
430
|
+
|
|
431
|
+
if (order < 0) {
|
|
432
|
+
return {
|
|
433
|
+
host: 'all',
|
|
434
|
+
status: 'broken',
|
|
435
|
+
detail: `installed ${installed}, marketplace offers ${market.version} (${stamp})`,
|
|
436
|
+
fix: '/plugin update condux@jabworks-agentic-toolkit',
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const lead = order > 0 ? ` (ahead of marketplace ${market.version} — local build)` : '';
|
|
441
|
+
|
|
442
|
+
return { host: 'all', status: 'done', detail: `installed ${installed}${lead}; ${stamp}` };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const WIDTH_HOST = 10;
|
|
446
|
+
const WIDTH_STATUS = 8;
|
|
447
|
+
|
|
448
|
+
function report(rows, quiet) {
|
|
449
|
+
for (const row of rows) {
|
|
450
|
+
if (quiet && row.status === 'done') continue;
|
|
451
|
+
process.stdout.write(
|
|
452
|
+
row.host.padEnd(WIDTH_HOST) + ' ' + row.status.padEnd(WIDTH_STATUS) + ' ' + row.detail + '\n',
|
|
453
|
+
);
|
|
454
|
+
if (row.fix) {
|
|
455
|
+
process.stdout.write(' '.repeat(WIDTH_HOST + WIDTH_STATUS + 2) + '↳ ' + row.fix + '\n');
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function summarize(rows) {
|
|
461
|
+
const broken = rows.filter((row) => row.status === 'broken').length;
|
|
462
|
+
const warned = rows.filter((row) => row.status === 'warn').length;
|
|
463
|
+
// A warning never claims a clean bill of health, and never withholds one
|
|
464
|
+
// either: condux works, something else on this machine competes with it.
|
|
465
|
+
const clean = warned === 0 ? 'condux is healthy on every host present' : `condux is healthy on every host present, with ${warned} warning(s)`;
|
|
466
|
+
const verdict = broken === 0 ? clean : `${broken} probe(s) broken`;
|
|
467
|
+
|
|
468
|
+
return `\n${verdict} — probes are static-parse plus execution; they cannot prove the host invoked anything.\n`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// --fix performs no registration itself: it runs condux's installer and probes
|
|
472
|
+
// again, so idempotency, backups and atomic writes stay in one place. The
|
|
473
|
+
// installer's own verify beat calls this doctor back with --host <h> --quiet
|
|
474
|
+
// and never --fix, so the two cannot ping-pong.
|
|
475
|
+
function findInstaller() {
|
|
476
|
+
return firstExisting([
|
|
477
|
+
path.join(PLUGIN_ROOT, 'install.mjs'),
|
|
478
|
+
path.join(SKILL_BASE, '..', '..', 'plugins', 'condux', 'install.mjs'),
|
|
479
|
+
]);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function runInstaller(only) {
|
|
483
|
+
const installer = findInstaller();
|
|
484
|
+
if (!installer) {
|
|
485
|
+
process.stdout.write('no install.mjs found beside this plugin — nothing to run for --fix\n\n');
|
|
486
|
+
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const args = [installer];
|
|
491
|
+
if (only) args.push('--host', only);
|
|
492
|
+
|
|
493
|
+
process.stdout.write(`running ${installer}${only ? ` --host ${only}` : ''}\n`);
|
|
494
|
+
const result = spawnSync(process.execPath, args, { timeout: 60000, encoding: 'utf8' });
|
|
495
|
+
|
|
496
|
+
// Report the delegate's failure rather than re-probing in silence: `running …`
|
|
497
|
+
// followed by nothing reads as a repair that happened.
|
|
498
|
+
if (result.error) process.stdout.write(`the installer could not be run: ${result.error.message}\n`);
|
|
499
|
+
else if (result.signal) process.stdout.write(`the installer was killed by ${result.signal}\n`);
|
|
500
|
+
else if (result.status !== 0) process.stdout.write(`the installer exited ${result.status}\n`);
|
|
501
|
+
|
|
502
|
+
process.stdout.write('\n');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function main(argv) {
|
|
506
|
+
const flags = parseFlags(argv);
|
|
507
|
+
if (flags.help) {
|
|
508
|
+
process.stdout.write(USAGE);
|
|
509
|
+
|
|
510
|
+
return 0;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (!findHook('session-start.mjs')) {
|
|
514
|
+
report([{ host: 'all', status: 'broken', detail: `condux's hooks are not reachable from ${SKILL_BASE}` }], false);
|
|
515
|
+
|
|
516
|
+
return 2;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const only = typeof flags.host === 'string' ? flags.host : null;
|
|
520
|
+
if (flags.fix === true) runInstaller(only);
|
|
521
|
+
|
|
522
|
+
// Detected after the installer runs — it may have created a host directory
|
|
523
|
+
// that was not there when this process started.
|
|
524
|
+
const hosts = detectHosts();
|
|
525
|
+
const perHost = [
|
|
526
|
+
['claude', () => probeClaude(hosts)],
|
|
527
|
+
['codex', () => probeCodex(hosts)],
|
|
528
|
+
['opencode', () => probeOpencode(hosts)],
|
|
529
|
+
];
|
|
530
|
+
|
|
531
|
+
const rows = perHost.filter(([host]) => !only || host === only).map(([, probe]) => probe());
|
|
532
|
+
if (!only) rows.push(probeAgents(), probeVersion(), probeConflicts(hosts));
|
|
533
|
+
|
|
534
|
+
report(rows, flags.quiet === true);
|
|
535
|
+
process.stdout.write(summarize(rows));
|
|
536
|
+
|
|
537
|
+
return rows.some((row) => row.status === 'broken') ? 1 : 0;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
process.exit(main(process.argv.slice(2)));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "query": "is condux actually working on this machine?", "should_trigger": true, "expected_skill": "condux-doctor" },
|
|
3
|
+
{ "query": "check my condux install", "should_trigger": true, "expected_skill": "condux-doctor" },
|
|
4
|
+
{ "query": "the routing rule stopped showing up at session start", "should_trigger": true, "expected_skill": "condux-doctor" },
|
|
5
|
+
{ "query": "did the condux SessionStart hook actually fire?", "should_trigger": true, "expected_skill": "condux-doctor" },
|
|
6
|
+
{ "query": "the specialist agents are missing after I updated the plugin", "should_trigger": true, "expected_skill": "condux-doctor" },
|
|
7
|
+
{ "query": "run a health check on condux", "should_trigger": true, "expected_skill": "condux-doctor" },
|
|
8
|
+
{ "query": "verify condux registered properly on codex", "should_trigger": true, "expected_skill": "condux-doctor" },
|
|
9
|
+
{ "query": "add a search filter to the invoice table", "should_trigger": true, "expected_skill": "workflow" },
|
|
10
|
+
{ "query": "how do condux tiers work?", "should_trigger": true, "expected_skill": "workflow" },
|
|
11
|
+
{ "query": "why isn't my skill triggering", "should_trigger": true, "expected_skill": "toolkit-debugging-playbook" },
|
|
12
|
+
{ "query": "dist drifted from skills after my edit", "should_trigger": true, "expected_skill": "toolkit-debugging-playbook" },
|
|
13
|
+
{ "query": "what should I work on next?", "should_trigger": true, "expected_skill": "groom" },
|
|
14
|
+
{ "query": "what is a git hook?", "should_trigger": false, "expected_skill": null },
|
|
15
|
+
{ "query": "my laptop fan is loud when node runs", "should_trigger": false, "expected_skill": null },
|
|
16
|
+
{ "query": "check the health of the kubernetes cluster", "should_trigger": false, "expected_skill": null }
|
|
17
|
+
]
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{ "query": "review the plan in the browser before implementing", "should_trigger": true, "expected_skill": "plan-review" },
|
|
17
17
|
{ "query": "execute the plan with agents", "should_trigger": true, "expected_skill": "subagent-execution" },
|
|
18
18
|
{ "query": "design isnt agreed yet, help me align first", "should_trigger": true, "expected_skill": "discovery" },
|
|
19
|
-
{ "query": "add a task to my todo list", "should_trigger": false, "expected_skill": null },
|
|
19
|
+
{ "query": "add a task to my todo list", "should_trigger": false, "expected_skill": null, "accept": ["record"] },
|
|
20
20
|
{ "query": "plan my sprint for next week", "should_trigger": false, "expected_skill": null },
|
|
21
21
|
{ "query": "make a project timeline gantt chart", "should_trigger": false, "expected_skill": null }
|
|
22
22
|
]
|
|
@@ -1,28 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Install the plan-review Stop hook
|
|
2
|
+
# Install (or remove) the plan-review Stop hook in Codex.
|
|
3
3
|
#
|
|
4
4
|
# Codex has no ExitPlanMode interception point, so plan review runs off Codex's
|
|
5
5
|
# experimental `Stop` hook: when a planning turn ends, annotate-server.js
|
|
6
6
|
# (--codex-stop) opens the review UI and returns a continuation reason on deny.
|
|
7
7
|
#
|
|
8
8
|
# This script:
|
|
9
|
-
# 1. validates and merges a Stop hook
|
|
10
|
-
# this skill's annotate-server.js
|
|
11
|
-
# inherit PATH)
|
|
9
|
+
# 1. validates and merges (or removes) a Stop hook entry in
|
|
10
|
+
# <CODEX_HOME>/hooks.json pointing at this skill's annotate-server.js
|
|
11
|
+
# (absolute paths — Codex Desktop doesn't inherit PATH)
|
|
12
12
|
# 2. enables the experimental hooks feature in <CODEX_HOME>/config.toml
|
|
13
|
+
# (install only — hooks.json and the feature flag are shared host state
|
|
14
|
+
# with other plugins, so uninstall never turns the flag back off)
|
|
13
15
|
#
|
|
14
|
-
# Usage: bash install-codex-hook.sh
|
|
16
|
+
# Usage: bash install-codex-hook.sh [--uninstall]
|
|
15
17
|
# Notes: experimental; disabled on Windows. Restart Codex and trust the hook
|
|
16
18
|
# after running. Honors $CODEX_HOME (falls back to ~/.codex).
|
|
17
19
|
set -euo pipefail
|
|
18
20
|
umask 077
|
|
19
21
|
|
|
22
|
+
report() { printf '%-10s %-8s %s\n' "$1" "$2" "$3"; }
|
|
23
|
+
|
|
20
24
|
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
21
25
|
SERVER="$SCRIPT_DIR/annotate-server.js"
|
|
22
|
-
|
|
26
|
+
|
|
27
|
+
MODE="install"
|
|
28
|
+
[[ "${1:-}" == "--uninstall" ]] && MODE="uninstall"
|
|
29
|
+
|
|
30
|
+
# --- 0. detect --------------------------------------------------------------
|
|
31
|
+
[[ -f "$SERVER" ]] || { report codex failed "annotate-server.js not found next to this script"; exit 1; }
|
|
23
32
|
|
|
24
33
|
NODE_BIN=$(command -v node || true)
|
|
25
|
-
[[ -n "$NODE_BIN" ]] || {
|
|
34
|
+
[[ -n "$NODE_BIN" ]] || { report codex failed "node not found on PATH"; exit 1; }
|
|
26
35
|
|
|
27
36
|
CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
|
|
28
37
|
mkdir -p "$CODEX_HOME"
|
|
@@ -30,51 +39,100 @@ CONFIG="$CODEX_HOME/config.toml"
|
|
|
30
39
|
HOOKS="$CODEX_HOME/hooks.json"
|
|
31
40
|
COMMAND="$NODE_BIN \"$SERVER\" --codex-stop"
|
|
32
41
|
|
|
33
|
-
# --- 1. validate + merge the Stop hook (idempotent) via node
|
|
34
|
-
|
|
42
|
+
# --- 1. validate + merge/remove the Stop hook (idempotent) via node ---------
|
|
43
|
+
HOOKS_RC=0
|
|
44
|
+
HOOKS_OUT=$("$NODE_BIN" - "$HOOKS" "$COMMAND" "$MODE" <<'NODE'
|
|
35
45
|
const fs = require('fs');
|
|
36
|
-
const [, , hooksPath, command] = process.argv;
|
|
46
|
+
const [, , hooksPath, command, mode] = process.argv;
|
|
37
47
|
let cfg = {};
|
|
38
48
|
try {
|
|
39
49
|
cfg = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
|
40
50
|
} catch (e) {
|
|
41
51
|
if (e.code !== 'ENOENT') {
|
|
42
|
-
console.error('
|
|
52
|
+
console.error('refusing to overwrite invalid hooks JSON at ' + hooksPath + ': ' + e.message);
|
|
43
53
|
process.exit(1);
|
|
44
54
|
}
|
|
45
55
|
}
|
|
46
56
|
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) {
|
|
47
|
-
console.error('
|
|
57
|
+
console.error('hooks config must be a JSON object: ' + hooksPath);
|
|
48
58
|
process.exit(1);
|
|
49
59
|
}
|
|
50
60
|
if (cfg.hooks === undefined) cfg.hooks = {};
|
|
51
61
|
if (!cfg.hooks || typeof cfg.hooks !== 'object' || Array.isArray(cfg.hooks)) {
|
|
52
|
-
console.error('
|
|
62
|
+
console.error('"hooks" must be a JSON object: ' + hooksPath);
|
|
53
63
|
process.exit(1);
|
|
54
64
|
}
|
|
55
65
|
if (cfg.hooks.Stop === undefined) cfg.hooks.Stop = [];
|
|
56
66
|
if (!Array.isArray(cfg.hooks.Stop)) {
|
|
57
|
-
console.error('
|
|
67
|
+
console.error('"hooks.Stop" must be an array: ' + hooksPath);
|
|
58
68
|
process.exit(1);
|
|
59
69
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
|
|
71
|
+
// Marker used to find our own entry again — matching on the command string
|
|
72
|
+
// (never array position) keeps this from touching Stop entries any other
|
|
73
|
+
// plugin (e.g. concord) registered on the same shared hooks.json.
|
|
74
|
+
const isOurs = (entry) => JSON.stringify(entry).includes('--codex-stop');
|
|
75
|
+
|
|
76
|
+
let changed;
|
|
77
|
+
if (mode === 'uninstall') {
|
|
78
|
+
const before = cfg.hooks.Stop.length;
|
|
79
|
+
cfg.hooks.Stop = cfg.hooks.Stop.filter((e) => !isOurs(e));
|
|
80
|
+
changed = cfg.hooks.Stop.length !== before;
|
|
81
|
+
if (!changed) {
|
|
82
|
+
process.stdout.write('skipped\tno --codex-stop entry found in ' + hooksPath + '\n');
|
|
83
|
+
process.exit(0);
|
|
67
84
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
// An empty array still reads as "a Stop hook is registered here" to the
|
|
86
|
+
// next reader, and an empty hooks object the same way for "hooks" — drop
|
|
87
|
+
// both rather than leave a stub behind.
|
|
88
|
+
if (cfg.hooks.Stop.length === 0) delete cfg.hooks.Stop;
|
|
89
|
+
if (Object.keys(cfg.hooks).length === 0) delete cfg.hooks;
|
|
72
90
|
} else {
|
|
73
|
-
|
|
91
|
+
const has = cfg.hooks.Stop.some(isOurs);
|
|
92
|
+
if (has) {
|
|
93
|
+
process.stdout.write('skipped\tStop hook already present in ' + hooksPath + '\n');
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
cfg.hooks.Stop.push({ hooks: [{ type: 'command', command, timeout: 345600 }] });
|
|
97
|
+
changed = true;
|
|
74
98
|
}
|
|
99
|
+
|
|
100
|
+
const tmp = hooksPath + '.tmp-' + process.pid;
|
|
101
|
+
let fileMode = 0o600;
|
|
102
|
+
try { fileMode = fs.statSync(hooksPath).mode & 0o777; } catch (e) {
|
|
103
|
+
if (e.code !== 'ENOENT') throw e;
|
|
104
|
+
}
|
|
105
|
+
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', { mode: fileMode });
|
|
106
|
+
fs.chmodSync(tmp, fileMode);
|
|
107
|
+
fs.renameSync(tmp, hooksPath);
|
|
108
|
+
process.stdout.write(
|
|
109
|
+
'done\t' + (mode === 'install' ? 'merged' : 'removed') + ' the Stop hook in ' + hooksPath + '\n',
|
|
110
|
+
);
|
|
75
111
|
NODE
|
|
112
|
+
) || HOOKS_RC=$?
|
|
113
|
+
|
|
114
|
+
if [[ "$HOOKS_RC" -ne 0 ]]; then
|
|
115
|
+
report codex failed "could not update $HOOKS (reason above); nothing was changed"
|
|
116
|
+
exit 1
|
|
117
|
+
fi
|
|
118
|
+
report codex "${HOOKS_OUT%%$'\t'*}" "${HOOKS_OUT#*$'\t'}"
|
|
76
119
|
|
|
77
|
-
|
|
120
|
+
if [[ "$MODE" == "uninstall" ]]; then
|
|
121
|
+
# [features] hooks = true is shared host state — concord's installer and
|
|
122
|
+
# condux's front door both read and write it, and none of the three owns
|
|
123
|
+
# it. Report whether it's still on without touching it either way.
|
|
124
|
+
if [[ -f "$CONFIG" ]] && grep -Eq '^[[:space:]]*hooks[[:space:]]*=[[:space:]]*true' "$CONFIG"; then
|
|
125
|
+
report features warn "hooks = true left as-is in $CONFIG — concord and condux may still need it"
|
|
126
|
+
else
|
|
127
|
+
report features absent "hooks feature not enabled in $CONFIG"
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
echo ""
|
|
131
|
+
echo "done. Restart Codex to drop the hook."
|
|
132
|
+
exit 0
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
# --- 2. enable the hooks feature (install only) ------------------------------
|
|
78
136
|
if [[ -f "$CONFIG" ]] && grep -Eq '^[[:space:]]*\[features\][[:space:]]*($|#)' "$CONFIG"; then
|
|
79
137
|
cp -p "$CONFIG" "$CONFIG.tmp"
|
|
80
138
|
awk '
|
|
@@ -99,10 +157,10 @@ if [[ -f "$CONFIG" ]] && grep -Eq '^[[:space:]]*\[features\][[:space:]]*($|#)' "
|
|
|
99
157
|
}
|
|
100
158
|
END { if (in_features && !hooks_written) print "hooks = true" }
|
|
101
159
|
' "$CONFIG" > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG"
|
|
102
|
-
|
|
160
|
+
report features done "ensured hooks = true under existing [features] in $CONFIG"
|
|
103
161
|
else
|
|
104
162
|
printf '\n[features]\nhooks = true\n' >> "$CONFIG"
|
|
105
|
-
|
|
163
|
+
report features done "added [features] hooks = true to $CONFIG"
|
|
106
164
|
fi
|
|
107
165
|
|
|
108
166
|
echo ""
|
|
@@ -110,3 +168,5 @@ echo "done. Next steps:"
|
|
|
110
168
|
echo " 1. Restart Codex (and Codex Desktop if used)."
|
|
111
169
|
echo " 2. Codex will ask you to trust the hook definition — approve it."
|
|
112
170
|
echo " 3. Enter plan mode; when the planning turn ends, the review UI opens."
|
|
171
|
+
echo ""
|
|
172
|
+
echo "To undo: bash \"$0\" --uninstall"
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Usage:
|
|
12
12
|
// node install-codex-agents.mjs [--codex-home <dir>] [--dry-run]
|
|
13
|
+
// node install-codex-agents.mjs --uninstall [--codex-home <dir>] [--dry-run]
|
|
13
14
|
//
|
|
14
15
|
// Behavior:
|
|
15
16
|
// - description = the .md frontmatter description prose (Claude-style
|
|
@@ -21,6 +22,14 @@
|
|
|
21
22
|
// workspace-write for planner/coder
|
|
22
23
|
// - existing files are backed up to <name>.toml.bak first
|
|
23
24
|
//
|
|
25
|
+
// --uninstall removes exactly the .toml files this installer would have
|
|
26
|
+
// written — the names are derived from the same ../agents/*.md frontmatter,
|
|
27
|
+
// never hardcoded, since the agent set has already changed once. It never
|
|
28
|
+
// touches unrelated files a user placed in agents/ by hand, and it removes
|
|
29
|
+
// the agents/ directory itself only if that leaves it empty (plain rmdir,
|
|
30
|
+
// never recursive). It does not read or write config.toml — the
|
|
31
|
+
// [features] hooks flag is shared host state this script does not own.
|
|
32
|
+
//
|
|
24
33
|
// Schema per https://developers.openai.com/codex/subagents (verified
|
|
25
34
|
// 2026-07-08): required name/description/developer_instructions; a custom
|
|
26
35
|
// agent named like a built-in (e.g. explorer) takes precedence over it.
|
|
@@ -32,6 +41,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
32
41
|
|
|
33
42
|
const args = process.argv.slice(2);
|
|
34
43
|
const DRY = args.includes('--dry-run');
|
|
44
|
+
const UNINSTALL = args.includes('--uninstall');
|
|
35
45
|
const homeIdx = args.indexOf('--codex-home');
|
|
36
46
|
const CODEX_HOME = homeIdx >= 0 ? args[homeIdx + 1] : (process.env.CODEX_HOME || path.join(os.homedir(), '.codex'));
|
|
37
47
|
const DEST = path.join(CODEX_HOME, 'agents');
|
|
@@ -50,7 +60,8 @@ function fmField(block, key) {
|
|
|
50
60
|
return m ? m[1].trim().replace(/^['"]|['"]$/g, '') : null;
|
|
51
61
|
}
|
|
52
62
|
|
|
53
|
-
|
|
63
|
+
// --uninstall must never create the directory it is trying to remove.
|
|
64
|
+
if (!DRY && !UNINSTALL) fs.mkdirSync(DEST, { recursive: true });
|
|
54
65
|
|
|
55
66
|
for (const file of fs.readdirSync(AGENTS_DIR).filter((f) => f.endsWith('.md'))) {
|
|
56
67
|
const src = fs.readFileSync(path.join(AGENTS_DIR, file), 'utf8');
|
|
@@ -58,6 +69,28 @@ for (const file of fs.readdirSync(AGENTS_DIR).filter((f) => f.endsWith('.md')))
|
|
|
58
69
|
if (!fm) { console.error(`skip ${file}: no frontmatter`); continue; }
|
|
59
70
|
|
|
60
71
|
const name = fmField(fm[1], 'name');
|
|
72
|
+
|
|
73
|
+
if (UNINSTALL) {
|
|
74
|
+
// Same name derivation as install, just aimed at removal — the set of
|
|
75
|
+
// files this loop owns is exactly the set install would have written.
|
|
76
|
+
const dest = path.join(DEST, `${name}.toml`);
|
|
77
|
+
if (!fs.existsSync(dest)) {
|
|
78
|
+
console.log(`skipped ${dest} (already absent)`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (DRY) {
|
|
82
|
+
console.log(`--- would remove ${dest}`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
fs.unlinkSync(dest);
|
|
87
|
+
console.log(`removed ${dest}`);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`failed ${dest}: ${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
61
94
|
let description = fmField(fm[1], 'description') || '';
|
|
62
95
|
// Strip Claude-style example blocks (literal \n escapes inside the YAML
|
|
63
96
|
// string) and unescape what remains into plain prose.
|
|
@@ -96,3 +129,18 @@ for (const file of fs.readdirSync(AGENTS_DIR).filter((f) => f.endsWith('.md')))
|
|
|
96
129
|
console.log(`wrote ${dest}${preserved.length ? ' (preserved: ' + preserved.map((l) => l.split(' ')[0]).join(', ') + ')' : ''}`);
|
|
97
130
|
}
|
|
98
131
|
}
|
|
132
|
+
|
|
133
|
+
if (UNINSTALL && !DRY) {
|
|
134
|
+
// Non-recursive on purpose — this only succeeds when every file we didn't
|
|
135
|
+
// own is also gone, i.e. never for a directory the user is still using.
|
|
136
|
+
try {
|
|
137
|
+
fs.rmdirSync(DEST);
|
|
138
|
+
console.log(`removed ${DEST} (empty)`);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err.code === 'ENOTEMPTY') {
|
|
141
|
+
console.log(`left ${DEST} in place (not empty — files not owned by this installer remain)`);
|
|
142
|
+
} else if (err.code !== 'ENOENT') {
|
|
143
|
+
console.error(`warn: could not remove ${DEST}: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|