@event4u/agent-config 9.10.0 → 9.12.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 +111 -0
- package/README.md +20 -16
- package/agents/templates/.ai-council.yml.example +481 -0
- package/agents/templates/.ai-video.xml.example +247 -0
- package/dist/agent-src/commands/fix/ci.md +29 -0
- package/dist/agent-src/commands/pr/create.md +60 -0
- package/dist/agent-src/contexts/authority/kernel-rule-edits.md +18 -2
- package/dist/agent-src/rules/design-fidelity.md +2 -2
- package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +1 -0
- package/dist/agent-src/skills/design-intelligence/SKILL.md +27 -1
- package/dist/agent-src/skills/design-intelligence/data/font-pairings-reference.csv +74 -74
- package/dist/agent-src/skills/design-intelligence/data/manifest.json +2 -0
- package/dist/agent-src/skills/design-tokens/scripts/tokens.ts +0 -7
- 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/skills/typography-system/SKILL.md +55 -11
- package/dist/agent-src/skills/ui-apply-generic/SKILL.md +180 -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 +34 -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 +23 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/review.ts +24 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +332 -0
- package/dist/agent-src/templates/scripts/work_engine/stack/detect.ts +449 -11
- 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-523HCFYO.js +90 -0
- package/dist/cli-delegate/chunk-736JCWLM.js +1104 -0
- package/dist/cli-delegate/chunk-7HQ52DHU.js +74 -0
- package/dist/cli-delegate/chunk-A3HJ6NTI.js +1084 -0
- package/dist/cli-delegate/chunk-EJYIGZ3T.js +125 -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-HYWSRNUX.js +179 -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-QFKQLQ7X.js +10835 -0
- package/dist/cli-delegate/chunk-TEGJU2JX.js +6908 -0
- package/dist/cli-delegate/chunk-TJIAM5FW.js +133 -0
- package/dist/cli-delegate/chunk-XX6TOPD7.js +715 -0
- package/dist/cli-delegate/cmd_analyze_session.js +294 -0
- package/dist/cli-delegate/cmd_conformance.js +643 -0
- package/dist/cli-delegate/cmd_converge.js +326 -0
- package/dist/cli-delegate/cmd_doctor.js +142 -0
- package/dist/cli-delegate/cmd_explain.js +2576 -0
- package/dist/cli-delegate/cmd_export.js +298 -0
- package/dist/cli-delegate/cmd_fleet.js +333 -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 +153 -0
- package/dist/cli-delegate/cmd_prune.js +446 -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 +225 -0
- package/dist/cli-delegate/cmd_sync.js +266 -0
- package/dist/cli-delegate/cmd_uninstall.js +657 -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 +307 -0
- package/dist/cli-delegate/cmd_versions.js +370 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +45 -16
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +13 -7
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -4
- 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 +634 -409
- package/dist/install/ruleInScope.js +123 -0
- package/dist/install/ruleInScope.js.map +1 -0
- package/dist/install/rule_scope.js +7 -3
- 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 +12 -3
- package/dist/install/wizard-plan.js.map +1 -1
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1553 -2728
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +6 -22
- package/dist/scripts/condense.js.map +1 -1
- package/docs/guidelines/design-fidelity-mechanics.md +14 -0
- package/package.json +4 -2
- package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +34 -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 +23 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +24 -1
- package/src/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +332 -0
- package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +449 -11
- 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/ruleInScope.ts +133 -0
- package/src/install/rule_scope.ts +7 -3
- package/src/install/toolDetection.ts +39 -8
- package/src/install/wizard-plan.ts +13 -3
- 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/global_deploy_inventory.ts +38 -2
- 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_installer_import_purity.ts +295 -0
- package/src/scripts/check_pack_size.ts +188 -0
- package/src/scripts/condense.ts +7 -28
- package/src/scripts/council_cli.ts +6 -2
- package/src/scripts/evaluator_umbrella.sh +34 -1
- package/src/scripts/install-hooks.sh +36 -4
- package/src/scripts/install.ts +210 -5
- package/src/scripts/lint_eval_fixture_citations.ts +113 -0
- package/src/scripts/lint_ui_stack_bundles.ts +201 -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
|
+
}
|
|
@@ -254,6 +254,13 @@ function json_string_ascii(s: string): string {
|
|
|
254
254
|
return out;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Predicate over a SOURCE file path — `true` = the file arrives in the install.
|
|
259
|
+
* Identical shape to `PlanSource.fileFilter` (`src/install/plan.ts`), so the
|
|
260
|
+
* plan path and the deploy path can share one predicate instance.
|
|
261
|
+
*/
|
|
262
|
+
export type FileFilter = (srcFile: string) => boolean;
|
|
263
|
+
|
|
257
264
|
/**
|
|
258
265
|
* Anchor-relative paths the deploy of `src` → `<anchor>/<dest_rel>` maintains
|
|
259
266
|
* — written OR skipped-as-identical.
|
|
@@ -262,8 +269,20 @@ function json_string_ascii(s: string): string {
|
|
|
262
269
|
* symlinked files resolve to file entries, symlinked subdirectories are walked
|
|
263
270
|
* through their resolved targets. `dest_rel` may be empty (`""`) for plan
|
|
264
271
|
* entries that deploy into the anchor root.
|
|
272
|
+
*
|
|
273
|
+
* `file_filter` MUST be the same predicate the copy was given. This set is what
|
|
274
|
+
* the reaper treats as "should exist"; a file the copy skipped but this set
|
|
275
|
+
* still lists is never reaped, and a file this set omits while the copy wrote it
|
|
276
|
+
* is deleted on the next deploy. Passing the filter to one and not the other is
|
|
277
|
+
* the failure mode the rule-scoping work exists to avoid — see
|
|
278
|
+
* `install._deploy_global_content`, which derives one filter and hands it to
|
|
279
|
+
* both.
|
|
265
280
|
*/
|
|
266
|
-
export function expected_deploy_files(
|
|
281
|
+
export function expected_deploy_files(
|
|
282
|
+
src: string,
|
|
283
|
+
dest_rel: string,
|
|
284
|
+
file_filter: FileFilter | null = null,
|
|
285
|
+
): Set<string> {
|
|
267
286
|
const out = new Set<string>();
|
|
268
287
|
let src_stat: fs.Stats;
|
|
269
288
|
try {
|
|
@@ -273,6 +292,9 @@ export function expected_deploy_files(src: string, dest_rel: string): Set<string
|
|
|
273
292
|
return out;
|
|
274
293
|
}
|
|
275
294
|
if (!src_stat.isDirectory()) {
|
|
295
|
+
if (file_filter !== null && !file_filter(src)) {
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
276
298
|
out.add(as_posix(dest_rel));
|
|
277
299
|
return out;
|
|
278
300
|
}
|
|
@@ -298,6 +320,12 @@ export function expected_deploy_files(src: string, dest_rel: string): Set<string
|
|
|
298
320
|
_walk(fs.realpathSync(entry), rel);
|
|
299
321
|
continue;
|
|
300
322
|
}
|
|
323
|
+
// The predicate sees the SOURCE path, never `rel`: the rule-scope
|
|
324
|
+
// predicate reads the file's own frontmatter tags, so a dest-relative
|
|
325
|
+
// path would not resolve. Same contract as the copy twin.
|
|
326
|
+
if (file_filter !== null && !file_filter(entry)) {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
301
329
|
out.add(as_posix(rel));
|
|
302
330
|
}
|
|
303
331
|
};
|
|
@@ -554,7 +582,15 @@ function prune_empty_dirs(prune_candidates: Set<string>, anchor_resolved: string
|
|
|
554
582
|
}
|
|
555
583
|
|
|
556
584
|
/** `anchor in node.parents` — is `anchor` a strict ancestor of `node`? */
|
|
557
|
-
|
|
585
|
+
/**
|
|
586
|
+
* Is `node` strictly inside `anchor`? Pure path arithmetic, no I/O — callers
|
|
587
|
+
* pass already-resolved (realpath'd) paths when symlink escape matters.
|
|
588
|
+
*
|
|
589
|
+
* Exported for `install._copy_dir_dereferencing_symlinks`, which must reject a
|
|
590
|
+
* symlink whose target leaves the package root. One definition rather than a
|
|
591
|
+
* second copy of the `path.relative` idiom.
|
|
592
|
+
*/
|
|
593
|
+
export function is_ancestor(anchor: string, node: string): boolean {
|
|
558
594
|
const rel = path.relative(anchor, node);
|
|
559
595
|
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
560
596
|
}
|