jules-orchestrator-kit 0.52.8 → 0.53.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 +66 -7
- package/bin/agentctl.mjs +172 -1
- package/bin/init.js +87 -74
- package/index.mjs +10 -0
- package/package.json +5 -2
- package/scripts/jules-create.mjs +5 -16
- package/scripts/jules-patch.mjs +18 -10
- package/scripts/stale-base-check.mjs +1 -1
- package/src/ci-templates.mjs +199 -0
- package/src/config.mjs +39 -11
- package/src/engine.mjs +2 -21
- package/src/env-aliases.mjs +80 -0
- package/src/mcp.mjs +19 -1
- package/src/ops/doctor-registry.mjs +53 -32
- package/src/ops/next-step.mjs +38 -7
- package/src/profiles-io.mjs +77 -0
- package/src/profiles.mjs +188 -0
- package/src/provider-readiness.mjs +215 -0
- package/src/security.mjs +42 -1
- package/src/tui.mjs +14 -3
- package/src/wizard-init.mjs +32 -4
- package/src/ops/doctor-planner.mjs +0 -220
- package/src/ops/receipts.mjs +0 -171
- package/src/ops/swarm-actions.mjs +0 -156
- package/src/ops/task-actions.mjs +0 -214
- package/src/ops/transaction.mjs +0 -278
- package/src/ux/capabilities.mjs +0 -180
- package/src/ux/diff-viewer.mjs +0 -289
- package/src/ux/layout.mjs +0 -195
- package/src/ux/log-viewer.mjs +0 -110
- package/src/ux/palette.mjs +0 -179
- package/src/ux/queue-model.mjs +0 -206
- package/src/ux/renderer.mjs +0 -188
- package/src/ux/swarm-model.mjs +0 -146
- package/src/ux/terminal-session.mjs +0 -215
- package/src/ux/widgets.mjs +0 -323
- /package/src/{ux/key-decoder.mjs → key-decoder.mjs} +0 -0
package/src/ops/next-step.mjs
CHANGED
|
@@ -1,7 +1,32 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { isTaskFile } from "../engine.mjs";
|
|
5
|
+
import { probeProvider } from "../provider-readiness.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The provider named in `.agent/config.yml`, without paying for a full
|
|
9
|
+
* `loadConfig()` (which detects the stack and shells out to git).
|
|
10
|
+
*
|
|
11
|
+
* A bare `agentctl` invocation has to stay instant, and the only field this
|
|
12
|
+
* advisor needs is one line of YAML.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} root
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function readConfiguredProvider(root) {
|
|
18
|
+
for (const name of ["config.yml", "jules.yml"]) {
|
|
19
|
+
const file = join(root, ".agent", name);
|
|
20
|
+
if (!existsSync(file)) continue;
|
|
21
|
+
try {
|
|
22
|
+
const match = readFileSync(file, "utf-8").match(/^provider:\s*["']?([\w.-]+)["']?\s*$/m);
|
|
23
|
+
if (match) return match[1];
|
|
24
|
+
} catch (_) {
|
|
25
|
+
// An unreadable config is already reported by the `init` branch above.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return "jules";
|
|
29
|
+
}
|
|
5
30
|
|
|
6
31
|
/**
|
|
7
32
|
* Work out the one thing the operator should do next.
|
|
@@ -52,13 +77,19 @@ export function resolveNextStep(root, env = process.env) {
|
|
|
52
77
|
};
|
|
53
78
|
}
|
|
54
79
|
|
|
55
|
-
|
|
80
|
+
// Which credential (or binary) is missing depends entirely on the provider
|
|
81
|
+
// the repository selected. Asking for JULES_API_KEY in a repository driving
|
|
82
|
+
// `claude-code` told the operator to fix something that was never broken.
|
|
83
|
+
const providerName = readConfiguredProvider(root);
|
|
84
|
+
const probe = probeProvider(providerName, { env });
|
|
85
|
+
if (!probe.ready) {
|
|
56
86
|
return {
|
|
57
|
-
id: "
|
|
58
|
-
headline:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
87
|
+
id: "provider",
|
|
88
|
+
headline: probe.known
|
|
89
|
+
? `Provider '${probe.name}' is not usable yet`
|
|
90
|
+
: `Provider '${probe.name}' is not a built-in preset`,
|
|
91
|
+
detail: `${probe.reason} Until it is resolved you can still run \`agentctl gate\` and \`--dry-run\` dispatches locally — every verification gate works with no provider at all.`,
|
|
92
|
+
command: probe.remedy,
|
|
62
93
|
blocking: false,
|
|
63
94
|
};
|
|
64
95
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { PROFILE_NAMES } from "./profiles.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Set `verify.profile` in the repository's manifest, in place.
|
|
7
|
+
*
|
|
8
|
+
* A surgical text edit rather than a parse-and-reserialise: the kit's YAML
|
|
9
|
+
* reader is a subset parser, so round-tripping the file through it would drop
|
|
10
|
+
* every comment the wizard wrote and any key the subset does not model. The
|
|
11
|
+
* manifest is a file a human maintains; a tool that rewrites it must leave the
|
|
12
|
+
* rest of it alone.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} root
|
|
15
|
+
* @param {string} profile - one of {@link PROFILE_NAMES}
|
|
16
|
+
* @returns {{ ok: boolean, file?: string, error?: string }}
|
|
17
|
+
*/
|
|
18
|
+
export function setVerificationProfile(root, profile) {
|
|
19
|
+
const name = String(profile || "").toLowerCase();
|
|
20
|
+
if (!PROFILE_NAMES.includes(name)) {
|
|
21
|
+
return { ok: false, error: `Unknown profile '${profile}'. Choose one of: ${PROFILE_NAMES.join(", ")}` };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const candidates = [join(root, ".agent", "config.yml"), join(root, ".agent", "jules.yml")];
|
|
25
|
+
const file = candidates.find((f) => existsSync(f));
|
|
26
|
+
if (!file) {
|
|
27
|
+
return { ok: false, error: "No .agent/config.yml found. Run `agentctl init` first." };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let text;
|
|
31
|
+
try {
|
|
32
|
+
text = readFileSync(file, "utf-8");
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return { ok: false, error: `Could not read ${file}: ${err.message}` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
38
|
+
const lines = text.split(/\r?\n/);
|
|
39
|
+
|
|
40
|
+
// Find the `verify:` mapping and the `profile:` key nested directly under it.
|
|
41
|
+
let verifyIdx = -1;
|
|
42
|
+
let profileIdx = -1;
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
if (/^verify:\s*$/.test(lines[i])) {
|
|
45
|
+
verifyIdx = i;
|
|
46
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
47
|
+
// A non-indented, non-blank line ends the block.
|
|
48
|
+
if (lines[j].trim() !== "" && !/^\s/.test(lines[j])) break;
|
|
49
|
+
if (/^\s+profile:\s*/.test(lines[j])) {
|
|
50
|
+
profileIdx = j;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (profileIdx >= 0) {
|
|
59
|
+
const indent = lines[profileIdx].match(/^(\s*)/)[1];
|
|
60
|
+
lines[profileIdx] = `${indent}profile: ${name}`;
|
|
61
|
+
} else if (verifyIdx >= 0) {
|
|
62
|
+
lines.splice(verifyIdx + 1, 0, ` profile: ${name}`);
|
|
63
|
+
} else {
|
|
64
|
+
if (lines.length && lines[lines.length - 1] !== "") lines.push("");
|
|
65
|
+
lines.push("verify:", ` profile: ${name}`, "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(tmp, lines.join(eol), "utf-8");
|
|
71
|
+
renameSync(tmp, file);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
return { ok: false, error: `Could not write ${file}: ${err.message}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { ok: true, file };
|
|
77
|
+
}
|
package/src/profiles.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification profiles: one word in `.agent/config.yml` that turns the kit's
|
|
3
|
+
* verification primitives on as a coherent set.
|
|
4
|
+
*
|
|
5
|
+
* The kit ships mutation testing, V8 diff coverage, flakiness probing and
|
|
6
|
+
* anti-tamper detection, and every one of them was reachable only by knowing
|
|
7
|
+
* the command exists and typing it by hand. A repository that scaffolded a
|
|
8
|
+
* default config got exactly one gate — `npm test` — and none of the rest, so
|
|
9
|
+
* the capability shipped and then sat idle. A profile is the shortest sentence
|
|
10
|
+
* that says how hard this repository wants its agents verified.
|
|
11
|
+
*
|
|
12
|
+
* Profiles are expanded at load time (see `loadConfig`), not frozen into the
|
|
13
|
+
* scaffolded YAML, so the stage list stays correct when the stack changes and
|
|
14
|
+
* improves when the kit does.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Ordered weakest to strictest; `standard` is the default for a fresh repo. */
|
|
18
|
+
export const PROFILE_NAMES = ["minimal", "standard", "max"];
|
|
19
|
+
|
|
20
|
+
export const PROFILE_DESCRIPTIONS = {
|
|
21
|
+
minimal: "Tests only. For a slow suite, an unfamiliar stack, or a first day with the kit.",
|
|
22
|
+
standard: "Lint, tests, build, plus anti-tamper on the diff. The everyday gate.",
|
|
23
|
+
max: "Everything mechanical: adds mutation scoring, diff coverage, and flakiness probing.",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Stacks whose test command runs on Node and therefore honours
|
|
28
|
+
* `NODE_V8_COVERAGE`, which is how `assert:diff-coverage` collects data.
|
|
29
|
+
*
|
|
30
|
+
* Bun and Deno are deliberately absent: they run JavaScript but do not emit V8
|
|
31
|
+
* coverage into that directory, so the assertion would find no coverage maps
|
|
32
|
+
* and fail every diff for a reason that has nothing to do with the diff.
|
|
33
|
+
*/
|
|
34
|
+
const V8_COVERAGE_STACKS = new Set(["node", "turbo", "pnpm", "nx", "react-native", "hardhat"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the full verification pipeline for a profile.
|
|
38
|
+
*
|
|
39
|
+
* Returns the *complete* stage list, not an addendum: `gate()` replaces its
|
|
40
|
+
* built-in pipeline wholesale when `verify.stages` is present, so a profile
|
|
41
|
+
* that emitted only its extra assertions would silently drop the tests.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} profile - one of {@link PROFILE_NAMES}
|
|
44
|
+
* @param {object} ctx
|
|
45
|
+
* @param {string} [ctx.stack] - detected stack id (see `detectPolyglotStack`)
|
|
46
|
+
* @param {{setup?: string, lint?: string, test?: string, unit?: string, e2e?: string, build?: string, policy?: object}} [ctx.verify]
|
|
47
|
+
* @returns {{ profile: string, stages: object[], skipped: Array<{id: string, reason: string}> }}
|
|
48
|
+
*/
|
|
49
|
+
export function buildProfileStages(profile, ctx = {}) {
|
|
50
|
+
const name = PROFILE_NAMES.includes(String(profile || "").toLowerCase())
|
|
51
|
+
? String(profile).toLowerCase()
|
|
52
|
+
: "standard";
|
|
53
|
+
const verify = ctx.verify || {};
|
|
54
|
+
const stack = ctx.stack || "unknown";
|
|
55
|
+
const network = verify.policy?.networkAccess || "allow";
|
|
56
|
+
const testCmd = verify.test || verify.unit || "";
|
|
57
|
+
|
|
58
|
+
const stages = [];
|
|
59
|
+
const skipped = [];
|
|
60
|
+
|
|
61
|
+
// Dependency install always runs first when the stack declares one; without
|
|
62
|
+
// it every later stage fails on a missing toolchain rather than on the code.
|
|
63
|
+
if (verify.setup) {
|
|
64
|
+
stages.push({ id: "setup", kind: "setup", cmd: verify.setup, required: true, networkAccess: "allow" });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (name !== "minimal" && verify.lint) {
|
|
68
|
+
stages.push({ id: "lint", kind: "lint", cmd: verify.lint, required: true, networkAccess: network });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (testCmd) {
|
|
72
|
+
stages.push({ id: "unit", kind: "test", cmd: testCmd, required: true, networkAccess: network });
|
|
73
|
+
} else {
|
|
74
|
+
skipped.push({
|
|
75
|
+
id: "unit",
|
|
76
|
+
reason: "No test command is configured or detectable — set verify.test in .agent/config.yml.",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (name !== "minimal" && verify.e2e) {
|
|
81
|
+
stages.push({ id: "e2e", kind: "e2e", cmd: verify.e2e, required: true, networkAccess: "allow" });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (name !== "minimal" && verify.build) {
|
|
85
|
+
stages.push({ id: "build", kind: "build", cmd: verify.build, required: true, networkAccess: network });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Anti-tamper is diff-based and language-agnostic: it costs nothing to run
|
|
89
|
+
// and it is the one check an agent under pressure to go green will trip.
|
|
90
|
+
if (name !== "minimal") {
|
|
91
|
+
stages.push({ id: "anti-tamper", kind: "assert", assert: "test-integrity", required: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (name === "max") {
|
|
95
|
+
// Mutation operators are C-family (`===`, `&&`, `++`, `true`), so the score
|
|
96
|
+
// is strongest on JS/TS, Go, Rust, Java, C#, PHP, Swift and Dart, and
|
|
97
|
+
// weaker on Python/Ruby where the keywords differ. It is still safe
|
|
98
|
+
// everywhere: it only ever mutates lines the diff added, and a diff with no
|
|
99
|
+
// mutable operators scores 100 and passes.
|
|
100
|
+
stages.push({
|
|
101
|
+
id: "mutation",
|
|
102
|
+
kind: "assert",
|
|
103
|
+
assert: "mutation",
|
|
104
|
+
minScore: 60,
|
|
105
|
+
maxMutants: 20,
|
|
106
|
+
testCmd,
|
|
107
|
+
required: true,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (V8_COVERAGE_STACKS.has(stack)) {
|
|
111
|
+
stages.push({
|
|
112
|
+
id: "diff-coverage",
|
|
113
|
+
kind: "assert",
|
|
114
|
+
assert: "diff-coverage",
|
|
115
|
+
minCoverage: 80,
|
|
116
|
+
testCmd,
|
|
117
|
+
required: true,
|
|
118
|
+
});
|
|
119
|
+
} else {
|
|
120
|
+
skipped.push({
|
|
121
|
+
id: "diff-coverage",
|
|
122
|
+
reason: `Diff coverage reads NODE_V8_COVERAGE, which stack '${stack}' does not produce.`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Three passes is the cheapest count that can distinguish "passed" from
|
|
127
|
+
// "passed this time": a single alternation is enough to quarantine.
|
|
128
|
+
stages.push({
|
|
129
|
+
id: "stability",
|
|
130
|
+
kind: "assert",
|
|
131
|
+
assert: "test-stability",
|
|
132
|
+
repeat: 3,
|
|
133
|
+
minPassRate: 1.0,
|
|
134
|
+
cmd: testCmd,
|
|
135
|
+
required: true,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// `assert:event-loop-lag` is deliberately not here. It measures the
|
|
139
|
+
// orchestrator's own loop while it blocks on a synchronous child process,
|
|
140
|
+
// so against a spawned test command it reports the blocking duration rather
|
|
141
|
+
// than the application's latency. It stays available as an explicit stage
|
|
142
|
+
// for repositories that know what they are measuring.
|
|
143
|
+
skipped.push({
|
|
144
|
+
id: "event-loop-lag",
|
|
145
|
+
reason: "Opt-in only: meaningful for in-process latency targets, not for a spawned test command.",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { profile: name, stages, skipped };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The pipeline `gate()` runs when no profile and no explicit stages are set:
|
|
154
|
+
* every verification command the stack resolved, in dependency order.
|
|
155
|
+
*
|
|
156
|
+
* Lives here rather than inline in `gate()` so that `agentctl profile` can show
|
|
157
|
+
* an operator what will actually run without reimplementing — and drifting
|
|
158
|
+
* from — the sequence the gate uses.
|
|
159
|
+
*
|
|
160
|
+
* @param {{setup?: string, lint?: string, test?: string, unit?: string, fuzz?: string, invariant?: string, e2e?: string, build?: string, policy?: object}} verify
|
|
161
|
+
* @returns {object[]}
|
|
162
|
+
*/
|
|
163
|
+
export function buildDefaultStages(verify = {}) {
|
|
164
|
+
const network = verify.policy?.networkAccess || "allow";
|
|
165
|
+
const stages = [];
|
|
166
|
+
if (verify.setup) stages.push({ id: "setup", kind: "setup", cmd: verify.setup, required: true, networkAccess: "allow" });
|
|
167
|
+
if (verify.lint) stages.push({ id: "lint", kind: "lint", cmd: verify.lint, required: true, networkAccess: network });
|
|
168
|
+
if (verify.test || verify.unit) {
|
|
169
|
+
stages.push({ id: "unit", kind: "test", cmd: verify.test || verify.unit, required: true, networkAccess: network });
|
|
170
|
+
}
|
|
171
|
+
if (verify.fuzz) stages.push({ id: "fuzz", kind: "fuzz", cmd: verify.fuzz, required: true, networkAccess: network });
|
|
172
|
+
if (verify.invariant) stages.push({ id: "invariant", kind: "invariant", cmd: verify.invariant, required: true, networkAccess: network });
|
|
173
|
+
if (verify.e2e) stages.push({ id: "e2e", kind: "e2e", cmd: verify.e2e, required: true, networkAccess: "allow" });
|
|
174
|
+
if (verify.build) stages.push({ id: "build", kind: "build", cmd: verify.build, required: true, networkAccess: network });
|
|
175
|
+
return stages;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Short human-readable summary of what a profile will run, for `init` output
|
|
180
|
+
* and `doctor`.
|
|
181
|
+
*
|
|
182
|
+
* @param {ReturnType<typeof buildProfileStages>} plan
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
export function describeProfilePlan(plan) {
|
|
186
|
+
const ids = plan.stages.map((s) => s.id);
|
|
187
|
+
return ids.length ? ids.join(" → ") : "(nothing to run)";
|
|
188
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { join, delimiter } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What each provider actually needs before a dispatch can succeed.
|
|
6
|
+
*
|
|
7
|
+
* The kit shipped four provider adapters (`src/provider.mjs`) but only ever
|
|
8
|
+
* asked one question about readiness: "is JULES_API_KEY set?". That question is
|
|
9
|
+
* meaningless for `claude-code` and `codex`, which authenticate through their
|
|
10
|
+
* own CLI and need a binary on PATH instead of an environment variable — so a
|
|
11
|
+
* repository driven by a local agent CLI was told, forever, that it was
|
|
12
|
+
* misconfigured. Readiness is a property of the *selected* provider, and this
|
|
13
|
+
* table is where that property lives.
|
|
14
|
+
*
|
|
15
|
+
* `envKeys` are alternatives, not requirements in sequence: any one satisfies
|
|
16
|
+
* the provider. `bin` is the executable the exec adapter spawns.
|
|
17
|
+
*
|
|
18
|
+
* No documentation URLs live here on purpose: `test/egress-allowlist.test.mjs`
|
|
19
|
+
* treats every host literal under src/ as a network destination to justify, and
|
|
20
|
+
* a vendor doc link that nothing ever fetches would spend that budget without
|
|
21
|
+
* buying anything. `install` and `remedy` carry the actionable part.
|
|
22
|
+
*/
|
|
23
|
+
export const PROVIDER_DESCRIPTORS = {
|
|
24
|
+
jules: {
|
|
25
|
+
name: "jules",
|
|
26
|
+
kind: "http",
|
|
27
|
+
label: "Google Jules — hosted asynchronous agent (REST)",
|
|
28
|
+
envKeys: ["JULES_API_KEY", "GEMINI_API_KEY"],
|
|
29
|
+
bin: null,
|
|
30
|
+
install: null,
|
|
31
|
+
remedy: "export JULES_API_KEY=...",
|
|
32
|
+
// Only the hosted provider clones a GitHub repository server-side, so only
|
|
33
|
+
// it needs to be told which one.
|
|
34
|
+
needsRepoSource: true,
|
|
35
|
+
},
|
|
36
|
+
"claude-code": {
|
|
37
|
+
name: "claude-code",
|
|
38
|
+
kind: "exec",
|
|
39
|
+
label: "Claude Code CLI — local agent",
|
|
40
|
+
envKeys: [],
|
|
41
|
+
bin: "claude",
|
|
42
|
+
install: "npm i -g @anthropic-ai/claude-code",
|
|
43
|
+
remedy: "Install the Claude Code CLI and run `claude` once to authenticate",
|
|
44
|
+
needsRepoSource: false,
|
|
45
|
+
},
|
|
46
|
+
codex: {
|
|
47
|
+
name: "codex",
|
|
48
|
+
kind: "exec",
|
|
49
|
+
label: "OpenAI Codex CLI — local agent",
|
|
50
|
+
envKeys: [],
|
|
51
|
+
bin: "codex",
|
|
52
|
+
install: "npm i -g @openai/codex",
|
|
53
|
+
remedy: "Install the Codex CLI and run `codex` once to authenticate",
|
|
54
|
+
needsRepoSource: false,
|
|
55
|
+
},
|
|
56
|
+
"gemini-flash": {
|
|
57
|
+
name: "gemini-flash",
|
|
58
|
+
kind: "exec",
|
|
59
|
+
label: "Gemini CLI — local agent (cheap/fast router tier)",
|
|
60
|
+
envKeys: ["GEMINI_API_KEY"],
|
|
61
|
+
bin: "gemini",
|
|
62
|
+
install: "npm i -g @google/gemini-cli",
|
|
63
|
+
remedy: "Install the Gemini CLI and export GEMINI_API_KEY",
|
|
64
|
+
needsRepoSource: false,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** `gemini-cli` is an accepted spelling in NAMED_PRESETS; keep the two in step. */
|
|
69
|
+
PROVIDER_DESCRIPTORS["gemini-cli"] = { ...PROVIDER_DESCRIPTORS["gemini-flash"], name: "gemini-cli" };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Preference order when nothing is configured and several providers are ready.
|
|
73
|
+
*
|
|
74
|
+
* Hosted first because it is the only one that can run a task while the
|
|
75
|
+
* operator's machine is asleep; the local CLIs follow in descending
|
|
76
|
+
* capability. This is a *tie-break*, never an override: an explicit
|
|
77
|
+
* `provider:` in .agent/config.yml always wins.
|
|
78
|
+
*/
|
|
79
|
+
export const PROVIDER_PREFERENCE = ["jules", "claude-code", "codex", "gemini-flash"];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Cross-platform `which`, without shelling out.
|
|
83
|
+
*
|
|
84
|
+
* Spawning `which`/`where` costs a process per probe and does not exist
|
|
85
|
+
* identically on Windows; PATHEXT resolution is the part that actually differs,
|
|
86
|
+
* and it is three lines. Returns the absolute path or null.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} bin
|
|
89
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
90
|
+
* @returns {string|null}
|
|
91
|
+
*/
|
|
92
|
+
export function whichBinary(bin, env = process.env) {
|
|
93
|
+
if (!bin || typeof bin !== "string") return null;
|
|
94
|
+
const pathVar = env.PATH || env.Path || env.path || "";
|
|
95
|
+
if (!pathVar) return null;
|
|
96
|
+
|
|
97
|
+
const isWindows = process.platform === "win32";
|
|
98
|
+
// On Windows a bare name has no extension; PATHEXT lists the ones the shell
|
|
99
|
+
// would have tried. Elsewhere the name is the whole story.
|
|
100
|
+
const extensions = isWindows
|
|
101
|
+
? (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").map((e) => e.trim()).filter(Boolean)
|
|
102
|
+
: [""];
|
|
103
|
+
|
|
104
|
+
for (const dir of pathVar.split(delimiter)) {
|
|
105
|
+
if (!dir) continue;
|
|
106
|
+
for (const ext of extensions) {
|
|
107
|
+
const candidate = join(dir, bin + ext);
|
|
108
|
+
try {
|
|
109
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
110
|
+
} catch (_) {
|
|
111
|
+
// Unreadable PATH entry (a stale mount, a permission-denied directory).
|
|
112
|
+
// A probe must never throw: an unreadable directory simply holds nothing.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Report whether one named provider can be dispatched to right now.
|
|
121
|
+
*
|
|
122
|
+
* Never throws and never spawns the agent: this is called from `agentctl` with
|
|
123
|
+
* no arguments and from `doctor`, both of which must stay instant and free.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} name
|
|
126
|
+
* @param {object} [opts]
|
|
127
|
+
* @param {NodeJS.ProcessEnv} [opts.env=process.env]
|
|
128
|
+
* @returns {{name: string, kind: string, label: string, ready: boolean, reason: string, remedy: string, keySource: string|null, binPath: string|null, known: boolean}}
|
|
129
|
+
*/
|
|
130
|
+
export function probeProvider(name, opts = {}) {
|
|
131
|
+
const env = opts.env || process.env;
|
|
132
|
+
const key = String(name || "").trim().toLowerCase();
|
|
133
|
+
const descriptor = PROVIDER_DESCRIPTORS[key];
|
|
134
|
+
|
|
135
|
+
if (!descriptor) {
|
|
136
|
+
return {
|
|
137
|
+
name: key || "(unset)",
|
|
138
|
+
kind: "unknown",
|
|
139
|
+
label: key ? `Custom provider '${key}'` : "No provider selected",
|
|
140
|
+
ready: false,
|
|
141
|
+
known: false,
|
|
142
|
+
reason: key
|
|
143
|
+
? `'${key}' is not a built-in provider preset, so its readiness cannot be checked here.`
|
|
144
|
+
: "No provider is configured.",
|
|
145
|
+
remedy: `Set provider: to one of ${PROVIDER_PREFERENCE.join(", ")} in .agent/config.yml`,
|
|
146
|
+
keySource: null,
|
|
147
|
+
binPath: null,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const keySource = descriptor.envKeys.find((k) => (env[k] || "").trim()) || null;
|
|
152
|
+
const binPath = descriptor.bin ? whichBinary(descriptor.bin, env) : null;
|
|
153
|
+
|
|
154
|
+
// An http provider is gated purely on credentials; an exec provider is gated
|
|
155
|
+
// on the binary, and treats its env key as optional because the CLIs carry
|
|
156
|
+
// their own stored login.
|
|
157
|
+
const ready = descriptor.kind === "http" ? Boolean(keySource) : Boolean(binPath);
|
|
158
|
+
|
|
159
|
+
let reason;
|
|
160
|
+
if (ready) {
|
|
161
|
+
reason =
|
|
162
|
+
descriptor.kind === "http"
|
|
163
|
+
? `Credential supplied via ${keySource} (read from the environment only).`
|
|
164
|
+
: `\`${descriptor.bin}\` found on PATH${keySource ? ` (plus ${keySource})` : ""}.`;
|
|
165
|
+
} else {
|
|
166
|
+
reason =
|
|
167
|
+
descriptor.kind === "http"
|
|
168
|
+
? `None of ${descriptor.envKeys.join(", ")} is set in the environment.`
|
|
169
|
+
: `\`${descriptor.bin}\` is not on PATH.`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
name: descriptor.name,
|
|
174
|
+
kind: descriptor.kind,
|
|
175
|
+
label: descriptor.label,
|
|
176
|
+
ready,
|
|
177
|
+
known: true,
|
|
178
|
+
reason,
|
|
179
|
+
remedy: descriptor.install && !binPath ? descriptor.install : descriptor.remedy,
|
|
180
|
+
keySource,
|
|
181
|
+
binPath,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Probe every built-in provider, ready ones first, in preference order.
|
|
187
|
+
*
|
|
188
|
+
* Used by `agentctl init` to propose a provider the machine can actually reach
|
|
189
|
+
* instead of scaffolding `provider: jules` into a repository whose operator has
|
|
190
|
+
* no Jules key and never wanted one.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} [opts]
|
|
193
|
+
* @param {NodeJS.ProcessEnv} [opts.env=process.env]
|
|
194
|
+
* @returns {Array<ReturnType<typeof probeProvider>>}
|
|
195
|
+
*/
|
|
196
|
+
export function detectAvailableProviders(opts = {}) {
|
|
197
|
+
const probes = PROVIDER_PREFERENCE.map((name) => probeProvider(name, opts));
|
|
198
|
+
return probes.sort((a, b) => {
|
|
199
|
+
if (a.ready !== b.ready) return a.ready ? -1 : 1;
|
|
200
|
+
return PROVIDER_PREFERENCE.indexOf(a.name) - PROVIDER_PREFERENCE.indexOf(b.name);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The provider a fresh `init` should scaffold: the first one that is actually
|
|
206
|
+
* usable on this machine, falling back to the hosted default so a repository
|
|
207
|
+
* initialised offline still names something coherent.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} [opts]
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
export function suggestProvider(opts = {}) {
|
|
213
|
+
const ready = detectAvailableProviders(opts).find((p) => p.ready);
|
|
214
|
+
return ready ? ready.name : "jules";
|
|
215
|
+
}
|
package/src/security.mjs
CHANGED
|
@@ -328,6 +328,47 @@ export function isForbiddenPath(filePath, config = {}) {
|
|
|
328
328
|
return forbidden.some((pattern) => matchesGlob(normFile, pattern, { caseInsensitive: true }));
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
/**
|
|
332
|
+
* The builtin deny patterns that exist to keep credentials out of a diff, and
|
|
333
|
+
* the documented template filenames those patterns must not catch.
|
|
334
|
+
*
|
|
335
|
+
* The recursive dot-env glob is correct for `.env.local` and `.env.production`
|
|
336
|
+
* and wrong for `.env.example` — a file nearly every repository commits
|
|
337
|
+
* precisely so the environment can be documented without the values. Denying it
|
|
338
|
+
* meant no agent could ever be asked to document a new variable, in any project.
|
|
339
|
+
*
|
|
340
|
+
* The exemption is deliberately narrow. It applies only when one of the two
|
|
341
|
+
* *builtin* patterns matched: a repository that writes its own broader dot-env
|
|
342
|
+
* deny rule blocks templates too, because the pattern string is not one of
|
|
343
|
+
* these. And the diff secret scanner runs over every changed file regardless of
|
|
344
|
+
* scope, so a real credential pasted into a template still fails on exit 6.
|
|
345
|
+
*/
|
|
346
|
+
const BUILTIN_ENV_DENY_PATTERNS = new Set(["**/.env", "**/.env.*"]);
|
|
347
|
+
export const ENV_TEMPLATE_BASENAMES = new Set([
|
|
348
|
+
".env.example",
|
|
349
|
+
".env.sample",
|
|
350
|
+
".env.template",
|
|
351
|
+
".env.dist",
|
|
352
|
+
".env.defaults",
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* True when a deny hit is the builtin credential rule catching a committed
|
|
357
|
+
* environment *template* rather than an environment file.
|
|
358
|
+
*
|
|
359
|
+
* @param {string} file - canonicalised repo-relative path
|
|
360
|
+
* @param {string} pattern - the deny pattern that matched
|
|
361
|
+
* @returns {boolean}
|
|
362
|
+
*/
|
|
363
|
+
export function isEnvTemplateException(file, pattern) {
|
|
364
|
+
if (!BUILTIN_ENV_DENY_PATTERNS.has(pattern)) return false;
|
|
365
|
+
const name = basename(file).toLowerCase();
|
|
366
|
+
if (ENV_TEMPLATE_BASENAMES.has(name)) return true;
|
|
367
|
+
// `.env.production.example`, `.env.test.sample`, ... — the documented-template
|
|
368
|
+
// suffix is what matters, not how many environment segments precede it.
|
|
369
|
+
return /^\.env\..+\.(example|sample|template|dist|defaults)$/.test(name);
|
|
370
|
+
}
|
|
371
|
+
|
|
331
372
|
export function checkScope(files = [], scope = {}, opts = {}) {
|
|
332
373
|
const violations = [];
|
|
333
374
|
const deny = scope.deny || [];
|
|
@@ -360,7 +401,7 @@ export function checkScope(files = [], scope = {}, opts = {}) {
|
|
|
360
401
|
// Deny folds case: on macOS/Windows ".GitHub/" resolves to the same
|
|
361
402
|
// directory as ".github/", so a case-sensitive deny is bypassable there.
|
|
362
403
|
const matchedDeny = deny.find((pat) => matchesGlob(file, pat, { caseInsensitive: true }));
|
|
363
|
-
if (matchedDeny) {
|
|
404
|
+
if (matchedDeny && !isEnvTemplateException(file, matchedDeny)) {
|
|
364
405
|
violations.push({ file, reason: `Forbidden path restriction matched pattern "${matchedDeny}"`, rule: "deny", pattern: matchedDeny });
|
|
365
406
|
continue;
|
|
366
407
|
}
|
package/src/tui.mjs
CHANGED
|
@@ -41,9 +41,20 @@ export function styleText(text, style) {
|
|
|
41
41
|
return `${style}${text}${ANSI.reset}`;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
import {
|
|
45
|
-
|
|
46
|
-
export
|
|
44
|
+
import { createKeyDecoder } from "./key-decoder.mjs";
|
|
45
|
+
|
|
46
|
+
export class WizardCancelledError extends Error {
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} [message]
|
|
49
|
+
* @param {"escape" | "ctrl-c" | "quit" | "stream-closed"} [reason="ctrl-c"]
|
|
50
|
+
*/
|
|
51
|
+
constructor(message = "Wizard operation cancelled by user", reason = "ctrl-c") {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "WizardCancelledError";
|
|
54
|
+
this.code = 130;
|
|
55
|
+
this.reason = reason;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
47
58
|
|
|
48
59
|
/**
|
|
49
60
|
* Read keypress in raw mode from TTY input stream without destroying stream on early return.
|