@sabaiway/agent-workflow-kit 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +3 -2
- package/SKILL.md +15 -4
- package/capability.json +1 -1
- package/package.json +1 -1
- package/tools/detect-backends.mjs +310 -0
- package/tools/detect-backends.test.mjs +342 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,47 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 1.5.1 — README hero fix (docs)
|
|
8
|
+
|
|
9
|
+
Docs-only patch. The hero showed a hardcoded `v1.4.0` chip while the kit was 1.5.0; the chip is
|
|
10
|
+
removed (the shields.io npm-version badge already shows the live version). A repo test
|
|
11
|
+
(`test/readme-no-stale-version.test.mjs`, dev-only — not shipped) now asserts no published README
|
|
12
|
+
hero carries a pinned `vX.Y.Z` chip, so the drift can't recur. No code, schema, or deployed-payload
|
|
13
|
+
change; the deployment-lineage head stays `1.3.0` (no migration).
|
|
14
|
+
|
|
15
|
+
## 1.5.0 — Backend detection (detect + guide)
|
|
16
|
+
|
|
17
|
+
The kit's onboarding can now **see the optional execution-backends** — the thin bridges to
|
|
18
|
+
subscription CLIs (`codex-cli-bridge` → `codex`, `antigravity-cli-bridge` → `agy`) — instead of
|
|
19
|
+
being blind to everything but the memory substrate. **Additive and read-only**: no `capability.json`
|
|
20
|
+
schema change, no validator change, no auto-install. Since nothing in the deployed `docs/ai/`
|
|
21
|
+
structure changes, **no migration is needed** and the deployment-lineage head stays `1.3.0`
|
|
22
|
+
(`upgrade` reconciles and re-stamps with nothing to apply).
|
|
23
|
+
|
|
24
|
+
- **`tools/detect-backends.mjs` — the read-only detector.** Pure, dependency-injectable,
|
|
25
|
+
dependency-free (Node ≥ 18), and already shipped (it lives under `tools/`, which is in the
|
|
26
|
+
package `files` + the installer `PAYLOAD`). It reports two **decoupled** axes so a healthy
|
|
27
|
+
manifest is never confused with a usable backend: `manifestState` (health of the bridge *skill*:
|
|
28
|
+
`not-installed | unsupported-schema | invalid-manifest | foreign | stub | ok`) and the readiness
|
|
29
|
+
signals `cli` / `credentials` / `wrappers`, probed **independently** for every registry entry even
|
|
30
|
+
when the skill is absent — so "the `codex` CLI is installed and signed in, but the bridge skill
|
|
31
|
+
isn't" reads as `needs-skill`, with the setup pointer. Every fs probe is wrapped → an explicit
|
|
32
|
+
`unknown` + reason, never a throw and never a nameless failure.
|
|
33
|
+
- **Detection is read-only — it never runs a subscription CLI.** "credentials present/missing" is
|
|
34
|
+
the existence of the credential-marker **file**, never a live `codex login status` / `agy` check
|
|
35
|
+
(which would spawn a paid, slow, networked CLI). The report deliberately never says
|
|
36
|
+
"authenticated" (a unit test asserts the word's absence).
|
|
37
|
+
- **Kit-owned registry (`KNOWN_BACKENDS`), not a schema change.** A missing bridge has no manifest
|
|
38
|
+
on disk and no `setup/README.md` in the kit tarball, so the per-backend facts (`bin`, credential
|
|
39
|
+
marker, stable setup URL) must live in the detector. A **drift-guard** test keeps the registry in
|
|
40
|
+
lockstep with the in-repo manifests (set equality with every `kind:execution-backend` dir, unique
|
|
41
|
+
names, `detect.installed` match, `setup/README.md` exists).
|
|
42
|
+
- **Two surfaces.** A new **`/agent-workflow-kit backends`** mode presents the table and, for any
|
|
43
|
+
backend that is not `ready`, points to its setup (local `setup/README.md` when installed, else the
|
|
44
|
+
setup URL). Bootstrap **step 11** also prints a one-line backends summary — read-only, and it
|
|
45
|
+
**never blocks the commit gate**. Honest about Windows: detection works, but the bridges' POSIX
|
|
46
|
+
`.sh` wrappers are not promised to run there.
|
|
47
|
+
|
|
7
48
|
## 1.4.0 — Delegation-aware composition root (agent-workflow family, Plan 1)
|
|
8
49
|
|
|
9
50
|
The kit becomes the **composition root** of the new `agent-workflow` family. **Additive** — the
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ instead of re-reading your whole repo.*
|
|
|
12
12
|
[](./LICENSE)
|
|
13
13
|
[](https://nodejs.org)
|
|
14
14
|
|
|
15
|
-
`
|
|
15
|
+
`Node ≥ 18` · `dependency-free` · `kernel-only`
|
|
16
16
|
|
|
17
17
|
**Works with any tool that reads `AGENTS.md`** — Claude Code · Codex · Cursor · Devin Desktop (formerly Windsurf) · GitHub Copilot · Gemini CLI · Cline · Aider · and 20+ more.
|
|
18
18
|
|
|
@@ -181,6 +181,7 @@ command is printed).
|
|
|
181
181
|
|---------|------|--------------|
|
|
182
182
|
| `/agent-workflow-kit` | new / empty project | recon → **asks visible-or-hidden** + **conversational language** + **agent attribution** (default off) → deploys `AGENTS.md` + `docs/ai/` filled with real recon data → installs enforcement → **asks before committing** |
|
|
183
183
|
| `/agent-workflow-kit upgrade` | existing deployment | reads `docs/ai/.workflow-version`, shows the changelog diff, applies migrations, re-stamps |
|
|
184
|
+
| `/agent-workflow-kit backends` | any time | **read-only** check of the optional execution-backends (the `codex` / `agy` bridges): what's set up vs missing and the next step. Never writes, never commits, never runs a subscription CLI (credentials = marker-file presence, not a live login). |
|
|
184
185
|
|
|
185
186
|
It **never auto-commits** and **never overwrites** an existing `AGENTS.md` without asking.
|
|
186
187
|
|
|
@@ -223,7 +224,7 @@ agent-workflow-kit/
|
|
|
223
224
|
├── scripts/ ← caps / archive / index + tests
|
|
224
225
|
├── contracts.md ← visibility / language / attribution rules
|
|
225
226
|
└── planning.md ← plan lifecycle + continuity
|
|
226
|
-
├── tools/ ← family tooling: manifest schema + validator, methodology-slot injection
|
|
227
|
+
├── tools/ ← family tooling: manifest schema + validator, methodology-slot injection, backend detector (detect-backends)
|
|
227
228
|
├── launchers/ ← Codex / Devin Desktop / Cursor entries
|
|
228
229
|
└── migrations/ ← per-version upgrade steps
|
|
229
230
|
```
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '1.
|
|
6
|
+
version: '1.5.1'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
|
@@ -72,12 +72,13 @@ ever deleted.
|
|
|
72
72
|
|
|
73
73
|
---
|
|
74
74
|
|
|
75
|
-
##
|
|
75
|
+
## Modes
|
|
76
76
|
|
|
77
77
|
Pick the mode from the user's invocation. Auto-detect an existing `docs/ai/` to guard against bootstrapping over a live system, but the user makes the final call.
|
|
78
78
|
|
|
79
79
|
- **`/agent-workflow-kit`** (default) — bootstrap a new or empty project. If `docs/ai/` already exists, stop and ask whether they meant `upgrade`.
|
|
80
80
|
- **`/agent-workflow-kit upgrade`** — upgrade an existing deployment to the skill's current `version`.
|
|
81
|
+
- **`/agent-workflow-kit backends`** — read-only environment check: which optional **execution-backends** (the `codex` / `agy` bridges) are set up vs missing. Never writes, never commits, never runs a subscription CLI.
|
|
81
82
|
|
|
82
83
|
### Mode: bootstrap
|
|
83
84
|
|
|
@@ -106,7 +107,7 @@ Pick the mode from the user's invocation. Auto-detect an existing `docs/ai/` to
|
|
|
106
107
|
independent axes: a packaging-only release bumps the package but leaves the lineage head until a
|
|
107
108
|
migration actually changes the deployed `docs/ai` structure. A stamp greater than the head →
|
|
108
109
|
STOP (never downgrade).
|
|
109
|
-
11. **Report & ask.** Show `tree docs/ai/`, 2–3 lines on what was filled with real data vs left as TODO, then **ask before committing** — never auto-commit.
|
|
110
|
+
11. **Report & ask.** Show `tree docs/ai/`, 2–3 lines on what was filled with real data vs left as TODO, then run the **backend detector** (`node ${CLAUDE_SKILL_DIR}/tools/detect-backends.mjs`) and print a one-line summary of the optional execution-backends (e.g. `backends: codex ✓ ready · antigravity ✗ needs-credentials — run /agent-workflow-kit backends`). This is **read-only and never blocks the commit gate**. Then **ask before committing** — never auto-commit.
|
|
110
111
|
|
|
111
112
|
Fill strategy:
|
|
112
113
|
|
|
@@ -127,6 +128,16 @@ Fill strategy:
|
|
|
127
128
|
5. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. If `AGENTS.md` has no *Communication language* block (pre-1.1.0 deployment), **ask the user their conversational language** and insert the block — see `migrations/1.1.0-communication-language.md`. If it has no *Attribution* block (pre-1.2.0 deployment), **ask whether the agent may attribute work to itself / AI** and insert the block (defaulting to `off`) — see `migrations/1.2.0-agent-attribution.md`.
|
|
128
129
|
6. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`1.3.0`, not the package version). Report changes; **ask before committing**.
|
|
129
130
|
|
|
131
|
+
### Mode: backends
|
|
132
|
+
|
|
133
|
+
Read-only. Answers *"which optional execution-backends are set up vs missing, and what's the next step?"* — for the family's subscription-CLI bridges (`codex-cli-bridge` → `codex`, `antigravity-cli-bridge` → `agy`). It **never writes, never commits, and never runs a subscription CLI**.
|
|
134
|
+
|
|
135
|
+
1. Run `node ${CLAUDE_SKILL_DIR}/tools/detect-backends.mjs` and present its table verbatim. Each row reports two **decoupled** axes: `manifestState` (health of the bridge *skill* — `not-installed | unsupported-schema | invalid-manifest | foreign | stub | ok`) and the readiness signals `cli` / `credentials` / `wrappers`, probed independently — so a CLI that is installed and signed in but whose bridge *skill* is absent reads `needs-skill`, not "missing".
|
|
136
|
+
2. For any backend that is not `ready`, point to its setup: the local `setup/README.md` when the bridge is installed, otherwise the backend's setup URL (both are in the report).
|
|
137
|
+
3. State plainly to the user that this is **detection only**:
|
|
138
|
+
- **"credentials present"** means the credential-marker **file** exists — it is **not** a live login check. The detector never runs `codex login status` / `agy` (that would spawn a paid, slow, networked subscription CLI).
|
|
139
|
+
- The bridges' wrappers are **POSIX `.sh`** scripts. On Windows the detector still works, but the bridges themselves are **not promised to run** — say so rather than implying they will.
|
|
140
|
+
|
|
130
141
|
---
|
|
131
142
|
|
|
132
143
|
## Gotchas
|
|
@@ -196,6 +207,6 @@ Deploy these into `AGENTS.md`; remove rows that don't apply to the stack.
|
|
|
196
207
|
- [`references/scripts/`](references/scripts/) — the Node enforcement scripts (caps + staleness + index-freshness gate, 3-tier archive, hook installer) and their unit tests.
|
|
197
208
|
- [`migrations/`](migrations/) — per-version upgrade steps; see `migrations/README.md`.
|
|
198
209
|
- [`launchers/`](launchers/) — run the bootstrapper from non-Claude agents (`SKILL.md` is a native Codex skill; a Devin Desktop workflow launcher + install script). See `launchers/README.md`.
|
|
199
|
-
- [`tools/`](tools/) — the family-wide tooling the kit **owns and ships**: `manifest/{schema.md,validate.mjs}` (the `capability.json` schema + the validator the kit runs as the memory detector, and root CI invokes), `delegation.mjs` (the executable delegate/fallback decision + hand-off plan), `inject-methodology.mjs` + `methodology-slot.md` (the bounded slot injection), and `release-scan.mjs` (the attribution-off release gate). See [`tools/manifest/schema.md`](tools/manifest/schema.md).
|
|
210
|
+
- [`tools/`](tools/) — the family-wide tooling the kit **owns and ships**: `manifest/{schema.md,validate.mjs}` (the `capability.json` schema + the validator the kit runs as the memory detector, and root CI invokes), `delegation.mjs` (the executable delegate/fallback decision + hand-off plan), `inject-methodology.mjs` + `methodology-slot.md` (the bounded slot injection), `detect-backends.mjs` (the read-only **backend detector** behind `/agent-workflow-kit backends`), and `release-scan.mjs` (the attribution-off release gate). See [`tools/manifest/schema.md`](tools/manifest/schema.md).
|
|
200
211
|
- [`capability.json`](capability.json) — the kit's own `agent-workflow` family manifest (`kind: composition-root`).
|
|
201
212
|
- [`CHANGELOG.md`](CHANGELOG.md) — version history of this kernel.
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -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));
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { existsSync, mkdtempSync, writeFileSync, symlinkSync, chmodSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import {
|
|
8
|
+
expandTilde,
|
|
9
|
+
resolveDir,
|
|
10
|
+
findOnPath,
|
|
11
|
+
probeCredential,
|
|
12
|
+
detectBackend,
|
|
13
|
+
detectBackends,
|
|
14
|
+
formatReport,
|
|
15
|
+
KNOWN_BACKENDS,
|
|
16
|
+
} from './detect-backends.mjs';
|
|
17
|
+
|
|
18
|
+
const REPO = fileURLToPath(new URL('../../', import.meta.url)); // …/agent-workflow-kit/tools/ → repo root
|
|
19
|
+
const KIT = join(REPO, 'agent-workflow-kit');
|
|
20
|
+
const FIX = join(KIT, 'tools', 'manifest', 'fixtures');
|
|
21
|
+
const HOME = '/home/u';
|
|
22
|
+
|
|
23
|
+
// An ENOENT-typed error, matching the shape Node's fs throws (used to drive the wrapped probes).
|
|
24
|
+
const enoent = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
25
|
+
const eacces = () => Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
|
26
|
+
|
|
27
|
+
// ── pure helpers ─────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe('expandTilde', () => {
|
|
30
|
+
it('"~" → home', () => assert.equal(expandTilde('~', HOME), HOME));
|
|
31
|
+
it('"~/x" → home/x', () => assert.equal(expandTilde('~/x/y', HOME), join(HOME, 'x/y')));
|
|
32
|
+
it('absolute path untouched', () => assert.equal(expandTilde('/abs/path', HOME), '/abs/path'));
|
|
33
|
+
it('relative path untouched', () => assert.equal(expandTilde('rel/path', HOME), 'rel/path'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('resolveDir', () => {
|
|
37
|
+
it('non-empty env wins, as-is', () => {
|
|
38
|
+
assert.equal(resolveDir({ env: 'D', default: '~/d' }, { D: '/from/env' }, HOME), '/from/env');
|
|
39
|
+
});
|
|
40
|
+
it('empty-string env → default', () => {
|
|
41
|
+
assert.equal(resolveDir({ env: 'D', default: '/d' }, { D: '' }, HOME), '/d');
|
|
42
|
+
});
|
|
43
|
+
it('null env name → default', () => {
|
|
44
|
+
assert.equal(resolveDir({ env: null, default: '/d' }, {}, HOME), '/d');
|
|
45
|
+
});
|
|
46
|
+
it('default is tilde-expanded', () => {
|
|
47
|
+
assert.equal(resolveDir({ env: 'D', default: '~/skills/x' }, {}, HOME), join(HOME, 'skills/x'));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('findOnPath', () => {
|
|
52
|
+
const linux = { platform: 'linux', getenv: { PATH: '/a:/b' }, realpath: (p) => p };
|
|
53
|
+
|
|
54
|
+
it('present via posix exec-bit', () => {
|
|
55
|
+
const r = findOnPath('codex', { ...linux, access: (p) => { if (p !== '/a/codex') throw enoent(); } });
|
|
56
|
+
assert.equal(r.state, 'present');
|
|
57
|
+
assert.equal(r.path, '/a/codex');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('missing when absent everywhere', () => {
|
|
61
|
+
const r = findOnPath('codex', { ...linux, access: () => { throw enoent(); } });
|
|
62
|
+
assert.equal(r.state, 'missing');
|
|
63
|
+
assert.equal(r.path, null);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('found in the 2nd PATH dir', () => {
|
|
67
|
+
const r = findOnPath('agy', { ...linux, access: (p) => { if (p !== '/b/agy') throw enoent(); } });
|
|
68
|
+
assert.equal(r.state, 'present');
|
|
69
|
+
assert.equal(r.path, '/b/agy');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('Windows: matches via PATHEXT', () => {
|
|
73
|
+
const r = findOnPath('codex', {
|
|
74
|
+
platform: 'win32',
|
|
75
|
+
getenv: { PATH: 'C:\\bin', PATHEXT: '.COM;.EXE;.CMD' },
|
|
76
|
+
realpath: (p) => p,
|
|
77
|
+
access: (p) => { if (!p.endsWith('codex.EXE')) throw enoent(); },
|
|
78
|
+
});
|
|
79
|
+
assert.equal(r.state, 'present');
|
|
80
|
+
assert.ok(r.path.endsWith('codex.EXE'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('resolves a symlinked binary to its realpath (real fs)', () => {
|
|
84
|
+
const dir = mkdtempSync(join(tmpdir(), 'awf-path-'));
|
|
85
|
+
const target = join(dir, 'real-tool');
|
|
86
|
+
const link = join(dir, 'linked-tool');
|
|
87
|
+
writeFileSync(target, '#!/bin/sh\n');
|
|
88
|
+
chmodSync(target, 0o755);
|
|
89
|
+
symlinkSync(target, link);
|
|
90
|
+
const r = findOnPath('linked-tool', { platform: 'linux', getenv: { PATH: dir } });
|
|
91
|
+
assert.equal(r.state, 'present');
|
|
92
|
+
assert.equal(r.path, target); // realpath followed the symlink
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('accessSync throwing EACCES → unknown (cannot confirm)', () => {
|
|
96
|
+
const r = findOnPath('codex', { ...linux, access: () => { throw eacces(); } });
|
|
97
|
+
assert.equal(r.state, 'unknown');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('probeCredential', () => {
|
|
102
|
+
const entry = (over = {}) => ({ credential: { env: 'CRED', default: '~/.codex', file: 'auth.json', ...over } });
|
|
103
|
+
|
|
104
|
+
it('present when the marker file is a real file (env override honoured)', () => {
|
|
105
|
+
const r = probeCredential(entry(), { getenv: { CRED: '/cdir' }, home: HOME, exists: () => true, stat: () => ({ isFile: () => true }) });
|
|
106
|
+
assert.equal(r.state, 'present');
|
|
107
|
+
assert.equal(r.path, '/cdir/auth.json');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('absent → missing', () => {
|
|
111
|
+
const r = probeCredential(entry(), { getenv: {}, home: HOME, exists: () => false });
|
|
112
|
+
assert.equal(r.state, 'missing');
|
|
113
|
+
assert.equal(r.path, join(HOME, '.codex/auth.json')); // env unset → tilde default
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('statSync throwing a non-ENOENT error → unknown', () => {
|
|
117
|
+
const r = probeCredential(entry(), { getenv: {}, home: HOME, exists: () => true, stat: () => { throw eacces(); } });
|
|
118
|
+
assert.equal(r.state, 'unknown');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('credential.env:null uses the (tilde-expanded) default', () => {
|
|
122
|
+
const r = probeCredential(entry({ env: null, default: '~/.gemini/antigravity-cli', file: 'tok' }), {
|
|
123
|
+
getenv: {}, home: HOME, exists: () => true, stat: () => ({ isFile: () => true }),
|
|
124
|
+
});
|
|
125
|
+
assert.equal(r.state, 'present');
|
|
126
|
+
assert.equal(r.path, join(HOME, '.gemini/antigravity-cli/tok'));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('never counts a directory as a present credential', () => {
|
|
130
|
+
const r = probeCredential(entry(), { getenv: { CRED: '/cdir' }, home: HOME, exists: () => true, stat: () => ({ isFile: () => false }) });
|
|
131
|
+
assert.equal(r.state, 'missing');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── detectBackend: manifestState precedence ──────────────────────────────────
|
|
136
|
+
|
|
137
|
+
// Build an entry whose installed-marker is `capability.json` so a fixture without a SKILL.md still
|
|
138
|
+
// registers as "installed", letting the validator branch decide the state.
|
|
139
|
+
const entryAt = (dir, over = {}) => ({
|
|
140
|
+
name: 'codex-cli-bridge',
|
|
141
|
+
installed: { env: 'X_DIR', default: dir, file: 'capability.json' },
|
|
142
|
+
bin: 'codex',
|
|
143
|
+
credential: { env: null, default: '/no/such/dir', file: 'auth.json' },
|
|
144
|
+
setupUrl: 'https://example.test/setup',
|
|
145
|
+
setupPathLocal: 'setup/README.md',
|
|
146
|
+
...over,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// All readiness inputs present, so manifestState is the only variable under test.
|
|
150
|
+
const allPresentDeps = {
|
|
151
|
+
getenv: {},
|
|
152
|
+
probeCli: () => ({ bin: 'codex', state: 'present', path: '/usr/bin/codex' }),
|
|
153
|
+
probeCredentials: () => ({ state: 'present', path: '/c/auth.json' }),
|
|
154
|
+
probeWrapper: (cmd) => ({ name: cmd, state: 'present' }),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
describe('detectBackend — manifestState precedence', () => {
|
|
158
|
+
it('not-installed when the marker file is absent', () => {
|
|
159
|
+
const empty = mkdtempSync(join(tmpdir(), 'awf-empty-'));
|
|
160
|
+
const d = detectBackend(entryAt(empty), allPresentDeps);
|
|
161
|
+
assert.equal(d.manifestState, 'not-installed');
|
|
162
|
+
assert.equal(d.skillDir, null);
|
|
163
|
+
assert.equal(d.readiness, 'needs-skill');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('unsupported-schema (fixtures/unknown-schema)', () => {
|
|
167
|
+
const d = detectBackend(entryAt(join(FIX, 'unknown-schema')), allPresentDeps);
|
|
168
|
+
assert.equal(d.manifestState, 'unsupported-schema');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('invalid-manifest (fixtures/malformed-json)', () => {
|
|
172
|
+
const d = detectBackend(entryAt(join(FIX, 'malformed-json')), allPresentDeps);
|
|
173
|
+
assert.equal(d.manifestState, 'invalid-manifest');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('stub (fixtures/stub — available:false)', () => {
|
|
177
|
+
const d = detectBackend(entryAt(join(FIX, 'stub')), allPresentDeps);
|
|
178
|
+
assert.equal(d.manifestState, 'stub');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('foreign (valid, but a memory-substrate, not this backend)', () => {
|
|
182
|
+
const d = detectBackend(entryAt(join(REPO, 'agent-workflow-memory')), allPresentDeps);
|
|
183
|
+
assert.equal(d.manifestState, 'foreign');
|
|
184
|
+
assert.match(d.manifestReason, /memory-substrate/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('ok (the real codex-cli-bridge dir, marker = SKILL.md)', () => {
|
|
188
|
+
const d = detectBackend(
|
|
189
|
+
entryAt(join(REPO, 'codex-cli-bridge'), { installed: { env: 'X_DIR', default: join(REPO, 'codex-cli-bridge'), file: 'SKILL.md' } }),
|
|
190
|
+
allPresentDeps,
|
|
191
|
+
);
|
|
192
|
+
assert.equal(d.manifestState, 'ok');
|
|
193
|
+
assert.deepEqual(d.wrappers.map((w) => w.name).sort(), ['codex-exec', 'codex-review']);
|
|
194
|
+
assert.equal(d.setupHint.local, 'setup/README.md'); // installed AND setup/README.md exists
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── detectBackend: readiness matrix (over an `ok` manifest) ───────────────────
|
|
199
|
+
|
|
200
|
+
const okEntry = entryAt(join(REPO, 'codex-cli-bridge'), {
|
|
201
|
+
installed: { env: 'X_DIR', default: join(REPO, 'codex-cli-bridge'), file: 'SKILL.md' },
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('detectBackend — readiness over an ok manifest', () => {
|
|
205
|
+
it('ready when cli + credentials + every wrapper present', () => {
|
|
206
|
+
assert.equal(detectBackend(okEntry, allPresentDeps).readiness, 'ready');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('needs-cli when the CLI is missing', () => {
|
|
210
|
+
const d = detectBackend(okEntry, { ...allPresentDeps, probeCli: () => ({ bin: 'codex', state: 'missing', path: null }) });
|
|
211
|
+
assert.equal(d.readiness, 'needs-cli');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('needs-cli when the CLI is unknown (unknown never counts as present)', () => {
|
|
215
|
+
const d = detectBackend(okEntry, { ...allPresentDeps, probeCli: () => ({ bin: 'codex', state: 'unknown', path: null }) });
|
|
216
|
+
assert.equal(d.readiness, 'needs-cli');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('needs-credentials when the credential marker is missing', () => {
|
|
220
|
+
const d = detectBackend(okEntry, { ...allPresentDeps, probeCredentials: () => ({ state: 'missing', path: '/c/auth.json' }) });
|
|
221
|
+
assert.equal(d.readiness, 'needs-credentials');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('degraded when a wrapper is missing from PATH', () => {
|
|
225
|
+
const d = detectBackend(okEntry, {
|
|
226
|
+
...allPresentDeps,
|
|
227
|
+
probeWrapper: (cmd) => ({ name: cmd, state: cmd === 'codex-review' ? 'missing' : 'present' }),
|
|
228
|
+
});
|
|
229
|
+
assert.equal(d.readiness, 'degraded');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('needs-skill dominates even when cli + credentials are present', () => {
|
|
233
|
+
const empty = mkdtempSync(join(tmpdir(), 'awf-empty2-'));
|
|
234
|
+
const d = detectBackend(entryAt(empty), allPresentDeps);
|
|
235
|
+
assert.equal(d.readiness, 'needs-skill');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ── registry drift guard ─────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
const readManifest = (dir) => JSON.parse(readFileSync(join(dir, 'capability.json'), 'utf8'));
|
|
242
|
+
|
|
243
|
+
// Every top-level in-repo dir with a kind:execution-backend manifest, discovered fresh from disk.
|
|
244
|
+
const inRepoBackends = () =>
|
|
245
|
+
readdirSync(REPO, { withFileTypes: true })
|
|
246
|
+
.filter((e) => e.isDirectory() && existsSync(join(REPO, e.name, 'capability.json')))
|
|
247
|
+
.filter((e) => readManifest(join(REPO, e.name)).kind === 'execution-backend')
|
|
248
|
+
.map((e) => e.name);
|
|
249
|
+
|
|
250
|
+
describe('KNOWN_BACKENDS — drift guard against the in-repo manifests', () => {
|
|
251
|
+
it('set equality: registry names == in-repo execution-backend dirs', () => {
|
|
252
|
+
const onDisk = inRepoBackends().sort();
|
|
253
|
+
const registry = KNOWN_BACKENDS.map((b) => b.name).sort();
|
|
254
|
+
assert.deepEqual(registry, onDisk);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('the methodology-engine is NOT counted as a backend', () => {
|
|
258
|
+
assert.ok(!KNOWN_BACKENDS.some((b) => b.name === 'agent-workflow-engine'));
|
|
259
|
+
assert.equal(readManifest(join(REPO, 'agent-workflow-engine')).kind, 'methodology-engine');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('registry names are unique', () => {
|
|
263
|
+
const names = KNOWN_BACKENDS.map((b) => b.name);
|
|
264
|
+
assert.equal(new Set(names).size, names.length);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('each entry.installed matches the real manifest detect.installed', () => {
|
|
268
|
+
for (const entry of KNOWN_BACKENDS) {
|
|
269
|
+
const real = readManifest(join(REPO, entry.name)).detect.installed;
|
|
270
|
+
assert.equal(entry.installed.env, real.env, `${entry.name} env`);
|
|
271
|
+
assert.equal(entry.installed.default, real.default, `${entry.name} default`);
|
|
272
|
+
assert.equal(entry.installed.file, real.file, `${entry.name} file`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('each entry.setupPathLocal exists in the repo', () => {
|
|
277
|
+
for (const entry of KNOWN_BACKENDS) {
|
|
278
|
+
assert.ok(existsSync(join(REPO, entry.name, entry.setupPathLocal)), `${entry.name}/${entry.setupPathLocal}`);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── formatReport ─────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
const readyStatus = {
|
|
286
|
+
name: 'codex-cli-bridge',
|
|
287
|
+
manifestState: 'ok',
|
|
288
|
+
manifestReason: 'ok',
|
|
289
|
+
skillDir: '/skills/codex-cli-bridge',
|
|
290
|
+
cli: { bin: 'codex', state: 'present', path: '/usr/bin/codex' },
|
|
291
|
+
credentials: { state: 'present', path: '/home/u/.codex/auth.json' },
|
|
292
|
+
wrappers: [{ name: 'codex-exec', state: 'present' }, { name: 'codex-review', state: 'present' }],
|
|
293
|
+
readiness: 'ready',
|
|
294
|
+
setupHint: { local: 'setup/README.md', url: 'https://example.test/codex' },
|
|
295
|
+
};
|
|
296
|
+
const needsSkillStatus = {
|
|
297
|
+
name: 'antigravity-cli-bridge',
|
|
298
|
+
manifestState: 'not-installed',
|
|
299
|
+
manifestReason: 'not installed',
|
|
300
|
+
skillDir: null,
|
|
301
|
+
cli: { bin: 'agy', state: 'present', path: '/home/u/.local/bin/agy' },
|
|
302
|
+
credentials: { state: 'present', path: '/home/u/.gemini/antigravity-cli/antigravity-oauth-token' },
|
|
303
|
+
wrappers: [],
|
|
304
|
+
readiness: 'needs-skill',
|
|
305
|
+
setupHint: { url: 'https://example.test/agy' },
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
describe('formatReport', () => {
|
|
309
|
+
const out = formatReport([readyStatus, needsSkillStatus]);
|
|
310
|
+
|
|
311
|
+
it('never says "authenticated" or "authed" (credentials = file presence, not a live login)', () => {
|
|
312
|
+
const low = out.toLowerCase();
|
|
313
|
+
assert.ok(!low.includes('authenticated'));
|
|
314
|
+
assert.ok(!low.includes('authed'));
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('uses the word "credentials"', () => assert.ok(out.toLowerCase().includes('credentials')));
|
|
318
|
+
|
|
319
|
+
it('shows the ready backend as ready', () => {
|
|
320
|
+
assert.match(out, /codex-cli-bridge.*ready/s);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('a needs-skill backend (CLI + creds present) says install the bridge + points at the setup URL', () => {
|
|
324
|
+
assert.match(out, /needs-skill/);
|
|
325
|
+
assert.match(out, /install the bridge/);
|
|
326
|
+
assert.match(out, /https:\/\/example\.test\/agy/);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('detectBackends — live shape on this machine', () => {
|
|
331
|
+
it('returns one status per registry entry with all data-model keys', () => {
|
|
332
|
+
const statuses = detectBackends();
|
|
333
|
+
assert.equal(statuses.length, KNOWN_BACKENDS.length);
|
|
334
|
+
for (const s of statuses) {
|
|
335
|
+
for (const key of ['name', 'manifestState', 'manifestReason', 'cli', 'credentials', 'wrappers', 'readiness', 'setupHint']) {
|
|
336
|
+
assert.ok(key in s, `${s.name} missing ${key}`);
|
|
337
|
+
}
|
|
338
|
+
assert.ok(['present', 'missing', 'unknown'].includes(s.cli.state));
|
|
339
|
+
assert.ok(['present', 'missing', 'unknown'].includes(s.credentials.state));
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|