@sabaiway/agent-workflow-kit 1.3.0 → 1.5.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +70 -1
  2. package/README.md +15 -5
  3. package/SKILL.md +81 -9
  4. package/bin/install.mjs +59 -8
  5. package/bin/install.test.mjs +66 -0
  6. package/capability.json +21 -0
  7. package/migrations/1.1.0-communication-language.md +5 -5
  8. package/migrations/README.md +2 -1
  9. package/package.json +8 -5
  10. package/references/contracts.md +2 -2
  11. package/references/scripts/archive-changelog.mjs +1 -4
  12. package/references/templates/AGENTS.md +2 -2
  13. package/tools/delegation.mjs +109 -0
  14. package/tools/delegation.test.mjs +115 -0
  15. package/tools/detect-backends.mjs +310 -0
  16. package/tools/detect-backends.test.mjs +342 -0
  17. package/tools/inject-methodology.mjs +111 -0
  18. package/tools/inject-methodology.test.mjs +124 -0
  19. package/tools/manifest/fixtures/bad-available/SKILL.md +7 -0
  20. package/tools/manifest/fixtures/bad-available/capability.json +10 -0
  21. package/tools/manifest/fixtures/detect-array/SKILL.md +7 -0
  22. package/tools/manifest/fixtures/detect-array/capability.json +10 -0
  23. package/tools/manifest/fixtures/malformed-json/capability.json +1 -0
  24. package/tools/manifest/fixtures/metadata-version/SKILL.md +10 -0
  25. package/tools/manifest/fixtures/metadata-version/capability.json +9 -0
  26. package/tools/manifest/fixtures/missing-key/SKILL.md +7 -0
  27. package/tools/manifest/fixtures/missing-key/capability.json +8 -0
  28. package/tools/manifest/fixtures/missing-source/SKILL.md +7 -0
  29. package/tools/manifest/fixtures/missing-source/capability.json +11 -0
  30. package/tools/manifest/fixtures/nested-version-decoy/SKILL.md +10 -0
  31. package/tools/manifest/fixtures/nested-version-decoy/capability.json +9 -0
  32. package/tools/manifest/fixtures/null-root/capability.json +1 -0
  33. package/tools/manifest/fixtures/provides-roles-mismatch/SKILL.md +7 -0
  34. package/tools/manifest/fixtures/provides-roles-mismatch/bin/run.sh +2 -0
  35. package/tools/manifest/fixtures/provides-roles-mismatch/capability.json +11 -0
  36. package/tools/manifest/fixtures/stub/capability.json +10 -0
  37. package/tools/manifest/fixtures/traversal-source/SKILL.md +7 -0
  38. package/tools/manifest/fixtures/traversal-source/capability.json +11 -0
  39. package/tools/manifest/fixtures/unknown-schema/capability.json +9 -0
  40. package/tools/manifest/fixtures/valid/SKILL.md +10 -0
  41. package/tools/manifest/fixtures/valid/bin/run.sh +3 -0
  42. package/tools/manifest/fixtures/valid/capability.json +18 -0
  43. package/tools/manifest/fixtures/version-mismatch/SKILL.md +7 -0
  44. package/tools/manifest/fixtures/version-mismatch/capability.json +9 -0
  45. package/tools/manifest/fixtures/win-absolute-source/SKILL.md +7 -0
  46. package/tools/manifest/fixtures/win-absolute-source/capability.json +11 -0
  47. package/tools/manifest/schema.md +67 -0
  48. package/tools/manifest/validate.mjs +264 -0
  49. package/tools/manifest/validate.test.mjs +73 -0
  50. package/tools/methodology-slot.md +1 -0
  51. package/tools/release-scan.mjs +103 -0
  52. package/tools/release-scan.test.mjs +41 -0
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ // Delegation decision + hand-off plan — the kit-owned, executable form of the composition
3
+ // contract, so the "delegate vs fall back" choice and the stamp/commit responsibilities are
4
+ // pinned down by code + tests, not left to agent interpretation (Plan §1.7).
5
+ //
6
+ // detectMemory(dir) → { delegate, reason, ... } runs the kit's OWN validator + asset check
7
+ // handoffPlan(delegate) → who writes what, which stamps end up present, who owns the commit gate
8
+ //
9
+ // Pure (dependency-injectable validator + fs), dependency-free, Node >= 18.
10
+
11
+ import { statSync } from 'node:fs';
12
+ import { join, resolve } from 'node:path';
13
+ import { pathToFileURL } from 'node:url';
14
+ import { validateManifest, VALID } from './manifest/validate.mjs';
15
+
16
+ // The exact skill name a delegable memory candidate must declare (guards against a wrong-name
17
+ // manifest that happens to be a valid memory-substrate with the right assets).
18
+ export const EXPECTED_MEMORY_NAME = 'agent-workflow-memory';
19
+
20
+ // The assets a memory candidate must carry, AND their required type. A partial install (manifest +
21
+ // SKILL.md only) is missing these → invalid → fallback. Checking the type (not just existence)
22
+ // rejects a wrong-shaped install (e.g. a file where a dir is expected) BEFORE any project write.
23
+ export const REQUIRED_MEMORY_ASSETS = [
24
+ { path: 'references/templates', type: 'dir' },
25
+ { path: 'references/contracts.md', type: 'file' },
26
+ { path: 'references/scripts', type: 'dir' },
27
+ { path: 'scripts/stamp-takeover.mjs', type: 'file' },
28
+ { path: 'migrations', type: 'dir' },
29
+ { path: 'capability.json', type: 'file' },
30
+ ];
31
+
32
+ const defaultStatType = (path) => {
33
+ try {
34
+ const s = statSync(path);
35
+ return s.isDirectory() ? 'dir' : s.isFile() ? 'file' : 'other';
36
+ } catch {
37
+ return null;
38
+ }
39
+ };
40
+
41
+ // Decide whether to delegate substrate deployment to a memory candidate. The kit runs its OWN
42
+ // validator (never one shipped by the candidate). Delegate only on valid + kind memory-substrate +
43
+ // right name + available + all required assets present AT THE RIGHT TYPE; otherwise fall back.
44
+ export const detectMemory = (memorySkillDir, deps = {}) => {
45
+ const validate = deps.validate ?? validateManifest;
46
+ const statType = deps.statType ?? defaultStatType;
47
+ const report = validate(memorySkillDir);
48
+ const missingAssets = REQUIRED_MEMORY_ASSETS.filter(
49
+ (asset) => statType(join(memorySkillDir, asset.path)) !== asset.type,
50
+ ).map((asset) => asset.path);
51
+ const delegate =
52
+ report.result === VALID &&
53
+ report.kind === 'memory-substrate' &&
54
+ report.name === EXPECTED_MEMORY_NAME &&
55
+ report.available !== false &&
56
+ missingAssets.length === 0;
57
+ const reason = delegate
58
+ ? 'memory manifest valid (kind: memory-substrate) and all required assets present'
59
+ : report.result !== VALID
60
+ ? `memory manifest ${report.result} — using bundled fallback`
61
+ : report.kind !== 'memory-substrate'
62
+ ? `memory manifest kind "${report.kind}" is not memory-substrate — using bundled fallback`
63
+ : report.name !== EXPECTED_MEMORY_NAME
64
+ ? `memory manifest name "${report.name}" is not "${EXPECTED_MEMORY_NAME}" — using bundled fallback`
65
+ : report.available === false
66
+ ? 'memory manifest is a declared stub (available:false) — using bundled fallback'
67
+ : `memory install incomplete (missing: ${missingAssets.join(', ')}) — using bundled fallback`;
68
+ return { delegate, reason, validatorResult: report.result, kind: report.kind, name: report.name, available: report.available, missingAssets };
69
+ };
70
+
71
+ // The hand-off matrix. Memory NEVER raises its own commit gate; the kit owns exactly ONE
72
+ // composition-level gate, after injection. Delegated → both stamps; fallback → .workflow-version only.
73
+ export const handoffPlan = (delegate) =>
74
+ delegate
75
+ ? {
76
+ mode: 'delegate',
77
+ memoryWrites: ['docs/ai/', 'AGENTS.md', 'docs/ai/.memory-version'],
78
+ kitWrites: ['AGENTS.md methodology slot', 'docs/ai/.workflow-version'],
79
+ stampsPresent: ['.memory-version', '.workflow-version'],
80
+ memoryRaisesCommitGate: false,
81
+ commitGate: 'kit-only-after-injection',
82
+ }
83
+ : {
84
+ mode: 'fallback',
85
+ memoryWrites: [],
86
+ // Fallback ships the kit's OWN AGENTS.md, which carries the methodology INLINE (no slot
87
+ // markers) — so injection is a deliberate no-op here. Label it as inline, not a "slot"
88
+ // (the "slot" mechanism only exists in the delegate branch, on memory's AGENTS.md).
89
+ kitWrites: ['docs/ai/', 'AGENTS.md', 'AGENTS.md methodology (inline)', 'docs/ai/.workflow-version'],
90
+ stampsPresent: ['.workflow-version'],
91
+ memoryRaisesCommitGate: false,
92
+ commitGate: 'kit-only-after-injection',
93
+ };
94
+
95
+ const main = (argv) => {
96
+ const dir = argv[0];
97
+ if (!dir) {
98
+ console.error('usage: delegation.mjs <memory-skill-dir> (prints the delegate/fallback decision + hand-off plan)');
99
+ process.exit(2);
100
+ }
101
+ const decision = detectMemory(resolve(dir));
102
+ const plan = handoffPlan(decision.delegate);
103
+ console.log(`[delegation] ${plan.mode}: ${decision.reason}`);
104
+ console.log(`[delegation] stamps present after deploy: ${plan.stampsPresent.join(', ')}`);
105
+ console.log(`[delegation] commit gate: ${plan.commitGate} (memory raises its own gate: ${plan.memoryRaisesCommitGate})`);
106
+ };
107
+
108
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
109
+ if (isDirectRun) main(process.argv.slice(2));
@@ -0,0 +1,115 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { detectMemory, handoffPlan, REQUIRED_MEMORY_ASSETS } from './delegation.mjs';
4
+
5
+ // Inject a fake validator + fs so the decision matrix is tested independent of real files/agents.
6
+ const fakeValidate = (over = {}) => () => ({
7
+ result: 'valid',
8
+ kind: 'memory-substrate',
9
+ name: 'agent-workflow-memory',
10
+ available: true,
11
+ errors: [],
12
+ ...over,
13
+ });
14
+ const ASSET_TYPE = {
15
+ 'references/templates': 'dir',
16
+ 'references/contracts.md': 'file',
17
+ 'references/scripts': 'dir',
18
+ 'scripts/stamp-takeover.mjs': 'file',
19
+ migrations: 'dir',
20
+ 'capability.json': 'file',
21
+ };
22
+ const typeFor = (p) => {
23
+ for (const [k, t] of Object.entries(ASSET_TYPE)) if (p.endsWith(k)) return t;
24
+ return 'file';
25
+ };
26
+ const allPresent = (p) => typeFor(p);
27
+ const missing = (absent) => (p) => (absent.some((a) => p.endsWith(a)) ? null : typeFor(p));
28
+ const wrongType = (paths) => (p) => {
29
+ for (const [k, t] of Object.entries(ASSET_TYPE)) {
30
+ if (p.endsWith(k)) return paths.includes(k) ? (t === 'dir' ? 'file' : 'dir') : t;
31
+ }
32
+ return 'file';
33
+ };
34
+
35
+ describe('detectMemory — decision matrix', () => {
36
+ it('valid + memory-substrate + right name + available + all assets → delegate', () => {
37
+ const d = detectMemory('/m', { validate: fakeValidate(), statType: allPresent });
38
+ assert.equal(d.delegate, true);
39
+ });
40
+
41
+ it('invalid manifest → fallback', () => {
42
+ const d = detectMemory('/m', { validate: fakeValidate({ result: 'invalid' }), statType: allPresent });
43
+ assert.equal(d.delegate, false);
44
+ assert.match(d.reason, /invalid/);
45
+ });
46
+
47
+ it('unsupported schema → fallback (treated like invalid)', () => {
48
+ const d = detectMemory('/m', { validate: fakeValidate({ result: 'unsupported' }), statType: allPresent });
49
+ assert.equal(d.delegate, false);
50
+ });
51
+
52
+ it('wrong kind → fallback', () => {
53
+ const d = detectMemory('/m', { validate: fakeValidate({ kind: 'composition-root' }), statType: allPresent });
54
+ assert.equal(d.delegate, false);
55
+ assert.match(d.reason, /not memory-substrate/);
56
+ });
57
+
58
+ it('wrong name → fallback (even if kind + assets are right)', () => {
59
+ const d = detectMemory('/m', { validate: fakeValidate({ name: 'evil-substrate' }), statType: allPresent });
60
+ assert.equal(d.delegate, false);
61
+ assert.match(d.reason, /name/);
62
+ });
63
+
64
+ it('available:false stub → fallback', () => {
65
+ const d = detectMemory('/m', { validate: fakeValidate({ available: false }), statType: allPresent });
66
+ assert.equal(d.delegate, false);
67
+ assert.match(d.reason, /stub/);
68
+ });
69
+
70
+ it('partial install (missing stamp-takeover) → fallback', () => {
71
+ const d = detectMemory('/m', {
72
+ validate: fakeValidate(),
73
+ statType: missing(['scripts/stamp-takeover.mjs']),
74
+ });
75
+ assert.equal(d.delegate, false);
76
+ assert.match(d.reason, /stamp-takeover/);
77
+ });
78
+
79
+ it('wrong-type asset (templates is a file, not a dir) → fallback', () => {
80
+ const d = detectMemory('/m', { validate: fakeValidate(), statType: wrongType(['references/templates']) });
81
+ assert.equal(d.delegate, false);
82
+ assert.match(d.reason, /references\/templates/);
83
+ });
84
+
85
+ it('required assets use real (references/) paths', () => {
86
+ const paths = REQUIRED_MEMORY_ASSETS.map((a) => a.path);
87
+ assert.ok(paths.includes('references/templates'));
88
+ assert.ok(paths.includes('references/contracts.md'));
89
+ });
90
+ });
91
+
92
+ describe('handoffPlan — stamp sets + single commit gate', () => {
93
+ it('delegate → both stamps present; memory never raises its own commit gate', () => {
94
+ const p = handoffPlan(true);
95
+ assert.deepEqual(p.stampsPresent, ['.memory-version', '.workflow-version']);
96
+ assert.equal(p.memoryRaisesCommitGate, false);
97
+ assert.equal(p.commitGate, 'kit-only-after-injection');
98
+ assert.ok(p.memoryWrites.includes('docs/ai/.memory-version'));
99
+ // Delegate is the ONLY branch with a real slot (memory ships it empty; the kit injects).
100
+ assert.ok(p.kitWrites.some((w) => w.includes('slot')), 'delegate kitWrites should name the methodology slot');
101
+ });
102
+
103
+ it('fallback → only .workflow-version; kit writes everything; one kit gate', () => {
104
+ const p = handoffPlan(false);
105
+ assert.deepEqual(p.stampsPresent, ['.workflow-version']);
106
+ assert.deepEqual(p.memoryWrites, []);
107
+ assert.equal(p.memoryRaisesCommitGate, false);
108
+ assert.equal(p.commitGate, 'kit-only-after-injection');
109
+ // Fallback ships the kit's own AGENTS.md with methodology INLINE — never a "slot" (no markers).
110
+ assert.ok(
111
+ p.kitWrites.some((w) => w.includes('inline')) && !p.kitWrites.some((w) => w.includes('slot')),
112
+ 'fallback kitWrites should describe inline methodology, not a slot',
113
+ );
114
+ });
115
+ });
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ // Backend detector — read-only detection of the family's optional execution-backends (the bridges
3
+ // to subscription CLIs: codex-cli-bridge → `codex`, antigravity-cli-bridge → `agy`). Surfaced as
4
+ // `/agent-workflow-kit backends` and a one-line bootstrap summary. It answers "what is set up vs
5
+ // missing" WITHOUT running any subscription CLI: "credentials present" means the credential-marker
6
+ // FILE exists, never a live `codex login` / `agy` check (which spawns a paid/slow/networked CLI).
7
+ //
8
+ // Two orthogonal axes are reported independently (a healthy manifest ≠ a usable backend):
9
+ // manifestState — health of the bridge SKILL: not-installed | unsupported-schema |
10
+ // invalid-manifest | foreign | stub | ok.
11
+ // readiness — cli + credentials + wrappers, probed for EVERY registry entry even when the
12
+ // skill is absent, so we can say "the CLI is installed but the bridge skill isn't".
13
+ //
14
+ // Source of truth is the in-tool KNOWN_BACKENDS registry (Option B / AD-008): a missing bridge has
15
+ // no manifest on disk and no setup/README in the kit tarball, so the per-backend facts (bin,
16
+ // credential marker, stable setup URL) must live here. A drift-guard test keeps the registry in
17
+ // lockstep with the in-repo manifests.
18
+ //
19
+ // Pure, dependency-injectable (fs/env/validator are deps), dependency-free, Node >= 18. Every fs
20
+ // probe is wrapped → an explicit `unknown` + reason, never a throw and never a nameless failure.
21
+
22
+ import { existsSync, statSync, accessSync, realpathSync, readFileSync, constants } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+ import os from 'node:os';
26
+ import { validateManifest, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
27
+
28
+ // Probe states. `unknown` (a wrapped fs error) NEVER counts as present in any readiness rule.
29
+ const PRESENT = 'present';
30
+ const MISSING = 'missing';
31
+ const UNKNOWN = 'unknown';
32
+
33
+ // manifestState values.
34
+ const NOT_INSTALLED = 'not-installed';
35
+ const UNSUPPORTED_SCHEMA = 'unsupported-schema';
36
+ const INVALID_MANIFEST = 'invalid-manifest';
37
+ const STUB = 'stub';
38
+ const FOREIGN = 'foreign';
39
+ const OK = 'ok';
40
+
41
+ // readiness values.
42
+ const READY = 'ready';
43
+ const NEEDS_SKILL = 'needs-skill';
44
+ const NEEDS_CLI = 'needs-cli';
45
+ const NEEDS_CREDENTIALS = 'needs-credentials';
46
+ const DEGRADED = 'degraded';
47
+
48
+ const EXPECTED_KIND = 'execution-backend';
49
+
50
+ // The kit-owned registry: the per-backend facts the detector needs even when a bridge is NOT
51
+ // installed (no manifest on disk to read). Kept in lockstep with the in-repo manifests by the
52
+ // drift-guard test. `credential.env: null` → no env override exists (do not invent one).
53
+ export const KNOWN_BACKENDS = [
54
+ {
55
+ name: 'codex-cli-bridge',
56
+ installed: { env: 'CODEX_CLI_BRIDGE_DIR', default: '~/.claude/skills/codex-cli-bridge', file: 'SKILL.md' },
57
+ bin: 'codex',
58
+ credential: { env: 'CODEX_HOME', default: '~/.codex', file: 'auth.json' },
59
+ setupUrl: 'https://github.com/sabaiway/agent-workflow/blob/main/codex-cli-bridge/setup/README.md',
60
+ setupPathLocal: 'setup/README.md',
61
+ },
62
+ {
63
+ name: 'antigravity-cli-bridge',
64
+ installed: { env: 'ANTIGRAVITY_CLI_BRIDGE_DIR', default: '~/.claude/skills/antigravity-cli-bridge', file: 'SKILL.md' },
65
+ bin: 'agy',
66
+ credential: { env: null, default: '~/.gemini/antigravity-cli', file: 'antigravity-oauth-token' },
67
+ setupUrl: 'https://github.com/sabaiway/agent-workflow/blob/main/antigravity-cli-bridge/setup/README.md',
68
+ setupPathLocal: 'setup/README.md',
69
+ },
70
+ ];
71
+
72
+ // ── pure helpers ─────────────────────────────────────────────────────────────
73
+
74
+ // Expand a leading "~" / "~/x" against home; absolute and relative paths pass through untouched.
75
+ export const expandTilde = (p, home = os.homedir()) => {
76
+ if (p === '~') return home;
77
+ if (p.startsWith('~/')) return join(home, p.slice(2));
78
+ return p;
79
+ };
80
+
81
+ // Resolve a {env, default} dir spec: a non-empty env var wins as-is, else the (tilde-expanded)
82
+ // default. Same resolver for the skill dir AND the credential dir.
83
+ export const resolveDir = ({ env, default: dflt }, getenv = process.env, home = os.homedir()) => {
84
+ const fromEnv = env ? getenv[env] : undefined;
85
+ if (typeof fromEnv === 'string' && fromEnv.length > 0) return fromEnv;
86
+ return expandTilde(dflt, home);
87
+ };
88
+
89
+ const defaultAccessX = (p) => accessSync(p, constants.X_OK);
90
+ const defaultRealpath = (p) => realpathSync(p);
91
+
92
+ // FS-only PATH scan — never a subprocess/shell. POSIX → one candidate per dir, checked with
93
+ // accessSync(file, X_OK); Windows → bin+ext for each PATHEXT entry. A symlinked binary still passes
94
+ // X_OK (access follows symlinks) and is reported at its realpath. ENOENT → keep scanning; any other
95
+ // fs error (e.g. EACCES) means we cannot confirm → `unknown`.
96
+ export const findOnPath = (bin, deps = {}) => {
97
+ const getenv = deps.getenv ?? process.env;
98
+ const platform = deps.platform ?? process.platform;
99
+ const access = deps.access ?? defaultAccessX;
100
+ const realpath = deps.realpath ?? defaultRealpath;
101
+ const isWin = platform === 'win32';
102
+ const rawPath = (isWin ? getenv.PATH ?? getenv.Path : getenv.PATH) ?? '';
103
+ const dirs = rawPath.split(isWin ? ';' : ':').filter(Boolean);
104
+ const exts = isWin ? (getenv.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) : [''];
105
+ let sawUnknown = false;
106
+ for (const dir of dirs) {
107
+ for (const ext of exts) {
108
+ const candidate = join(dir, bin + ext);
109
+ try {
110
+ access(candidate);
111
+ let resolved = candidate;
112
+ try {
113
+ resolved = realpath(candidate);
114
+ } catch {
115
+ // realpath failed (race / broken symlink) — keep the candidate path, still present.
116
+ }
117
+ return { bin, state: PRESENT, path: resolved };
118
+ } catch (err) {
119
+ if (err && err.code === 'ENOENT') continue;
120
+ sawUnknown = true; // EACCES or other → cannot confirm absence
121
+ }
122
+ }
123
+ }
124
+ return { bin, state: sawUnknown ? UNKNOWN : MISSING, path: null };
125
+ };
126
+
127
+ // Wrapped file-existence probe: present (a regular file) | missing (absent or not a file) |
128
+ // unknown (a non-ENOENT fs error). Never reads contents.
129
+ const probeFile = (file, deps = {}) => {
130
+ const exists = deps.exists ?? existsSync;
131
+ const stat = deps.stat ?? statSync;
132
+ try {
133
+ if (!exists(file)) return MISSING;
134
+ return stat(file).isFile() ? PRESENT : MISSING;
135
+ } catch (err) {
136
+ return err && err.code === 'ENOENT' ? MISSING : UNKNOWN;
137
+ }
138
+ };
139
+
140
+ // "authed?" = existence of the credential-marker file (read-only). NEVER runs the subscription CLI.
141
+ // Report wording is "credentials present/missing/unknown", never "authenticated".
142
+ export const probeCredential = (entry, deps = {}) => {
143
+ const dir = resolveDir(
144
+ { env: entry.credential.env, default: entry.credential.default },
145
+ deps.getenv ?? process.env,
146
+ deps.home ?? os.homedir(),
147
+ );
148
+ const file = join(dir, entry.credential.file);
149
+ return { state: probeFile(file, deps), path: file };
150
+ };
151
+
152
+ const defaultReadManifest = (skillDir, deps = {}) => {
153
+ const read = deps.readFile ?? readFileSync;
154
+ try {
155
+ return JSON.parse(read(join(skillDir, 'capability.json'), 'utf8'));
156
+ } catch {
157
+ return null;
158
+ }
159
+ };
160
+
161
+ // The bridge's PATH wrapper names = the deduped `roles[].cmd` set (codex's review + execute roles
162
+ // are two cmds; antigravity's review + probe roles share one `agy-run`).
163
+ const wrapperCmds = (manifest) => {
164
+ const roles = manifest && typeof manifest.roles === 'object' && !Array.isArray(manifest.roles) ? manifest.roles : {};
165
+ const seen = new Set();
166
+ const out = [];
167
+ for (const role of Object.values(roles)) {
168
+ const cmd = role && typeof role.cmd === 'string' ? role.cmd : null;
169
+ if (cmd && !seen.has(cmd)) {
170
+ seen.add(cmd);
171
+ out.push(cmd);
172
+ }
173
+ }
174
+ return out;
175
+ };
176
+
177
+ const computeReadiness = (manifestState, cli, credentials, wrappers) => {
178
+ if (manifestState !== OK) return NEEDS_SKILL;
179
+ if (cli.state !== PRESENT) return NEEDS_CLI;
180
+ if (credentials.state !== PRESENT) return NEEDS_CREDENTIALS;
181
+ if (wrappers.every((w) => w.state === PRESENT)) return READY;
182
+ return DEGRADED;
183
+ };
184
+
185
+ // ── core ─────────────────────────────────────────────────────────────────────
186
+
187
+ // Detect one backend → the data-model object (manifestState + decoupled readiness signals).
188
+ // manifestState precedence: not-installed → (validate) unsupported-schema → invalid-manifest →
189
+ // stub (available:false) → foreign (wrong kind/name) → ok.
190
+ export const detectBackend = (entry, deps = {}) => {
191
+ const validate = deps.validate ?? validateManifest;
192
+ const getenv = deps.getenv ?? process.env;
193
+ const home = deps.home ?? os.homedir();
194
+ const probeCliFn = deps.probeCli ?? ((bin) => findOnPath(bin, deps));
195
+ const probeWrapperFn =
196
+ deps.probeWrapper ??
197
+ ((cmd) => {
198
+ const r = findOnPath(cmd, deps);
199
+ return { name: cmd, state: r.state };
200
+ });
201
+ const probeCredentialsFn = deps.probeCredentials ?? ((e) => probeCredential(e, deps));
202
+ const readManifest = deps.readManifest ?? ((dir) => defaultReadManifest(dir, deps));
203
+
204
+ const resolvedDir = resolveDir({ env: entry.installed.env, default: entry.installed.default }, getenv, home);
205
+ const markerPresent = probeFile(join(resolvedDir, entry.installed.file), deps) === PRESENT;
206
+
207
+ let manifestState;
208
+ let manifestReason;
209
+ let isOk = false;
210
+ if (!markerPresent) {
211
+ manifestState = NOT_INSTALLED;
212
+ manifestReason = `bridge skill not installed — ${entry.installed.file} not found in ${resolvedDir}`;
213
+ } else {
214
+ const report = validate(resolvedDir);
215
+ if (report.result === UNSUPPORTED) {
216
+ manifestState = UNSUPPORTED_SCHEMA;
217
+ manifestReason = `manifest schema unsupported — ${report.errors?.[0] ?? 'unknown schema'}`;
218
+ } else if (report.result === INVALID) {
219
+ manifestState = INVALID_MANIFEST;
220
+ manifestReason = `manifest invalid — ${report.errors?.[0] ?? 'failed validation'}`;
221
+ } else if (report.available === false) {
222
+ manifestState = STUB;
223
+ manifestReason = 'manifest declares available:false (stub, not a usable backend)';
224
+ } else if (report.kind !== EXPECTED_KIND || report.name !== entry.name) {
225
+ manifestState = FOREIGN;
226
+ manifestReason = `manifest is ${report.kind ?? '?'}/${report.name ?? '?'}, expected ${EXPECTED_KIND}/${entry.name}`;
227
+ } else {
228
+ manifestState = OK;
229
+ manifestReason = 'bridge skill installed and manifest valid';
230
+ isOk = true;
231
+ }
232
+ }
233
+
234
+ const cliProbe = probeCliFn(entry.bin);
235
+ const credentials = probeCredentialsFn(entry);
236
+ const wrappers = isOk ? wrapperCmds(readManifest(resolvedDir)).map(probeWrapperFn) : [];
237
+ const readiness = computeReadiness(manifestState, cliProbe, credentials, wrappers);
238
+
239
+ const installed = manifestState !== NOT_INSTALLED;
240
+ const localPresent = installed && probeFile(join(resolvedDir, entry.setupPathLocal), deps) === PRESENT;
241
+ const setupHint = localPresent
242
+ ? { local: entry.setupPathLocal, url: entry.setupUrl }
243
+ : { url: entry.setupUrl };
244
+
245
+ return {
246
+ name: entry.name,
247
+ manifestState,
248
+ manifestReason,
249
+ skillDir: installed ? resolvedDir : null,
250
+ cli: { bin: entry.bin, state: cliProbe.state, path: cliProbe.path ?? null },
251
+ credentials: { state: credentials.state, path: credentials.path },
252
+ wrappers,
253
+ readiness,
254
+ setupHint,
255
+ };
256
+ };
257
+
258
+ export const detectBackends = (deps = {}) => KNOWN_BACKENDS.map((entry) => detectBackend(entry, deps));
259
+
260
+ // ── report ───────────────────────────────────────────────────────────────────
261
+
262
+ const MARK = { [PRESENT]: '✓', [MISSING]: '✗', [UNKNOWN]: '?' };
263
+ const mark = (state) => MARK[state] ?? '?';
264
+
265
+ const setupTarget = (s) => s.setupHint.local ?? s.setupHint.url;
266
+
267
+ // Next-step hint per readiness. Deliberately never says "authenticated"/"authed" — only
268
+ // "credentials present/missing" (detection is file-presence, not a live login check).
269
+ const nextStep = (s) => {
270
+ switch (s.readiness) {
271
+ case READY:
272
+ return null;
273
+ case NEEDS_SKILL:
274
+ return `install the bridge skill — ${setupTarget(s)}`;
275
+ case NEEDS_CLI:
276
+ return `install or locate the "${s.cli.bin}" CLI on PATH`;
277
+ case NEEDS_CREDENTIALS:
278
+ return `set up credentials for "${s.cli.bin}" (marker file ${s.credentials.path} not present)`;
279
+ case DEGRADED:
280
+ return `bridge wrapper(s) not on PATH: ${s.wrappers.filter((w) => w.state !== PRESENT).map((w) => w.name).join(', ')}`;
281
+ default:
282
+ return null;
283
+ }
284
+ };
285
+
286
+ const fmtWrappers = (ws) =>
287
+ ws.length ? `wrappers ${ws.filter((w) => w.state === PRESENT).length}/${ws.length}` : 'wrappers —';
288
+
289
+ export const formatReport = (statuses) => {
290
+ const lines = ['agent-workflow execution backends (detection only — no subscription CLI is run)', ''];
291
+ for (const s of statuses) {
292
+ lines.push(
293
+ ` ${s.name} [${s.manifestState}] ` +
294
+ `cli ${s.cli.bin} ${mark(s.cli.state)} ` +
295
+ `credentials ${mark(s.credentials.state)} ` +
296
+ `${fmtWrappers(s.wrappers)} → ${s.readiness}`,
297
+ );
298
+ const hint = nextStep(s);
299
+ if (hint) lines.push(` ↳ ${hint}`);
300
+ }
301
+ return lines.join('\n');
302
+ };
303
+
304
+ const main = (_argv, deps = {}) => {
305
+ console.log(formatReport(detectBackends(deps)));
306
+ process.exit(0); // informational, like validate.mjs non-strict — never blocks anything
307
+ };
308
+
309
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
310
+ if (isDirectRun) main(process.argv.slice(2));