@event4u/agent-config 9.10.0 → 9.11.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +59 -0
- package/README.md +12 -15
- package/agents/templates/.ai-council.yml.example +481 -0
- package/agents/templates/.ai-video.xml.example +247 -0
- package/dist/agent-src/rules/design-fidelity.md +1 -1
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +15 -2
- package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +30 -30
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +20 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/review.ts +20 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +179 -0
- package/dist/agent-src/templates/scripts/work_engine/stack/detect.ts +155 -4
- package/dist/agent-src/templates/scripts/work_engine/state.ts +21 -4
- package/dist/cli/initRouting.js +74 -11
- package/dist/cli/initRouting.js.map +1 -1
- package/dist/cli/main.js +14 -3
- package/dist/cli/main.js.map +1 -1
- package/dist/cli-delegate/chunk-4SV3XGFA.js +6910 -0
- package/dist/cli-delegate/chunk-736JCWLM.js +1104 -0
- package/dist/cli-delegate/chunk-7DSAMZFU.js +356 -0
- package/dist/cli-delegate/chunk-7HQ52DHU.js +74 -0
- package/dist/cli-delegate/chunk-BJGXZNPM.js +266 -0
- package/dist/cli-delegate/chunk-FTAISGDQ.js +117 -0
- package/dist/cli-delegate/chunk-GME3Q44L.js +82 -0
- package/dist/cli-delegate/chunk-GRRPEPRN.js +336 -0
- package/dist/cli-delegate/chunk-HJ6DAJHR.js +502 -0
- package/dist/cli-delegate/chunk-IR7P7JWA.js +872 -0
- package/dist/cli-delegate/chunk-J55WI3RR.js +47 -0
- package/dist/cli-delegate/chunk-MCJYCQPF.js +35 -0
- package/dist/cli-delegate/chunk-NDJLNC6Q.js +169 -0
- package/dist/cli-delegate/chunk-OMIVVCG2.js +26 -0
- package/dist/cli-delegate/chunk-OY53J6II.js +7336 -0
- package/dist/cli-delegate/chunk-RDGRHAAB.js +259 -0
- package/dist/cli-delegate/chunk-TFG57JJM.js +1969 -0
- package/dist/cli-delegate/chunk-XX6TOPD7.js +715 -0
- package/dist/cli-delegate/chunk-YMFO6FDN.js +10603 -0
- package/dist/cli-delegate/cmd_analyze_session.js +294 -0
- package/dist/cli-delegate/cmd_conformance.js +646 -0
- package/dist/cli-delegate/cmd_converge.js +326 -0
- package/dist/cli-delegate/cmd_doctor.js +144 -0
- package/dist/cli-delegate/cmd_explain.js +2508 -0
- package/dist/cli-delegate/cmd_export.js +296 -0
- package/dist/cli-delegate/cmd_fleet.js +332 -0
- package/dist/cli-delegate/cmd_memory_get.js +3878 -0
- package/dist/cli-delegate/cmd_migrate.js +11 -0
- package/dist/cli-delegate/cmd_preflight.js +270 -0
- package/dist/cli-delegate/cmd_prune.js +444 -0
- package/dist/cli-delegate/cmd_refresh.js +28 -0
- package/dist/cli-delegate/cmd_settings_check.js +574 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +223 -0
- package/dist/cli-delegate/cmd_sync.js +264 -0
- package/dist/cli-delegate/cmd_uninstall.js +656 -0
- package/dist/cli-delegate/cmd_update.js +425 -0
- package/dist/cli-delegate/cmd_upgrade.js +444 -0
- package/dist/cli-delegate/cmd_validate.js +305 -0
- package/dist/cli-delegate/cmd_versions.js +370 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +14 -7
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +2 -2
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +12 -7
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +4 -3
- package/dist/install/emit_host_rules_cli.js +2 -1
- package/dist/install/emit_host_rules_cli.js.map +1 -1
- package/dist/install/install.mjs +410 -378
- package/dist/install/rule_scope.js +5 -1
- package/dist/install/rule_scope.js.map +1 -1
- package/dist/install/toolDetection.js +40 -12
- package/dist/install/toolDetection.js.map +1 -1
- package/dist/install/wizard-plan.js +2 -2
- package/dist/install/wizard-plan.js.map +1 -1
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1614 -765
- package/dist/router.json +1 -1
- package/package.json +4 -2
- package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +30 -30
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
- package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +20 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +20 -1
- package/src/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +179 -0
- package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +155 -4
- package/src/agent-src/templates/scripts/work_engine/state.ts +21 -4
- package/src/config/discovery/packs.yml +7 -0
- package/src/config/evaluator-budgets.json +1 -1
- package/src/config/pack-size-budget.json +27 -0
- package/src/install/emit_host_rules_cli.ts +2 -1
- package/src/install/rule_scope.ts +5 -1
- package/src/install/toolDetection.ts +39 -8
- package/src/install/wizard-plan.ts +2 -2
- package/src/scripts/_cli/cmd_conformance.ts +2 -1
- package/src/scripts/_cli/cmd_converge.ts +2 -1
- package/src/scripts/_cli/cmd_doctor.ts +214 -13
- package/src/scripts/_cli/cmd_export.ts +2 -1
- package/src/scripts/_cli/cmd_fleet.ts +2 -1
- package/src/scripts/_cli/cmd_migrate.ts +2 -1
- package/src/scripts/_cli/cmd_preflight.ts +2 -1
- package/src/scripts/_cli/cmd_refresh.ts +2 -1
- package/src/scripts/_cli/cmd_update.ts +2 -1
- package/src/scripts/_cli/cmd_upgrade.ts +2 -1
- package/src/scripts/_cli/cmd_versions.ts +2 -1
- package/src/scripts/_cli/detection_report.ts +480 -0
- package/src/scripts/_dispatch.bash +52 -14
- package/src/scripts/_lib/changelog_eras.ts +103 -9
- package/src/scripts/_lib/environment_detector.ts +441 -0
- package/src/scripts/_lib/package_root.ts +72 -0
- package/src/scripts/ai_council/config.ts +15 -7
- package/src/scripts/ai_council/modes.ts +68 -2
- package/src/scripts/ai_council/transport_resolver.ts +331 -0
- package/src/scripts/check_bundle_path_leakage.ts +10 -4
- package/src/scripts/check_dependency_floors.ts +153 -0
- package/src/scripts/check_pack_size.ts +188 -0
- package/src/scripts/council_cli.ts +6 -2
- package/src/scripts/evaluator_umbrella.sh +34 -1
- package/src/scripts/lint_eval_fixture_citations.ts +113 -0
- package/src/scripts/lint_ui_stack_bundles.ts +170 -0
|
@@ -409,13 +409,97 @@ function _rstrip(text: string): string {
|
|
|
409
409
|
return text.replace(/\s+$/u, '');
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Split a current-era body into (carry-over, archived) at `boundary`.
|
|
414
|
+
*
|
|
415
|
+
* The archive is named `pre-<boundary>`, so by its own contract it may only
|
|
416
|
+
* hold entries STRICTLY OLDER than the boundary. Sections at or above it —
|
|
417
|
+
* i.e. the incoming release's own entry, when it is already prepended — stay
|
|
418
|
+
* in the new era.
|
|
419
|
+
*
|
|
420
|
+
* On a fresh release run the body never contains the incoming entry (the split
|
|
421
|
+
* runs before the prepend), so the carry-over is empty and this is a no-op.
|
|
422
|
+
* On a `--resume` run the entry IS already committed and the bump step is
|
|
423
|
+
* correctly skipped, so without this partition the split archived the release
|
|
424
|
+
* it was named after and nothing ever put it back: the current era ended up
|
|
425
|
+
* empty and the `changelog-entry` CI gate went red. That is the 9.10.0
|
|
426
|
+
* failure. The partition makes the two orderings converge.
|
|
427
|
+
*
|
|
428
|
+
* Changelog bodies are newest-first, so the carry-over is a prefix — but the
|
|
429
|
+
* scan is per-heading rather than "stop at the first older one" so a
|
|
430
|
+
* hand-edited out-of-order body cannot silently archive a newer entry.
|
|
431
|
+
*/
|
|
432
|
+
export function _partition_era_body(
|
|
433
|
+
body: readonly string[],
|
|
434
|
+
boundary: string,
|
|
435
|
+
): { carry: string[]; archived: string[] } {
|
|
436
|
+
const b = _RELEASE_VERSION_RE.exec(boundary.trim());
|
|
437
|
+
if (!b) {
|
|
438
|
+
throw new Error(`not a bare semver (X.Y.Z): '${boundary}'`);
|
|
439
|
+
}
|
|
440
|
+
const bound: [number, number, number] = [
|
|
441
|
+
Number.parseInt(b[1]!, 10),
|
|
442
|
+
Number.parseInt(b[2]!, 10),
|
|
443
|
+
Number.parseInt(b[3]!, 10),
|
|
444
|
+
];
|
|
445
|
+
|
|
446
|
+
// Section boundaries: every `## [X.Y.Z]` heading starts a new section.
|
|
447
|
+
// Content before the first heading (there should be none) rides along with
|
|
448
|
+
// the archive, matching the pre-fix behaviour for that shape.
|
|
449
|
+
const starts: number[] = [];
|
|
450
|
+
for (let i = 0; i < body.length; i++) {
|
|
451
|
+
if (VERSION_HEADING_RE.test(body[i]!)) {
|
|
452
|
+
starts.push(i);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (starts.length === 0) {
|
|
456
|
+
return { carry: [], archived: [...body] };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const carry: string[] = [];
|
|
460
|
+
const archived: string[] = [...body.slice(0, starts[0]!)];
|
|
461
|
+
for (let s = 0; s < starts.length; s++) {
|
|
462
|
+
const from = starts[s]!;
|
|
463
|
+
const to = s + 1 < starts.length ? starts[s + 1]! : body.length;
|
|
464
|
+
const section = body.slice(from, to);
|
|
465
|
+
const m = VERSION_HEADING_RE.exec(body[from]!);
|
|
466
|
+
const v = m?.groups?.['version'];
|
|
467
|
+
const parsed = v ? _RELEASE_VERSION_RE.exec(v) : null;
|
|
468
|
+
if (parsed === null || parsed === undefined) {
|
|
469
|
+
archived.push(...section);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const cur: [number, number, number] = [
|
|
473
|
+
Number.parseInt(parsed[1]!, 10),
|
|
474
|
+
Number.parseInt(parsed[2]!, 10),
|
|
475
|
+
Number.parseInt(parsed[3]!, 10),
|
|
476
|
+
];
|
|
477
|
+
const isOlder =
|
|
478
|
+
cur[0] < bound[0] ||
|
|
479
|
+
(cur[0] === bound[0] && cur[1] < bound[1]) ||
|
|
480
|
+
(cur[0] === bound[0] && cur[1] === bound[1] && cur[2] < bound[2]);
|
|
481
|
+
(isOlder ? archived : carry).push(...section);
|
|
482
|
+
}
|
|
483
|
+
return { carry, archived };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Drop trailing blank lines (in place semantics, returns the same array). */
|
|
487
|
+
function _trim_trailing_blanks(xs: string[]): string[] {
|
|
488
|
+
while (xs.length > 0 && xs[xs.length - 1]!.trim() === '') {
|
|
489
|
+
xs.pop();
|
|
490
|
+
}
|
|
491
|
+
return xs;
|
|
492
|
+
}
|
|
493
|
+
|
|
412
494
|
/**
|
|
413
495
|
* Execute `plan` against the on-disk CHANGELOG.md.
|
|
414
496
|
*
|
|
415
497
|
* - Refuses to overwrite an existing archive file.
|
|
416
|
-
* - Moves every entry
|
|
498
|
+
* - Moves every entry OLDER than the boundary into the new archive; an
|
|
499
|
+
* already-prepended entry for the incoming release stays in the new era
|
|
500
|
+
* (see `_partition_era_body`).
|
|
417
501
|
* - Replaces the current era block with the collapsed pointer + the
|
|
418
|
-
* freshly-labelled new current era header
|
|
502
|
+
* freshly-labelled new current era header.
|
|
419
503
|
*/
|
|
420
504
|
export function perform_split(plan: SplitPlan): void {
|
|
421
505
|
if (fs.existsSync(plan.archive_path)) {
|
|
@@ -433,21 +517,31 @@ export function perform_split(plan: SplitPlan): void {
|
|
|
433
517
|
}
|
|
434
518
|
|
|
435
519
|
const [body_start, , next_era_line] = _era_body_bounds(lines, current_idx);
|
|
436
|
-
const
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
520
|
+
const body = lines.slice(body_start, next_era_line);
|
|
521
|
+
// Entries at/above the boundary (a resume run's already-prepended release
|
|
522
|
+
// entry) stay in the new era; only older ones are archived.
|
|
523
|
+
const { carry, archived } = _partition_era_body(body, plan.boundary);
|
|
524
|
+
// Trim trailing blank lines so neither side accumulates them.
|
|
525
|
+
_trim_trailing_blanks(archived);
|
|
526
|
+
_trim_trailing_blanks(carry);
|
|
441
527
|
|
|
442
528
|
const collapsed = _splitlines(_rstrip_newlines(collapsed_era_block(plan.boundary)));
|
|
443
529
|
const new_era = _splitlines(_rstrip_newlines(new_era_intro_block(plan.new_era_label, plan.boundary)));
|
|
444
530
|
|
|
445
531
|
const head = lines.slice(0, current_idx);
|
|
446
532
|
const tail = lines.slice(next_era_line);
|
|
447
|
-
const new_lines = [
|
|
533
|
+
const new_lines = [
|
|
534
|
+
...head,
|
|
535
|
+
...collapsed,
|
|
536
|
+
'',
|
|
537
|
+
...new_era,
|
|
538
|
+
'',
|
|
539
|
+
...(carry.length > 0 ? [...carry, ''] : []),
|
|
540
|
+
...tail,
|
|
541
|
+
];
|
|
448
542
|
const new_text = _rstrip(new_lines.join('\n')) + '\n';
|
|
449
543
|
|
|
450
|
-
const archive_body =
|
|
544
|
+
const archive_body = archived.length > 0 ? _rstrip(archived.join('\n')) + '\n' : '';
|
|
451
545
|
const archive_text = archive_file_header(plan.boundary) + archive_body;
|
|
452
546
|
|
|
453
547
|
fs.mkdirSync(path.dirname(plan.archive_path), { recursive: true });
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only environment detection — one report, no spend (Phase 1 of
|
|
3
|
+
* `road-to-zero-ceremony-detection`).
|
|
4
|
+
*
|
|
5
|
+
* Answers "what does this machine already have?" in a single pass so the
|
|
6
|
+
* `doctor` surface and the council's transport resolution read the SAME
|
|
7
|
+
* facts instead of each probing on its own. Three record kinds:
|
|
8
|
+
*
|
|
9
|
+
* - `hosts` — which AI hosts are installed, their resolved binary, version.
|
|
10
|
+
* - `auth` — which provider credentials are PRESENT, and of which source.
|
|
11
|
+
* - `keys` — the `api_key_ref`-shaped reference for each present key.
|
|
12
|
+
*
|
|
13
|
+
* ## Guarantees
|
|
14
|
+
*
|
|
15
|
+
* - **Zero network.** Nothing here opens a socket. Enforced by the static
|
|
16
|
+
* import check in the test suite, not by convention.
|
|
17
|
+
* - **Zero spend.** The only subprocess is `<binary> --version`, which no
|
|
18
|
+
* provider bills. No completion, no token, ever — the roadmap's
|
|
19
|
+
* install-time-completion item was cut for exactly this reason.
|
|
20
|
+
* - **Presence, never validity.** An `auth` record says "a credential of this
|
|
21
|
+
* shape exists at this path". It does NOT say the credential works, is
|
|
22
|
+
* unexpired, or is accepted by the provider. Runtime auth failure stays
|
|
23
|
+
* authoritative (`CliClient._AUTH_FAILURE_PATTERNS`).
|
|
24
|
+
* - **Read-only.** Reads the filesystem and the injected env map; writes
|
|
25
|
+
* nothing, creates no directory.
|
|
26
|
+
*
|
|
27
|
+
* ## No third probe shape — what each field absorbs
|
|
28
|
+
*
|
|
29
|
+
* Auth probing already existed in two shapes before this module and a third
|
|
30
|
+
* must not appear. This module composes; it re-implements nothing:
|
|
31
|
+
*
|
|
32
|
+
* | Field | Absorbed from |
|
|
33
|
+
* |---|---|
|
|
34
|
+
* | `hosts[].installed` | `install/toolDetection.ts::detectInstalledTools` (the 23-host signal table) |
|
|
35
|
+
* | `hosts[].binary` | `install/toolDetection.ts::resolveToolBinary` (same table, path-returning) |
|
|
36
|
+
* | `hosts[].version` | `install/agentSwitchDetection.ts::parseVersionOutput` (the first-dotted-number parser) |
|
|
37
|
+
* | `auth` (`openai`, `cli-*`) | `_cli/cmd_doctor.ts`'s read-only `$CODEX_HOME/auth.json` probe — `codexHome()` moved HERE and imported back, so there is one definition |
|
|
38
|
+
* | `auth` (`anthropic`, `cli-subscription`) | `_lib/claude_plugin.ts::claude_config_dir` (`CLAUDE_CONFIG_DIR` override incl.) |
|
|
39
|
+
* | `auth` (`key-file`) | `_lib/user_global_paths.ts::resolve_with_fallback` (new namespace, legacy read-fallback) |
|
|
40
|
+
*
|
|
41
|
+
* The council's TTL'd `AuthCache` (`ai_council/solo_dispatch.ts`) is the third
|
|
42
|
+
* existing shape and is deliberately NOT absorbed: it caches a *liveness*
|
|
43
|
+
* verdict from an injected probe that may spend a call. This module only
|
|
44
|
+
* reports presence, so it needs no TTL — a per-process memo is enough.
|
|
45
|
+
*
|
|
46
|
+
* ## Known limitation — CLI logins the filesystem does not reveal
|
|
47
|
+
*
|
|
48
|
+
* A vendor CLI that stores its subscription credential OUTSIDE the filesystem
|
|
49
|
+
* is undetectable here. Measured case: on macOS the `claude` CLI keeps its
|
|
50
|
+
* OAuth credential in the system Keychain, so `~/.claude/.credentials.json`
|
|
51
|
+
* is absent even for a logged-in Claude Pro user. The detector reports
|
|
52
|
+
* `cli-subscription` only where a credential FILE exists (Linux, and the
|
|
53
|
+
* `CLAUDE_CONFIG_DIR` profile layouts).
|
|
54
|
+
*
|
|
55
|
+
* Consequence, stated because it costs money: with no detectable CLI login,
|
|
56
|
+
* `mode: auto` skips the cli rung and resolves to `api` — metered — for a user
|
|
57
|
+
* who does hold a subscription. This is a FALSE NEGATIVE, which is the
|
|
58
|
+
* direction this module is built to fail in (a false positive would claim a
|
|
59
|
+
* subscription that is not there and switch the USD gate off). The remedy is an
|
|
60
|
+
* explicit `mode: cli` on the member; `doctor` shows the detected source so the
|
|
61
|
+
* gap is visible rather than silent. Reading the Keychain would require a
|
|
62
|
+
* `security(1)` subprocess against a credential store — a spend-free but
|
|
63
|
+
* privacy-relevant probe, and out of scope here.
|
|
64
|
+
*
|
|
65
|
+
* @see docs/contracts/ai-council-config.md § Transport modes — the billing rules
|
|
66
|
+
* `classifyBilling` implements.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
import { spawnSync } from 'node:child_process';
|
|
70
|
+
import * as fs from 'node:fs';
|
|
71
|
+
import * as os from 'node:os';
|
|
72
|
+
import * as path from 'node:path';
|
|
73
|
+
|
|
74
|
+
import {
|
|
75
|
+
detectInstalledTools,
|
|
76
|
+
knownToolIds,
|
|
77
|
+
resolveToolBinary,
|
|
78
|
+
} from '../../install/toolDetection.js';
|
|
79
|
+
import { parseVersionOutput } from '../../install/agentSwitchDetection.js';
|
|
80
|
+
import { claude_config_dir } from './claude_plugin.js';
|
|
81
|
+
import { hardenedSpawnEnv } from './spawn_env.js';
|
|
82
|
+
import { resolve_with_fallback, type EnvMap } from './user_global_paths.js';
|
|
83
|
+
|
|
84
|
+
/** Where a present credential was found. Never a claim that it works. */
|
|
85
|
+
export type AuthSource = 'cli-subscription' | 'cli-api-key' | 'key-file' | 'env-key';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* How a call against this provider is paid for.
|
|
89
|
+
*
|
|
90
|
+
* `subscription` — covered by a flat-rate plan the user already pays; the USD
|
|
91
|
+
* cost gate does not apply (`cli_call_budget` guards the quota instead).
|
|
92
|
+
* `per-token` — metered spend; the full `cost_budget` applies.
|
|
93
|
+
*/
|
|
94
|
+
export type BillingClass = 'subscription' | 'per-token';
|
|
95
|
+
|
|
96
|
+
/** Version string when parseable, the literal `'unknown'` when not, `null` when unprobed. */
|
|
97
|
+
export type HostVersion = string | 'unknown' | null;
|
|
98
|
+
|
|
99
|
+
export interface DetectedHost {
|
|
100
|
+
readonly id: string;
|
|
101
|
+
readonly installed: boolean;
|
|
102
|
+
/** Resolved executable path, or `null` when the host has no binary on `$PATH`. */
|
|
103
|
+
readonly binary: string | null;
|
|
104
|
+
readonly version: HostVersion;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface DetectedAuth {
|
|
108
|
+
readonly provider: string;
|
|
109
|
+
readonly source: AuthSource;
|
|
110
|
+
/** A filesystem path, or `env:<VAR>` — presence evidence only. */
|
|
111
|
+
readonly evidence: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface DetectedKey {
|
|
115
|
+
readonly provider: string;
|
|
116
|
+
/** `api_key_ref`-shaped: `file:<name>.key` or `env:<VAR>`. */
|
|
117
|
+
readonly ref: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface EnvironmentReport {
|
|
121
|
+
readonly hosts: readonly DetectedHost[];
|
|
122
|
+
readonly auth: readonly DetectedAuth[];
|
|
123
|
+
readonly keys: readonly DetectedKey[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Provider → (default CLI binary, community-wrapper flag).
|
|
128
|
+
*
|
|
129
|
+
* Mirrors the `CliClient` subclass attributes without importing the client
|
|
130
|
+
* layer. The boolean is the load-bearing billing input: a VENDOR-OFFICIAL CLI
|
|
131
|
+
* (`false`) runs under the user's subscription; a COMMUNITY wrapper (`true`)
|
|
132
|
+
* shells out to the same paid API and stays metered. Canonical here so
|
|
133
|
+
* `cmd_doctor` and this module cannot drift.
|
|
134
|
+
*/
|
|
135
|
+
export const PROVIDER_CLI_META: Readonly<Record<string, readonly [string, boolean]>> = {
|
|
136
|
+
anthropic: ['claude', false],
|
|
137
|
+
openai: ['codex', false],
|
|
138
|
+
gemini: ['gemini', false],
|
|
139
|
+
xai: ['grok', true],
|
|
140
|
+
perplexity: ['perplexity', true],
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Provider → environment variable carrying a raw API key. */
|
|
144
|
+
const PROVIDER_ENV_KEY: Readonly<Record<string, string>> = {
|
|
145
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
146
|
+
openai: 'OPENAI_API_KEY',
|
|
147
|
+
gemini: 'GEMINI_API_KEY',
|
|
148
|
+
xai: 'XAI_API_KEY',
|
|
149
|
+
perplexity: 'PERPLEXITY_API_KEY',
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** Provider → key-file name under the user-global root (`file:` ref target). */
|
|
153
|
+
const PROVIDER_KEY_FILE: Readonly<Record<string, string>> = {
|
|
154
|
+
anthropic: 'anthropic.key',
|
|
155
|
+
openai: 'openai.key',
|
|
156
|
+
gemini: 'gemini.key',
|
|
157
|
+
xai: 'xai.key',
|
|
158
|
+
perplexity: 'perplexity.key',
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Providers this module knows how to classify, in a stable order. */
|
|
162
|
+
export function knownProviders(): readonly string[] {
|
|
163
|
+
return Object.keys(PROVIDER_CLI_META);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** True when `provider`'s CLI is a community wrapper around the paid API. */
|
|
167
|
+
export function isCommunityCli(provider: string): boolean {
|
|
168
|
+
return PROVIDER_CLI_META[provider]?.[1] ?? false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Billing class from (provider, detected auth source) — NEVER from transport.
|
|
173
|
+
*
|
|
174
|
+
* This signature is the invariant: there is no transport / mode parameter, so
|
|
175
|
+
* no caller can make billing depend on which transport was chosen. That is
|
|
176
|
+
* what keeps the per-provider rules intact when `auto` picks a transport at
|
|
177
|
+
* invocation time: a vendor-official CLI logged in under a subscription is
|
|
178
|
+
* unmetered; everything else — including a community CLI and including an
|
|
179
|
+
* unknown source — is metered, i.e. deliberately over-gated.
|
|
180
|
+
*
|
|
181
|
+
* `null` source means "nothing detected" and classifies as `per-token`: the
|
|
182
|
+
* safe direction is to keep the USD gate on.
|
|
183
|
+
*/
|
|
184
|
+
export function classifyBilling(provider: string, source: AuthSource | null): BillingClass {
|
|
185
|
+
if (source !== 'cli-subscription') return 'per-token';
|
|
186
|
+
if (isCommunityCli(provider)) return 'per-token';
|
|
187
|
+
return 'subscription';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Codex CLI home — `CODEX_HOME` env override (the codex CLI's own
|
|
192
|
+
* convention), else `~/.codex`. Single definition; `cmd_doctor` imports it.
|
|
193
|
+
*/
|
|
194
|
+
export function codexHome(env?: EnvMap | null): string {
|
|
195
|
+
const map = env ?? process.env;
|
|
196
|
+
const override = (map['CODEX_HOME'] ?? '').trim();
|
|
197
|
+
return override !== '' ? override : path.join(homeOf(env), '.codex');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function homeOf(env?: EnvMap | null): string {
|
|
201
|
+
const map = env ?? process.env;
|
|
202
|
+
const injected = (map['HOME'] ?? '').trim();
|
|
203
|
+
return injected !== '' ? injected : os.homedir();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const VERSION_PROBE_TIMEOUT_MS = 2000;
|
|
207
|
+
|
|
208
|
+
/** Injected `<binary> --version` runner. Returns raw combined output, or `null`. */
|
|
209
|
+
export type VersionProbe = (binary: string) => string | null;
|
|
210
|
+
|
|
211
|
+
export interface DetectEnvironmentOptions {
|
|
212
|
+
/** Home directory override (tests / fixture machines). */
|
|
213
|
+
readonly home?: string;
|
|
214
|
+
/** `$PATH` override (tests / fixture machines). */
|
|
215
|
+
readonly pathEnv?: string;
|
|
216
|
+
/** Environment map override; also carries `CODEX_HOME` / `EVENT4U_CONFIG_HOME`. */
|
|
217
|
+
readonly env?: EnvMap;
|
|
218
|
+
/**
|
|
219
|
+
* Version probe override. Omit to spawn `<binary> --version` with a
|
|
220
|
+
* hardened env and a 2s timeout; pass `() => null` to skip probing.
|
|
221
|
+
*/
|
|
222
|
+
readonly probeVersion?: VersionProbe;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `<binary> --version` under a hardened env. Never throws — a spawn error,
|
|
227
|
+
* timeout, signal, or unparseable output all collapse to `null`, which the
|
|
228
|
+
* caller degrades to `'unknown'`.
|
|
229
|
+
*
|
|
230
|
+
* Hardened per `docs/spawn-site-policy.md` (this runs on the consumer runtime
|
|
231
|
+
* via `doctor`). Note the env scrub deliberately does NOT strip
|
|
232
|
+
* `CLAUDE_CONFIG_DIR` — see `docs/threat-model.md` row i.
|
|
233
|
+
*/
|
|
234
|
+
function defaultProbeVersion(binary: string): string | null {
|
|
235
|
+
try {
|
|
236
|
+
const res = spawnSync(binary, ['--version'], {
|
|
237
|
+
timeout: VERSION_PROBE_TIMEOUT_MS,
|
|
238
|
+
encoding: 'utf-8',
|
|
239
|
+
windowsHide: true,
|
|
240
|
+
env: hardenedSpawnEnv(),
|
|
241
|
+
});
|
|
242
|
+
if (res.error !== undefined || res.signal !== null) return null;
|
|
243
|
+
return `${res.stdout ?? ''}\n${res.stderr ?? ''}`;
|
|
244
|
+
} catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isFile(p: string): boolean {
|
|
250
|
+
try {
|
|
251
|
+
return fs.statSync(p).isFile();
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function exists(p: string): boolean {
|
|
258
|
+
try {
|
|
259
|
+
return fs.existsSync(p);
|
|
260
|
+
} catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Classify a codex `auth.json` by which credential shape it carries.
|
|
267
|
+
*
|
|
268
|
+
* OAuth tokens ⇒ the user logged in with a ChatGPT plan (`cli-subscription`);
|
|
269
|
+
* anything else ⇒ metered (`cli-api-key`). A present-but-unreadable or
|
|
270
|
+
* malformed file also reports `cli-api-key` — the over-gated direction, since
|
|
271
|
+
* assuming a plan would switch the USD gate off on no evidence.
|
|
272
|
+
*/
|
|
273
|
+
function classifyCodexAuth(authPath: string): AuthSource {
|
|
274
|
+
let data: unknown;
|
|
275
|
+
try {
|
|
276
|
+
data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
|
277
|
+
} catch {
|
|
278
|
+
// Present but unreadable: over-gate (metered) rather than assume a plan.
|
|
279
|
+
return 'cli-api-key';
|
|
280
|
+
}
|
|
281
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
282
|
+
return 'cli-api-key';
|
|
283
|
+
}
|
|
284
|
+
const d = data as Record<string, unknown>;
|
|
285
|
+
const tokens = d['tokens'];
|
|
286
|
+
const hasOauth =
|
|
287
|
+
typeof tokens === 'object' &&
|
|
288
|
+
tokens !== null &&
|
|
289
|
+
!Array.isArray(tokens) &&
|
|
290
|
+
Object.keys(tokens as Record<string, unknown>).length > 0;
|
|
291
|
+
if (hasOauth) return 'cli-subscription';
|
|
292
|
+
return 'cli-api-key';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Provider-specific `cli-subscription` evidence path, or `null` when none applies. */
|
|
296
|
+
function cliSubscriptionEvidence(
|
|
297
|
+
provider: string,
|
|
298
|
+
home: string,
|
|
299
|
+
env: EnvMap,
|
|
300
|
+
claudeConfigDir: string,
|
|
301
|
+
): { source: AuthSource; evidence: string } | null {
|
|
302
|
+
if (provider === 'anthropic') {
|
|
303
|
+
const configDir = claudeConfigDir;
|
|
304
|
+
for (const rel of ['.credentials.json', 'credentials.json']) {
|
|
305
|
+
const p = path.join(configDir, rel);
|
|
306
|
+
if (isFile(p)) return { source: 'cli-subscription', evidence: p };
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
if (provider === 'openai') {
|
|
311
|
+
const p = path.join(codexHome(env), 'auth.json');
|
|
312
|
+
if (isFile(p)) return { source: classifyCodexAuth(p), evidence: p };
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
if (provider === 'gemini') {
|
|
316
|
+
const p = path.join(home, '.gemini', 'oauth_creds.json');
|
|
317
|
+
if (isFile(p)) return { source: 'cli-subscription', evidence: p };
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
// xai / perplexity ship no subscription login — their CLIs read an API key.
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function detectHosts(opts: DetectEnvironmentOptions): DetectedHost[] {
|
|
325
|
+
const installedMap = detectInstalledTools({
|
|
326
|
+
...(opts.home !== undefined ? { home: opts.home } : {}),
|
|
327
|
+
pathEnv: opts.pathEnv,
|
|
328
|
+
});
|
|
329
|
+
const probe = opts.probeVersion ?? defaultProbeVersion;
|
|
330
|
+
const out: DetectedHost[] = [];
|
|
331
|
+
for (const id of knownToolIds()) {
|
|
332
|
+
const binary = resolveToolBinary(id, opts.pathEnv);
|
|
333
|
+
let version: HostVersion = null;
|
|
334
|
+
if (binary !== null) {
|
|
335
|
+
let raw: string | null;
|
|
336
|
+
try {
|
|
337
|
+
raw = probe(binary);
|
|
338
|
+
} catch {
|
|
339
|
+
raw = null;
|
|
340
|
+
}
|
|
341
|
+
// An unparseable version degrades to 'unknown' — never throws,
|
|
342
|
+
// never omits the host from the report.
|
|
343
|
+
version = raw === null ? 'unknown' : (parseVersionOutput(raw) ?? 'unknown');
|
|
344
|
+
}
|
|
345
|
+
out.push({ id, installed: installedMap[id] ?? false, binary, version });
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function detectAuth(opts: DetectEnvironmentOptions): {
|
|
351
|
+
auth: DetectedAuth[];
|
|
352
|
+
keys: DetectedKey[];
|
|
353
|
+
} {
|
|
354
|
+
const env = opts.env ?? process.env;
|
|
355
|
+
const home = opts.home ?? homeOf(opts.env);
|
|
356
|
+
// Delegate to claude_plugin's resolver on the live path (it owns the
|
|
357
|
+
// CLAUDE_CONFIG_DIR override); derive from the injected home otherwise.
|
|
358
|
+
const claudeConfigDir =
|
|
359
|
+
opts.home === undefined && opts.env === undefined
|
|
360
|
+
? claude_config_dir()
|
|
361
|
+
: ((env['CLAUDE_CONFIG_DIR'] ?? '').trim() || path.join(home, '.claude'));
|
|
362
|
+
const auth: DetectedAuth[] = [];
|
|
363
|
+
const keys: DetectedKey[] = [];
|
|
364
|
+
|
|
365
|
+
for (const provider of knownProviders()) {
|
|
366
|
+
const cli = cliSubscriptionEvidence(provider, home, env, claudeConfigDir);
|
|
367
|
+
if (cli !== null) {
|
|
368
|
+
auth.push({ provider, source: cli.source, evidence: cli.evidence });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const keyFile = PROVIDER_KEY_FILE[provider];
|
|
372
|
+
if (keyFile !== undefined) {
|
|
373
|
+
let resolved: string | null = null;
|
|
374
|
+
try {
|
|
375
|
+
resolved = resolve_with_fallback(keyFile, { env });
|
|
376
|
+
} catch {
|
|
377
|
+
resolved = null;
|
|
378
|
+
}
|
|
379
|
+
if (resolved !== null && exists(resolved)) {
|
|
380
|
+
auth.push({ provider, source: 'key-file', evidence: resolved });
|
|
381
|
+
keys.push({ provider, ref: `file:${keyFile}` });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const envVar = PROVIDER_ENV_KEY[provider];
|
|
386
|
+
if (envVar !== undefined && (env[envVar] ?? '').trim() !== '') {
|
|
387
|
+
auth.push({ provider, source: 'env-key', evidence: `env:${envVar}` });
|
|
388
|
+
keys.push({ provider, ref: `env:${envVar}` });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return { auth, keys };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Strongest auth source detected for `provider`, or `null`.
|
|
396
|
+
*
|
|
397
|
+
* "Strongest" is the transport-preference order `auto` walks: a vendor CLI
|
|
398
|
+
* login beats a stored key. Pure over an already-built report.
|
|
399
|
+
*/
|
|
400
|
+
export function strongestAuth(
|
|
401
|
+
report: EnvironmentReport,
|
|
402
|
+
provider: string,
|
|
403
|
+
): DetectedAuth | null {
|
|
404
|
+
const order: readonly AuthSource[] = [
|
|
405
|
+
'cli-subscription',
|
|
406
|
+
'cli-api-key',
|
|
407
|
+
'key-file',
|
|
408
|
+
'env-key',
|
|
409
|
+
];
|
|
410
|
+
for (const source of order) {
|
|
411
|
+
const hit = report.auth.find((a) => a.provider === provider && a.source === source);
|
|
412
|
+
if (hit !== undefined) return hit;
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let _cached: EnvironmentReport | null = null;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Build the environment report.
|
|
421
|
+
*
|
|
422
|
+
* Cached per process for the no-argument call (the production shape) so
|
|
423
|
+
* `doctor` and transport resolution observe one consistent snapshot; any call
|
|
424
|
+
* that injects options bypasses the cache and returns a fresh read, so tests
|
|
425
|
+
* and fixture machines never poison each other. Reset with
|
|
426
|
+
* `resetEnvironmentCache()`.
|
|
427
|
+
*/
|
|
428
|
+
export function detectEnvironment(opts: DetectEnvironmentOptions = {}): EnvironmentReport {
|
|
429
|
+
const isDefaultCall = Object.keys(opts).length === 0;
|
|
430
|
+
if (isDefaultCall && _cached !== null) return _cached;
|
|
431
|
+
const hosts = detectHosts(opts);
|
|
432
|
+
const { auth, keys } = detectAuth(opts);
|
|
433
|
+
const report: EnvironmentReport = { hosts, auth, keys };
|
|
434
|
+
if (isDefaultCall) _cached = report;
|
|
435
|
+
return report;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Drop the per-process memo. Tests only. */
|
|
439
|
+
export function resetEnvironmentCache(): void {
|
|
440
|
+
_cached = null;
|
|
441
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout-independent package-root resolution for the `_cli` delegate commands.
|
|
3
|
+
*
|
|
4
|
+
* `src/scripts/_cli/*.ts` sits three levels below the package root, so every
|
|
5
|
+
* delegate command derived the root with three hard-coded parent hops. Then the
|
|
6
|
+
* delegate precompile (`fa51c5a54`) started bundling those same modules into
|
|
7
|
+
* `dist/cli-delegate/*.js` — **two** levels below the root. Fixed hop counts are
|
|
8
|
+
* correct in exactly one of the two layouts, and nothing failed loudly: three
|
|
9
|
+
* hops from the bundle lands on `<pkg>/..`, which for a scoped install is
|
|
10
|
+
* `node_modules/@event4u`. `conformance` then reported `dist/router.json` and
|
|
11
|
+
* `src/scripts/hook_manifest.yaml` as missing — files that ship fine — and the
|
|
12
|
+
* 9.11.0 release PR's `tarball E2E` went red on both Node majors.
|
|
13
|
+
*
|
|
14
|
+
* The fix is to stop counting hops. Anchor on the marker that actually defines
|
|
15
|
+
* the root: the `package.json` whose `name` is this package. That answer holds
|
|
16
|
+
* for the source tree, for any bundle depth, for npm-global, npx, and
|
|
17
|
+
* vendor/bin symlink invocations — and it keeps holding if either directory
|
|
18
|
+
* ever moves.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
/** `name` in this package's own package.json — the root marker. */
|
|
26
|
+
export const PACKAGE_NAME = '@event4u/agent-config';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Bound on the upward walk. Deep enough for any real install path, finite so a
|
|
30
|
+
* malformed start value can never spin.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_ASCENT = 16;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Absolute package root for the module at `fromUrlOrPath` (pass
|
|
36
|
+
* `import.meta.url`).
|
|
37
|
+
*
|
|
38
|
+
* Walks up looking for the package.json whose `name` is {@link PACKAGE_NAME}.
|
|
39
|
+
* A consumer's own package.json encountered on the way is skipped by that name
|
|
40
|
+
* check, so an install nested under another project resolves correctly.
|
|
41
|
+
*
|
|
42
|
+
* `legacyHops` is the pre-existing hop count for the *source* layout and is
|
|
43
|
+
* used only if no marker is found. Falling back beats throwing during CLI
|
|
44
|
+
* startup: a wrong path yields one actionable "missing file" message, while an
|
|
45
|
+
* exception takes down every command in the delegate.
|
|
46
|
+
*/
|
|
47
|
+
export function resolvePackageRoot(fromUrlOrPath: string, legacyHops = 3): string {
|
|
48
|
+
const start = fromUrlOrPath.startsWith('file:') ? fileURLToPath(fromUrlOrPath) : fromUrlOrPath;
|
|
49
|
+
const startDir = path.dirname(start);
|
|
50
|
+
|
|
51
|
+
let dir = startDir;
|
|
52
|
+
for (let i = 0; i < MAX_ASCENT; i++) {
|
|
53
|
+
const manifest = path.join(dir, 'package.json');
|
|
54
|
+
if (fs.existsSync(manifest)) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8')) as { name?: unknown };
|
|
57
|
+
if (parsed.name === PACKAGE_NAME) {
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Unreadable / unparseable manifest — not our marker; keep ascending.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const parent = path.dirname(dir);
|
|
65
|
+
if (parent === dir) {
|
|
66
|
+
break; // filesystem root
|
|
67
|
+
}
|
|
68
|
+
dir = parent;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return path.resolve(startDir, ...(Array.from({ length: legacyHops }, () => '..') as string[]));
|
|
72
|
+
}
|