@kaydash9999/banker-plugins 0.14.0 → 0.15.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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +47 -0
- package/README.md +23 -11
- package/codex/manifest.json +5 -5
- package/codex/transform-matrix.md +5 -5
- package/commands/front-qa.md +2 -2
- package/commands/setup.md +13 -4
- package/hooks/graceful-pause.mjs +222 -0
- package/hooks/hooks.json +2 -1
- package/hooks/register.mjs +12 -0
- package/package.json +4 -1
- package/skills/3d-intro-build/SKILL.md +1 -1
- package/skills/3d-intro-setup/SKILL.md +1 -1
- package/skills/all-in-one/SKILL.md +1 -1
- package/skills/append_wiki/SKILL.md +1 -1
- package/skills/arch-diagram/SKILL.md +1 -1
- package/skills/audit-mock/SKILL.md +1 -1
- package/skills/audit-security/SKILL.md +1 -1
- package/skills/audit-web-page/SKILL.md +1 -1
- package/skills/cleansing-memory/SKILL.md +1 -1
- package/skills/compact-copy/SKILL.md +1 -1
- package/skills/compact-wiki/SKILL.md +1 -1
- package/skills/curation/SKILL.md +1 -1
- package/skills/deep-init/SKILL.md +1 -1
- package/skills/deep-research/SKILL.md +1 -1
- package/skills/docs-setup/SKILL.md +1 -1
- package/skills/humanizer/SKILL.md +1 -1
- package/skills/interval-report/SKILL.md +1 -1
- package/skills/lineage/SKILL.md +146 -16
- package/skills/lineage/lineage.py +1091 -107
- package/skills/make-notion-guide/SKILL.md +1 -1
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/motion-graphic-setup/SKILL.md +1 -1
- package/skills/nothing-design/SKILL.md +1 -1
- package/skills/obsidizer/SKILL.md +1 -1
- package/skills/omc-reference/SKILL.md +1 -1
- package/skills/payload-mon/SKILL.md +1 -1
- package/skills/pdf-vision-extract/SKILL.md +1 -1
- package/skills/play-qa/SKILL.md +1 -1
- package/skills/ralph-qa/SKILL.md +263 -112
- package/skills/ralph-qa/references/gemini-read-only.toml +9 -0
- package/skills/ralph-qa/references/gemini-seat.mjs +175 -0
- package/skills/ralph-qa/references/payload-scan.mjs +130 -0
- package/skills/ralph-qa/references/verifier-probe.mjs +872 -780
- package/skills/ready-compact/SKILL.md +1 -1
- package/skills/refresh-git-ignore/SKILL.md +1 -1
- package/skills/refresh-readme/SKILL.md +1 -1
- package/skills/rfp-author/SKILL.md +1 -1
- package/skills/setup-bypass-permissions/SKILL.md +123 -0
- package/skills/setup-bypass-permissions/agents/openai.yaml +4 -0
- package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +381 -0
- package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.ps1 +247 -0
- package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +241 -0
- package/skills/{harness-factory → setup-harness-factory}/SKILL.md +3 -3
- package/skills/setup-insane-search/SKILL.md +1 -1
- package/skills/setup-java/SKILL.md +1 -1
- package/skills/setup-lsp/SKILL.md +1 -1
- package/skills/setup-mcp/SKILL.md +1 -1
- package/skills/setup-node/SKILL.md +1 -1
- package/skills/setup-omc/SKILL.md +1 -1
- package/skills/setup-omc-hud/SKILL.md +15 -1
- package/skills/setup-omc-hud/scripts/claude-update-last.mjs +247 -0
- package/skills/setup-playwright/SKILL.md +1 -1
- package/skills/setup-pwsh/SKILL.md +1 -1
- package/skills/setup-python/SKILL.md +1 -1
- package/skills/setup-sandbox/SKILL.md +1 -1
- package/skills/setup-stitch/SKILL.md +1 -1
- package/skills/setup-tmux/SKILL.md +1 -1
- package/skills/smart-compact/SKILL.md +1 -1
- package/skills/summary-wiki/SKILL.md +1 -1
- package/skills/tone-compact/SKILL.md +1 -1
- package/skills/ultra-init/SKILL.md +1 -1
- package/skills/ultra-interview/SKILL.md +1 -1
- package/skills/ultra-ui-qa/SKILL.md +1 -1
- package/skills/update-banker/SKILL.md +1 -1
- package/skills/vertical-pptx/SKILL.md +1 -1
- package/skills/vertical-pptx-setup/SKILL.md +1 -1
- package/skills/visual-ralph/SKILL.md +1 -1
- package/skills/graceful_pause/SKILL.md +0 -95
|
@@ -1,860 +1,952 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
*
|
|
2
|
+
/**
|
|
3
|
+
* verifier-probe: which external CLI seats ralph-qa can seat, and with which model,
|
|
4
|
+
* worked out from this machine alone. It sends no HTTP request and no prompt itself. The
|
|
5
|
+
* only child processes are `codex --version`, `codex debug models --bundled` and
|
|
6
|
+
* `opencode --version`, the last with opencode's model-list refresh turned off.
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
* (1) 전역 fetch 는 Node 17.5 플래그·18.0 기본이라 package.json engines(node>=16.7)에서 미보장이다.
|
|
7
|
-
* (2) 저장소 선례 — hooks/update-checkin.mjs:18-19 가 이미 node:http + node:https 를 직접 import 한다.
|
|
8
|
+
* node verifier-probe.mjs --runtime=claude|codex [--codex[=<model>]] [--gemini[=<model>]] [--opencode[=<provider/model>]]
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
+
* Only the CLIs named by a flag are looked at; with none, there are no external seats
|
|
11
|
+
* (reason "no-flag"). For each one it prints a seat with a decision:
|
|
12
|
+
* adopt seat it with `model` (`reason` says why that model is the best known one)
|
|
13
|
+
* ask the best model is unclear: offer `options` (best first; may be empty, then ask
|
|
14
|
+
* for a model by name). `fallback` is what to seat when nobody can be asked:
|
|
15
|
+
* a model, "@cli-default" (run the CLI without a model flag; only when its
|
|
16
|
+
* config names no model at all) or null, and then `noAskReason` is the reason
|
|
17
|
+
* to report
|
|
18
|
+
* unseated `reason` says why not
|
|
19
|
+
* The author's own family is never adopted, offered or used as a fallback (Claude Code runs
|
|
20
|
+
* Claude models, Codex runs GPT models and whatever family its config.toml names), and
|
|
21
|
+
* neither is codex when the author is Codex. When the runtime is unknown, or the flag and the
|
|
22
|
+
* environment disagree on it, both families are left out and codex is not seated. A model
|
|
23
|
+
* whose family cannot be read from its name is never adopted on its own or used as a
|
|
24
|
+
* fallback; it is listed under `unclassified`. A model given with the flag is taken as is,
|
|
25
|
+
* with `familyKnown: false` when its family cannot be read. A name that could carry shell
|
|
26
|
+
* syntax is dropped with a note, wherever it comes from: the seat commands put the chosen
|
|
27
|
+
* name on a command line.
|
|
10
28
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* "키는 유효한데 base_url 을 안 적어 뒀다"가 이 경로의 통상 케이스이고, 그걸
|
|
25
|
-
* 미구성으로 처리하면 유효한 크리덴셜을 환경 탓으로 버리게 된다.
|
|
26
|
-
* TOML 파서를 설치하지 않는다 — 필요한 두 필드만 줄 단위로 뽑는다(의존성 0).
|
|
27
|
-
* codex login status / auth.json 은 유효성 근거로 쓰지 않는다 — 실측상 거짓 음성이고
|
|
28
|
-
* (Not logged in 이라 답하면서 codex 는 정상 동작한다) exit code 로도 갈리지 않는다.
|
|
29
|
-
* T2 생존 GET {base_url}/models (Authorization: Bearer → 401·403 이면 api-key 헤더로 재시도)
|
|
30
|
-
* 2xx=유효 / 401·403=dead-credential / 404=route-missing /
|
|
31
|
-
* 그 외 4xx·5xx=http-error / ECONNREFUSED=unreachable / 하드 타임아웃=timeout
|
|
32
|
-
* 재시도는 base_url 이 **명시 설정**(codex provider config·base URL env)에서 온 경우에만
|
|
33
|
-
* 허용한다. 기본 엔드포인트로 내려간 경우엔 한 번만 보낸다 — 아래 [기본 엔드포인트] 참조.
|
|
34
|
-
* T3 계열 2xx 응답의 data[].id 를 계열 분류 → 저자 계열을 제외한 후보만 채택.
|
|
35
|
-
* 계열 분류표는 상수다 — env override 를 두지 않는다. 분류표는 파라미터가 아니라
|
|
36
|
-
* 자기승인을 막는 안전 장치 본체이고, env 하나로 claude-* 를 claude 가 아니게
|
|
37
|
-
* 만들 수 있으면 코드로 승격시킨 방어를 환경변수로 되돌리는 셈이다.
|
|
38
|
-
* 미분류 id 는 fail-closed 로 제외하고 개수를 unclassified 에 남긴다.
|
|
39
|
-
* 분류 가능한 id 가 0 → unclassifiable-catalog / 후보가 0 → no-independent-model
|
|
40
|
-
* T4 강등 착석 후(1회 이상 판정을 낸 뒤)의 인증실패·쿼터·레이트리밋은 좌석 상실이며
|
|
41
|
-
* 실행을 INCONCLUSIVE 로 종결시킨다. 착석 전 무효(T2·T3)는 좌석이 애초에 없는 것이라
|
|
42
|
-
* 상실이 아니다. T4 는 프로브가 아니라 저자 런타임이 루프 중에 판정한다.
|
|
43
|
-
*
|
|
44
|
-
* ── gemini 사다리 — 좌석이 둘이고 서로 독립이다 ─────────────────────────────────────────────
|
|
45
|
-
*
|
|
46
|
-
* 요구가 문장 두 개로 갈라져 있다: "gemini CLI 가 설치돼 있고 유효하면 쓴다"와 "gemini 모델을
|
|
47
|
-
* 쓸 수 있는 정보(크리덴셜)가 유효하면 curl·python 으로 쓴다". 그래서 좌석도 둘이고, CLI 부재가
|
|
48
|
-
* 크리덴셜 경로를 가리면 안 된다. 가리면 환경이 유효한 키를 줬는데도 "환경 탓에 모델 축이 안
|
|
49
|
-
* 덮였다"(cli-absent)고 보고하게 되는데, 그게 이 스킬이 막겠다고 선언한 허위 커버리지다.
|
|
50
|
-
*
|
|
51
|
-
* [CLI 좌석] observed.cli.gemini · observed.external.gemini
|
|
52
|
-
* G0 존재 PATH 스캔으로 gemini 를 찾는다 (Windows 는 PATHEXT 고려).
|
|
53
|
-
* G1 인증 gemini 는 OpenAI 호환이 아니고 자체 인증을 쓴다 — T1(codex provider)·T2(OpenAI 호환
|
|
54
|
-
* GET /models)로는 판정할 수 없다. 인증 후보 env 이름을 상수 목록으로 확인한다.
|
|
55
|
-
* 전부 부재 → valid:false, reason:"gemini-no-auth"
|
|
56
|
-
* G2 생존 T2 에 대응하는 0토큰 프리플라이트가 gemini CLI 에 있는지 이 머신에서 확인할 수 없었다.
|
|
57
|
-
* → 프로브는 G2 를 수행하지 않고 cli.gemini.liveChecked=false 로 남긴다.
|
|
58
|
-
* "확인했는데 통과"가 아니라 "확인하지 않았다"를 출력에 적는다.
|
|
59
|
-
* 아래 GA2 는 CLI 가 아니라 API 를 재는 것이라 G2 를 대신하지 않는다.
|
|
60
|
-
* G3 전송 gemini -p "<프롬프트>" (비대화형 1회 호출). G2 가 없으므로 이 좌석은
|
|
61
|
-
* "첫 검증 호출이 곧 프로브"인 좌석이다 — 이 파일이 아니라 SKILL.md 산문이 낸다.
|
|
62
|
-
* 첫 호출이 실패하면 착석 전 무효(= 좌석 없음)이고 좌석 상실이 아니다.
|
|
63
|
-
*
|
|
64
|
-
* [크리덴셜 좌석] observed.geminiApi · observed.external.geminiApi
|
|
65
|
-
* GA1 인증 G1 과 같은 env 목록을 본다. CLI 존재 여부는 보지 않는다 — 별개 좌석이다.
|
|
66
|
-
* GA2 생존 모델 목록 GET {base}/v1beta/models?key=<KEY> — T2 와 동등한 0토큰 판정이다.
|
|
67
|
-
* 2xx=유효 / 401·403=dead-credential / 404=route-missing / 그 외·refused·timeout=무효.
|
|
68
|
-
* 추론 호출(POST)은 어느 경로에서도 내지 않는다.
|
|
69
|
-
* base 는 상수 기본값 + RALPH_QA_GEMINI_BASE_URL override 한 쌍이다.
|
|
70
|
-
* 인증이 쿼리스트링에 실리므로 출력에는 redactUrl 로 search 를 떼어 낸 base 만 적는다.
|
|
71
|
-
* GA3 전송 실제 검증 호출은 T0-b 의 transport 로 나간다(요청 형태는 OpenAI 호환이 아니다) —
|
|
72
|
-
* 이 파일이 아니라 SKILL.md 산문이 낸다. transport 가 none 이면 좌석 무효.
|
|
73
|
-
* GA4 강등 T4 와 같다.
|
|
74
|
-
*
|
|
75
|
-
* 비대칭은 그래도 남는다: 두 좌석 다 OpenAI 호환 카탈로그가 아니라 계열 필터(T3)를 못 돌린다.
|
|
76
|
-
* 저자 계열 배제는 "엔드포인트·CLI 가 애초에 다른 벤더"라는 사실에 의존한다(저자 계열은 claude·gpt).
|
|
77
|
-
* 이 머신에 gemini CLI 가 없어 G2·G3 은 한 번도 실행된 적이 없다(미검증). GA2 는 스텁 서버로 검증했다.
|
|
78
|
-
*
|
|
79
|
-
* ── 송신 게이트 두 개 ───────────────────────────────────────────────────────────────────────
|
|
80
|
-
*
|
|
81
|
-
* [--external=off] HTTP 를 한 건도 내지 않는다. 정적 탐지(PATH 스캔·config 파싱·env 이름 확인)에서
|
|
82
|
-
* 멈추고 T2·GA2 를 건너뛴다. 이 플래그가 좌석 계산에서만 소비되면 SKILL.md 가 지시하는
|
|
83
|
-
* "프로브(관측): 미실행" 이 거짓 보고가 된다 — 실제로는 키가 벤더 호스트로 나간 뒤이기 때문이다.
|
|
84
|
-
* 미실행은 관측 필드에 명시한다: probed:false · status:null · live:null · reason:"external-off".
|
|
85
|
-
* status:null 을 홀로 두지 않는 이유는 "안 했다"와 "했는데 실패했다"가 같은 모양이 되기 때문이다.
|
|
86
|
-
*
|
|
87
|
-
* [기본 엔드포인트] base_url 을 못 찾았을 때 키 env 이름이 지목하는 표준 벤더 호스트로 내려가는
|
|
88
|
-
* 경로는 편의지만 무고지로 두면 위험하다: OPENAI_API_KEY 가 OpenAI 가 아닌 프로바이더(게이트웨이·
|
|
89
|
-
* 프록시·사설 서버)의 키를 담는 구성이 흔하고, 그런 환경의 base URL 은 앱 설정에 있지 env 에 없다.
|
|
90
|
-
* 그대로 두면 남의 프로바이더 키가 이름만 같은 벤더로 나간다. 그래서 셋을 건다.
|
|
91
|
-
* (1) RALPH_QA_NO_DEFAULT_ENDPOINT 가 설정되면 내려가지 않는다 → 미구성(not-configured) 미착석.
|
|
92
|
-
* (2) 기본 엔드포인트일 때는 401·403 대체 헤더 재시도를 하지 않는다 — 키를 두 번 보내지 않는다.
|
|
93
|
-
* (3) 내려간 사실은 source(default:*)와 notes 양쪽에 항상 남는다.
|
|
94
|
-
*
|
|
95
|
-
* ── 출력 ────────────────────────────────────────────────────────────────────────────────────
|
|
96
|
-
* 최상위 키는 정확히 observed · declared · notes 셋이다.
|
|
97
|
-
* observed = 프로브가 직접 측정한 것 (probeMs·httpRequests·transport·cli·api·geminiApi·external·
|
|
98
|
-
* candidates·unclassified)
|
|
99
|
-
* probeMs 는 HTTP 를 한 건이라도 낸 실행에만 숫자가 있다(안 냈으면 null). 잰 것이 없는데
|
|
100
|
-
* 작은 수를 적으면 "쟀는데 빨랐다"로 읽힌다. 실제로 낸 요청 수는 httpRequests 가 담는다.
|
|
101
|
-
* declared = 프로브가 플래그 값을 되받아 적는 것 (authorFamily*·seats·externalAbsentReason)
|
|
102
|
-
* 프로브는 서브에이전트를 띄우지 않으므로 seats.backbone 을 관측할 수 없다. 두 블록을 섞으면
|
|
103
|
-
* "필터가 호출됐다는 가시적 흔적"이 검증 불가한 선언값에 희석된다.
|
|
104
|
-
*
|
|
105
|
-
* 키 값은 stdout/stderr 어디에도 내지 않는다. 키 이름(keyEnv)만 낸다.
|
|
106
|
-
*
|
|
107
|
-
* 사용: node verifier-probe.mjs --json [--author-family <v>] [--external auto|off] [--agents N]
|
|
108
|
-
* env: RALPH_QA_PROBE_TIMEOUT_MS(하드 타임아웃) · RALPH_QA_NO_DEFAULT_ENDPOINT(기본 엔드포인트 차단)
|
|
109
|
-
* RALPH_QA_BASE_URL·OPENAI_BASE_URL·RALPH_QA_GEMINI_BASE_URL(명시 base URL)
|
|
29
|
+
* Sources, all local: codex's bundled catalog (ordered by `priority`) and its config files
|
|
30
|
+
* (/etc/codex/managed_config.toml, config.toml under $CODEX_HOME or ~/.codex, /etc/codex/config.toml;
|
|
31
|
+
* their MCP servers, however the TOML defines them, are listed as `mcpServers` so the seat can
|
|
32
|
+
* turn them off, and a server it cannot turn off keeps codex unseated, as does a managed file
|
|
33
|
+
* that sets a key the seat turns off); gemini's $GEMINI_MODEL, its system and user settings.json
|
|
34
|
+
* (the user's under $GEMINI_CLI_HOME when set; settings that widen the seat's workspace keep it
|
|
35
|
+
* unseated, and usage statistics left on are noted) and whether its system policy folder holds
|
|
36
|
+
* policies;
|
|
37
|
+
* opencode's global, $OPENCODE_CONFIG, ~/.opencode, $OPENCODE_CONFIG_DIR and managed configs
|
|
38
|
+
* and its local copy of the models.dev list. Left out on purpose: the reviewed repository's own
|
|
39
|
+
* gemini and opencode settings and $OPENCODE_CONFIG_CONTENT, because the seats run in the
|
|
40
|
+
* payload folder with project config off and replace that variable. Whether a provider
|
|
41
|
+
* actually serves a model is not checked here: the seat's first call is the check.
|
|
110
42
|
*/
|
|
111
|
-
import {
|
|
112
|
-
import {
|
|
113
|
-
import
|
|
114
|
-
import
|
|
115
|
-
import {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
[
|
|
125
|
-
|
|
126
|
-
['llama', [/llama/]],
|
|
127
|
-
['mistral', [/mistral/, /mixtral/, /codestral/, /ministral/, /magistral/, /pixtral/, /devstral/]],
|
|
128
|
-
['deepseek', [/deepseek/]],
|
|
129
|
-
['qwen', [/qwen/, /qwq/]],
|
|
130
|
-
['grok', [/grok/, /(^|[-._/])xai/]],
|
|
131
|
-
['cohere', [/cohere/, /command-r/, /(^|[-._/])aya[-._0-9]/]],
|
|
132
|
-
['phi', [/(^|[-._/])phi[-._0-9]/]],
|
|
133
|
-
['nova', [/(^|[-._/])(nova|titan)[-._0-9]/]],
|
|
134
|
-
['gpt', [/(^|[-._/])gpt/, /openai/, /(^|[-._/])o[1-9](\b|[-._])/, /davinci/, /babbage/, /codex/]],
|
|
135
|
-
];
|
|
43
|
+
import { spawnSync } from "node:child_process";
|
|
44
|
+
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
45
|
+
import { homedir } from "node:os";
|
|
46
|
+
import { delimiter, dirname, isAbsolute, join } from "node:path";
|
|
47
|
+
import { fileURLToPath } from "node:url";
|
|
48
|
+
|
|
49
|
+
const SEAT_NAMES = ["codex", "gemini", "opencode"];
|
|
50
|
+
|
|
51
|
+
// The value of the `--runtime` at `i` and how many words it takes: `--runtime=x` one,
|
|
52
|
+
// `--runtime x` two, unless x is another flag, which is then read in its own turn.
|
|
53
|
+
function runtimeValue(argv, i, inline) {
|
|
54
|
+
if (inline !== undefined) return [inline, 1];
|
|
55
|
+
const next = argv[i + 1];
|
|
56
|
+
return next !== undefined && !next.startsWith("--") ? [next, 2] : ["", 1];
|
|
57
|
+
}
|
|
136
58
|
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const ABSENT_REASONS = [
|
|
174
|
-
'author-off',
|
|
175
|
-
'probe-unseated status=<code>',
|
|
176
|
-
'no-independent-model',
|
|
177
|
-
'unclassifiable-catalog',
|
|
178
|
-
'no-transport',
|
|
179
|
-
'cli-absent',
|
|
180
|
-
'gemini-call-failed',
|
|
181
|
-
// 목적지는 있는데 키 env 가 비어 프로브를 아예 내지 못했다. 프로브가 안 돈 이상 probe-unseated 가
|
|
182
|
-
// 아니고, 키가 없는 것은 실제로 환경 사실이므로 환경 계열이다.
|
|
183
|
-
'no-credential',
|
|
184
|
-
// 저자가 RALPH_QA_NO_DEFAULT_ENDPOINT 로 기본 벤더 엔드포인트 하강을 껐고, 그래서 좌석이 못 섰다.
|
|
185
|
-
// cli-absent 로 흘리면 안 된다 — 그 토큰은 "환경이 경로를 안 줬다"는 뜻이라 보고에서
|
|
186
|
-
// APPROVE(모델축 미커버 — 환경)로 매핑되는데, 여기서 원인은 환경이 아니라 저자 설정이다.
|
|
187
|
-
'default-endpoint-off',
|
|
59
|
+
export function parseArgs(argv) {
|
|
60
|
+
const requested = {};
|
|
61
|
+
const unknown = [];
|
|
62
|
+
let runtime = "";
|
|
63
|
+
for (let i = 0; i < argv.length; ) {
|
|
64
|
+
const seat = /^--(codex|gemini|opencode)(?:=(.*))?$/.exec(argv[i]);
|
|
65
|
+
const rt = /^--runtime(?:=(.*))?$/.exec(argv[i]);
|
|
66
|
+
let used = 1;
|
|
67
|
+
if (seat) requested[seat[1]] = seat[2] || null;
|
|
68
|
+
else if (rt) [runtime, used] = runtimeValue(argv, i, rt[1]);
|
|
69
|
+
else unknown.push(argv[i]);
|
|
70
|
+
i += used;
|
|
71
|
+
}
|
|
72
|
+
return { requested, runtime: runtime.trim().toLowerCase() || null, unknown };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A model's family from anywhere in its name: provider prefixes, region prefixes and gateway
|
|
76
|
+
// spellings (`amazon-bedrock/us.anthropic.claude-…`, `litellm/sonnet`, `gitlab/duo-chat-gpt-5`).
|
|
77
|
+
// The two author families match loosely on purpose: a miss would seat the author's own model
|
|
78
|
+
// as an independent one.
|
|
79
|
+
const FAMILIES = [
|
|
80
|
+
["claude", /claude|anthropic|(^|[^a-z])(opus|sonnet|haiku)([^a-z]|$)/],
|
|
81
|
+
["gpt", /openai|(^|[^a-z])(chat)?gpt(?![a-z])|(^|[^a-z])codex|(^|[^a-z])o\d/],
|
|
82
|
+
["gemini", /gemini/],
|
|
83
|
+
["gemma", /gemma/],
|
|
84
|
+
["qwen", /qwen/],
|
|
85
|
+
["llama", /llama/],
|
|
86
|
+
["mistral", /mistral|mixtral|codestral|devstral|magistral/],
|
|
87
|
+
["deepseek", /deepseek/],
|
|
88
|
+
["grok", /grok|(^|[^a-z])xai[/.]/],
|
|
89
|
+
["phi", /(^|[^a-z])phi-?\d/],
|
|
90
|
+
["kimi", /kimi|moonshot/],
|
|
91
|
+
["glm", /(^|[^a-z])glm|zhipu/],
|
|
92
|
+
["minimax", /minimax/],
|
|
93
|
+
["nova", /amazon\.nova|(^|[^a-z])nova-(pro|lite|micro|premier)/],
|
|
94
|
+
["cohere", /cohere|command-r/],
|
|
188
95
|
];
|
|
96
|
+
const FAMILY_RE = Object.fromEntries(FAMILIES);
|
|
97
|
+
// gemini's own aliases; it resolves them to Gemini models (`auto` routes between Pro and Flash).
|
|
98
|
+
const GEMINI_ALIASES = new Set(["pro", "auto", "flash", "flash-lite"]);
|
|
99
|
+
|
|
100
|
+
export function familyOf(model) {
|
|
101
|
+
const name = String(model ?? "").toLowerCase();
|
|
102
|
+
if (GEMINI_ALIASES.has(name)) return "gemini";
|
|
103
|
+
for (const [family, re] of FAMILIES) if (re.test(name)) return family;
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// True when the name matches any author family at all, not just as its first family.
|
|
108
|
+
export function isAuthorFamily(model, authorFamilies) {
|
|
109
|
+
const name = String(model ?? "").toLowerCase();
|
|
110
|
+
return authorFamilies.some((family) => FAMILY_RE[family].test(name));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// What a model name may hold to go on a seat's command line: letters, digits and `._:/@+-`,
|
|
114
|
+
// starting with a letter or digit. No spaces, quotes, `$`, backticks or other shell syntax.
|
|
115
|
+
const SAFE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/;
|
|
116
|
+
export const safeModel = (m) => typeof m === "string" && SAFE_MODEL.test(m);
|
|
189
117
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
function parseArgs(argv) {
|
|
198
|
-
const out = { json: false, authorFamily: null, external: 'auto', agents: 3, unknown: [] };
|
|
199
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
200
|
-
const arg = argv[i];
|
|
201
|
-
const eq = arg.indexOf('=');
|
|
202
|
-
const name = eq > 0 ? arg.slice(0, eq) : arg;
|
|
203
|
-
const inlineValue = eq > 0 ? arg.slice(eq + 1) : null;
|
|
204
|
-
const next = () => (inlineValue !== null ? inlineValue : argv[++i]);
|
|
205
|
-
if (name === '--json') out.json = true;
|
|
206
|
-
else if (name === '--author-family') out.authorFamily = (next() || '').trim() || null;
|
|
207
|
-
else if (name === '--external') out.external = (next() || '').trim() === 'off' ? 'off' : 'auto';
|
|
208
|
-
else if (name === '--agents') {
|
|
209
|
-
const n = Number.parseInt(next(), 10);
|
|
210
|
-
if (Number.isFinite(n) && n >= 1) out.agents = n;
|
|
211
|
-
} else out.unknown.push(name);
|
|
118
|
+
// The safe names among `models`, with a note for each one dropped (its length, not its text).
|
|
119
|
+
function safeOnly(models, where, notes) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const m of models) {
|
|
122
|
+
if (m == null || m === "") continue;
|
|
123
|
+
if (safeModel(m)) out.push(m);
|
|
124
|
+
else notes.push(`model name dropped (${where}, ${String(m).length} characters): it holds characters a command line cannot carry safely`);
|
|
212
125
|
}
|
|
213
126
|
return out;
|
|
214
127
|
}
|
|
215
128
|
|
|
216
|
-
|
|
129
|
+
const AUTHOR_FAMILIES = { claude: ["claude"], codex: ["gpt"] };
|
|
130
|
+
const UNKNOWN_AUTHOR = ["claude", "gpt"];
|
|
217
131
|
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
return
|
|
132
|
+
// Environment markers each runtime leaves on the shells it starts. Both or neither: unknown.
|
|
133
|
+
export const RUNTIME_MARKERS = {
|
|
134
|
+
claude: ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"],
|
|
135
|
+
codex: ["CODEX_THREAD_ID", "CODEX_SANDBOX", "CODEX_SANDBOX_NETWORK_DISABLED", "CODEX_MANAGED_BY_NPM",
|
|
136
|
+
"CODEX_MANAGED_BY_BUN", "CODEX_MANAGED_BY_PNPM", "CODEX_MANAGED_PACKAGE_ROOT"],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export function detectRuntime(env) {
|
|
140
|
+
const hits = Object.keys(RUNTIME_MARKERS).filter((rt) => RUNTIME_MARKERS[rt].some((k) => env[k]));
|
|
141
|
+
return hits.length === 1 ? hits[0] : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The runtime to probe for: the flag, the markers when there is no flag, and neither when they
|
|
145
|
+
// disagree (the probe then treats the runtime as unknown and says why).
|
|
146
|
+
export function resolveRuntime(flag, env) {
|
|
147
|
+
const detected = detectRuntime(env);
|
|
148
|
+
if (flag && detected && flag !== detected) return `${flag} (the environment says ${detected})`;
|
|
149
|
+
return flag ?? detected;
|
|
236
150
|
}
|
|
237
151
|
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
152
|
+
// Why a seat ends up empty. The probe sets most; the skill sets model-declined (the person
|
|
153
|
+
// dropped the seat when asked), cli-call-failed when the first call failed with no candidate
|
|
154
|
+
// left to switch to, and the `noAskReason` of an ask it could not put to anyone. The probe sets
|
|
155
|
+
// cli-call-failed too when every call would fail (codex's legacy profile) or the seat could not
|
|
156
|
+
// hold its guards (MCP servers it cannot list or turn off, a managed or requirements file over
|
|
157
|
+
// the seat's flags, gemini's system policies, settings that widen gemini's workspace); `notes`
|
|
158
|
+
// then says what to change. SKILL.md lists the same set.
|
|
159
|
+
export const ABSENT_REASONS = [
|
|
160
|
+
'no-flag',
|
|
161
|
+
'self-family',
|
|
162
|
+
'self-runtime',
|
|
163
|
+
'model-declined',
|
|
164
|
+
'runtime-unknown',
|
|
165
|
+
'cli-absent',
|
|
166
|
+
'no-independent-model',
|
|
167
|
+
'no-default-model',
|
|
168
|
+
'cli-call-failed',
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
// Why a model was adopted (the first five) or asked about (the rest). SKILL.md lists the same set.
|
|
172
|
+
export const DECISION_REASONS = [
|
|
173
|
+
'flag',
|
|
174
|
+
'catalog-top-is-configured',
|
|
175
|
+
'catalog-top',
|
|
176
|
+
'cli-pro-alias',
|
|
177
|
+
'single-candidate',
|
|
178
|
+
'top-differs-from-configured',
|
|
179
|
+
'custom-provider',
|
|
180
|
+
'no-catalog',
|
|
181
|
+
'open-model-set',
|
|
182
|
+
'configured-vs-pro',
|
|
183
|
+
'several-candidates',
|
|
184
|
+
'no-candidate',
|
|
185
|
+
'config-not-understood',
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
export const CLI_DEFAULT = "@cli-default";
|
|
189
|
+
|
|
190
|
+
// opencode refreshes its models.dev list on every start, `--version` included, unless told not to.
|
|
191
|
+
const OPENCODE_QUIET = { OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_AUTOUPDATE: "1" };
|
|
192
|
+
|
|
193
|
+
const firstLine = (res) => (res?.status === 0 ? String(res.stdout).trim().split("\n")[0] || null : null);
|
|
194
|
+
const absent = (cli) => ({ cli, present: false, decision: "unseated", reason: "cli-absent" });
|
|
195
|
+
const adopt = (seat, model, reason) => ({ ...seat, decision: "adopt", model, reason });
|
|
196
|
+
const withNotes = (seat, notes) => (notes.length ? { ...seat, notes: [...new Set([...(seat.notes ?? []), ...notes])] } : seat);
|
|
197
|
+
|
|
198
|
+
// TOML keys: bare, "basic" or 'literal', dotted with optional spaces (`a."b.c" . d`).
|
|
199
|
+
const KEY_SEG = String.raw`(?:"(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)`;
|
|
200
|
+
const KEY_PATH = String.raw`${KEY_SEG}(?:\s*\.\s*${KEY_SEG})*`;
|
|
201
|
+
const HEADER_RE = new RegExp(String.raw`^\s*\[(\[?)\s*(${KEY_PATH})\s*\]\]?\s*(?:#.*)?$`);
|
|
202
|
+
const ASSIGN_RE = new RegExp(String.raw`^\s*(${KEY_PATH})\s*=\s*(.*)$`);
|
|
203
|
+
const SEG_RE = /"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+)/g;
|
|
204
|
+
const STRING_VALUE = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*(?:#.*)?$/;
|
|
205
|
+
|
|
206
|
+
// A dotted key's segments, quotes taken off (escapes kept as written).
|
|
207
|
+
const keyPath = (text) => [...text.matchAll(SEG_RE)].map((m) => m[1] ?? m[2] ?? m[3]);
|
|
208
|
+
|
|
209
|
+
// The walk's state: where it is (open string, nesting depth, table, array being gathered) and
|
|
210
|
+
// what it found so far.
|
|
211
|
+
const tomlState = () => ({ values: {}, arrays: {}, mcpServers: new Set(), mcpUnread: false, modelKeys: false,
|
|
212
|
+
open: null, depth: 0, table: [], roots: new Set(), array: null });
|
|
213
|
+
|
|
214
|
+
// The index just past the one-line string that opens at `i` (the line's end when it never closes).
|
|
215
|
+
function stringEnd(text, i) {
|
|
216
|
+
const q = text[i];
|
|
217
|
+
for (let j = i + 1; j < text.length; j += 1) {
|
|
218
|
+
if (q === '"' && text[j] === "\\") j += 1;
|
|
219
|
+
else if (text[j] === q) return j + 1;
|
|
247
220
|
}
|
|
248
|
-
return
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const header = /^\s*\[([^\]]+)\]\s*$/.exec(raw);
|
|
262
|
-
if (header) { section = header[1].trim(); continue; }
|
|
263
|
-
const kv = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"([^"]*)"/.exec(raw);
|
|
264
|
-
if (!kv) continue;
|
|
265
|
-
const [, key, value] = kv;
|
|
266
|
-
if (section === null && key === 'model_provider') out.modelProvider = value;
|
|
267
|
-
if (section === null && key === 'model') out.model = value;
|
|
268
|
-
if (provider && wanted.indexOf(section) >= 0) {
|
|
269
|
-
if (key === 'base_url') out.baseUrl = value;
|
|
270
|
-
if (key === 'env_key') out.envKey = value;
|
|
221
|
+
return text.length;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The index just past the delimiter that closes the multi-line string `open` (""" or ''') at or
|
|
225
|
+
// after `i`, -1 when this line does not close it. A basic one skips backslash escapes, and a run
|
|
226
|
+
// of up to five quotes ends with the last three of them (TOML lets one or two stand inside).
|
|
227
|
+
function multiEnd(text, i, open) {
|
|
228
|
+
for (let j = i; j < text.length; j += 1) {
|
|
229
|
+
if (open === '"""' && text[j] === "\\") j += 1;
|
|
230
|
+
else if (text.startsWith(open, j)) {
|
|
231
|
+
let k = j + 3;
|
|
232
|
+
while (k < j + 5 && text[k] === open[0]) k += 1;
|
|
233
|
+
return k;
|
|
271
234
|
}
|
|
272
235
|
}
|
|
273
|
-
return
|
|
236
|
+
return -1;
|
|
274
237
|
}
|
|
275
238
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
notes.push('codex config 의 model_provider 는 있으나 [model_providers.' + first.modelProvider + '] 의 base_url 을 찾지 못했다.');
|
|
239
|
+
// One step of walkValue outside a string: the index after the token at `i`, -1 at a comment.
|
|
240
|
+
function valueStep(state, text, i) {
|
|
241
|
+
const three = text.slice(i, i + 3);
|
|
242
|
+
if (three === '"""' || three === "'''") {
|
|
243
|
+
state.open = three;
|
|
244
|
+
return i + 3;
|
|
245
|
+
}
|
|
246
|
+
const c = text[i];
|
|
247
|
+
if (c === "#") return -1;
|
|
248
|
+
if (c === '"' || c === "'") return stringEnd(text, i);
|
|
249
|
+
if (c === "[" || c === "{") state.depth += 1;
|
|
250
|
+
if (c === "]" || c === "}") state.depth = Math.max(0, state.depth - 1);
|
|
251
|
+
return i + 1;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// A value's text as it runs across lines: how deep `[` and `{` nesting is still open, and the
|
|
255
|
+
// multi-line string (""" or ''') still open, strings and comments aside. Inline tables can span
|
|
256
|
+
// lines too (TOML 1.1, which codex 0.144.5 reads).
|
|
257
|
+
function walkValue(state, text) {
|
|
258
|
+
let i = 0;
|
|
259
|
+
while (i >= 0 && i < text.length) {
|
|
260
|
+
if (!state.open) {
|
|
261
|
+
i = valueStep(state, text, i);
|
|
262
|
+
continue;
|
|
303
263
|
}
|
|
264
|
+
const end = multiEnd(text, i, state.open);
|
|
265
|
+
if (end < 0) return;
|
|
266
|
+
state.open = null;
|
|
267
|
+
i = end;
|
|
304
268
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// The MCP server a key path under mcp_servers names. One `mcp_servers = { ... }` inline table
|
|
272
|
+
// is not read for names: the probe could not say which servers to turn off.
|
|
273
|
+
function noteMcp(state, full) {
|
|
274
|
+
if (full[0] !== "mcp_servers") return;
|
|
275
|
+
if (full.length > 1) state.mcpServers.add(full[1]);
|
|
276
|
+
else state.mcpUnread = true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// True when a key path ends in `model`, or its inline-table value sets one.
|
|
280
|
+
const setsModel = (full, value) => full[full.length - 1] === "model" || (value.startsWith("{") && /[{,]\s*model\s*=/.test(value));
|
|
281
|
+
|
|
282
|
+
// The strings of a top-level array, gathered line by line until it closes; comments aside.
|
|
283
|
+
const ARRAY_ITEM = /"((?:[^"\\]|\\.)*)"|'([^']*)'|#.*/g;
|
|
284
|
+
function gatherArray(state, text) {
|
|
285
|
+
if (!state.array) return;
|
|
286
|
+
state.array.text += text + "\n";
|
|
287
|
+
if (state.open || state.depth > 0) return;
|
|
288
|
+
state.arrays[state.array.key] = [...state.array.text.matchAll(ARRAY_ITEM)].filter((m) => !m[0].startsWith("#")).map((m) => m[1] ?? m[2]);
|
|
289
|
+
state.array = null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// One `key = value` line: the MCP server it defines, whether it sets a model in any table,
|
|
293
|
+
// the multi-line string or array it opens, and a top-level string value or array of strings.
|
|
294
|
+
function assignment(state, path, value) {
|
|
295
|
+
const full = [...state.table, ...path];
|
|
296
|
+
state.roots.add(full[0]);
|
|
297
|
+
noteMcp(state, full);
|
|
298
|
+
if (setsModel(full, value)) state.modelKeys = true;
|
|
299
|
+
if (full.length === 1 && value.startsWith("[")) state.array = { key: full[0], text: "" };
|
|
300
|
+
walkValue(state, value);
|
|
301
|
+
gatherArray(state, value);
|
|
302
|
+
if (state.open || state.depth > 0) return;
|
|
303
|
+
const str = state.table.length === 0 && path.length === 1 ? STRING_VALUE.exec(value) : null;
|
|
304
|
+
if (str) state.values[path[0]] = str[1] ?? str[2];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// A table header: the table later keys go in, and the MCP server a [mcp_servers.<name>...] names.
|
|
308
|
+
function enterTable(state, header) {
|
|
309
|
+
state.roots.add(keyPath(header[2])[0]);
|
|
310
|
+
state.table = header[1] ? ["[[]]"] : keyPath(header[2]);
|
|
311
|
+
if (state.table[0] === "mcp_servers" && state.table.length > 1) state.mcpServers.add(state.table[1]);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// One line of readToml into `state`: walked inside a multi-line string, array or inline table
|
|
315
|
+
// (a `model =` there still counts), else a table header or a `key = value`.
|
|
316
|
+
function tomlLine(state, line) {
|
|
317
|
+
if (state.open || state.depth > 0) {
|
|
318
|
+
if (!state.open && /(?:^|[{,])\s*model\s*=/.test(line)) state.modelKeys = true;
|
|
319
|
+
walkValue(state, line);
|
|
320
|
+
gatherArray(state, line);
|
|
321
|
+
return;
|
|
310
322
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
323
|
+
const header = HEADER_RE.exec(line);
|
|
324
|
+
if (header) return enterTable(state, header);
|
|
325
|
+
const assign = ASSIGN_RE.exec(line);
|
|
326
|
+
if (assign) assignment(state, keyPath(assign[1]), assign[2]);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The servers each [mcp_servers] header in the text names when the lines after it are walked
|
|
330
|
+
// alone, up to the next header. A cross-check on the walk, which a string it misreads (or text
|
|
331
|
+
// that only looks like a header) could lead past the block; a misread here only adds names.
|
|
332
|
+
function rawMcpBlockNames(lines) {
|
|
333
|
+
const names = [];
|
|
334
|
+
lines.forEach((line, i) => {
|
|
335
|
+
const header = HEADER_RE.exec(line);
|
|
336
|
+
if (!header || header[1] || keyPath(header[2]).join(".") !== "mcp_servers") return;
|
|
337
|
+
const state = tomlState();
|
|
338
|
+
state.table = ["mcp_servers"];
|
|
339
|
+
for (const next of lines.slice(i + 1)) {
|
|
340
|
+
if (!state.open && state.depth === 0 && HEADER_RE.test(next)) break;
|
|
341
|
+
tomlLine(state, next);
|
|
321
342
|
}
|
|
322
|
-
|
|
323
|
-
return {
|
|
324
|
-
configured: true,
|
|
325
|
-
source: 'default:openai',
|
|
326
|
-
baseUrl: DEFAULT_OPENAI_BASE_URL,
|
|
327
|
-
keyEnv: fallbackKeyEnv,
|
|
328
|
-
key: env[fallbackKeyEnv],
|
|
329
|
-
preferredModel: null,
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
return { configured: false, source: 'none', baseUrl: null, keyEnv: null, key: '', preferredModel: null };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// GA1: gemini 크리덴셜 해석. CLI 존재 여부를 보지 않는다 — 두 경로는 별개 좌석이다.
|
|
336
|
-
// 키 값은 읽되 절대 출력하지 않는다(호출자는 keyEnv 이름만 낸다).
|
|
337
|
-
// reason 은 configured:false 일 때의 사유다 — 크리덴셜이 없는 것(no-credential)과 목적지가 없는
|
|
338
|
-
// 것(not-configured)은 다르고, 좌석 사유가 그 둘을 뭉개면 보고가 원인을 못 짚는다.
|
|
339
|
-
function resolveGeminiApi(env, notes = []) {
|
|
340
|
-
const keyEnv = GEMINI_AUTH_ENVS.find((name) => (env[name] || '').trim()) || null;
|
|
341
|
-
const override = (env[GEMINI_BASE_URL_ENV] || '').trim();
|
|
342
|
-
const seat = (source, baseUrl, reason) => ({
|
|
343
|
-
configured: Boolean(keyEnv) && !reason,
|
|
344
|
-
source,
|
|
345
|
-
baseUrl,
|
|
346
|
-
keyEnv: reason === 'not-configured' ? null : keyEnv,
|
|
347
|
-
key: keyEnv && reason !== 'not-configured' ? env[keyEnv] : '',
|
|
348
|
-
reason: keyEnv ? reason : 'no-credential',
|
|
343
|
+
names.push(...state.mcpServers);
|
|
349
344
|
});
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
345
|
+
return names;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Every `mcp_servers.<name>` the text spells, comment lines aside, and the keys under each
|
|
349
|
+
// [mcp_servers] header: a cross-check on the walk.
|
|
350
|
+
const RAW_MCP = new RegExp(String.raw`mcp_servers\s*\.\s*(${KEY_SEG})`, "g");
|
|
351
|
+
const rawMcpNames = (lines) => lines.filter((l) => !/^\s*#/.test(l))
|
|
352
|
+
.flatMap((l) => [...l.matchAll(RAW_MCP)].map((m) => keyPath(m[1])[0])).concat(rawMcpBlockNames(lines));
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
// What the probe reads from a TOML file: its top-level `key = "string"` pairs (quoted keys too),
|
|
356
|
+
// the first segment of every key and table (`roots`), the MCP server names wherever they are
|
|
357
|
+
// defined (table headers, under [mcp_servers], dotted keys), whether servers were written in a
|
|
358
|
+
// form it does not list or that the text names beyond what it listed (`mcpUnread`), whether any
|
|
359
|
+
// table sets a model (`modelKeys`) and whether a multi-line string, array or inline table was
|
|
360
|
+
// left open, and the strings of each top-level array (`arrays`). Enough for those keys; not a
|
|
361
|
+
// TOML parser.
|
|
362
|
+
export function readToml(text) {
|
|
363
|
+
const state = tomlState();
|
|
364
|
+
const lines = String(text ?? "").split(/\r?\n/);
|
|
365
|
+
for (const line of lines) tomlLine(state, line);
|
|
366
|
+
const unlisted = rawMcpNames(lines).filter((n) => !state.mcpServers.has(n));
|
|
367
|
+
return {
|
|
368
|
+
values: state.values,
|
|
369
|
+
roots: [...state.roots],
|
|
370
|
+
arrays: state.arrays,
|
|
371
|
+
mcpServers: [...state.mcpServers],
|
|
372
|
+
mcpUnread: state.mcpUnread || unlisted.length > 0,
|
|
373
|
+
modelKeys: state.modelKeys,
|
|
374
|
+
broken: state.open !== null || state.depth > 0,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export const topLevelToml = (text) => readToml(text).values;
|
|
379
|
+
|
|
380
|
+
// The comment or trailing comma that starts at `i`, as the index just past it; -1 when none.
|
|
381
|
+
function skipNoise(text, i) {
|
|
382
|
+
if (text.startsWith("//", i)) {
|
|
383
|
+
const end = text.indexOf("\n", i);
|
|
384
|
+
return end < 0 ? text.length : end;
|
|
385
|
+
}
|
|
386
|
+
if (text.startsWith("/*", i)) {
|
|
387
|
+
const end = text.indexOf("*/", i + 2);
|
|
388
|
+
return end < 0 ? text.length : end + 2;
|
|
355
389
|
}
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
390
|
+
if (text[i] === ",") {
|
|
391
|
+
let j = i + 1;
|
|
392
|
+
while (/\s/.test(text[j] ?? "")) j += 1;
|
|
393
|
+
if (text[j] === "}" || text[j] === "]") return i + 1;
|
|
359
394
|
}
|
|
360
|
-
return
|
|
395
|
+
return -1;
|
|
361
396
|
}
|
|
362
397
|
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
398
|
+
// JSON with comments and trailing commas (JSONC, as opencode and gemini accept) made plain.
|
|
399
|
+
export function stripJsonc(text) {
|
|
400
|
+
let out = "";
|
|
401
|
+
let inString = false;
|
|
402
|
+
for (let i = 0; i < text.length; ) {
|
|
403
|
+
const c = text[i];
|
|
404
|
+
if (inString) {
|
|
405
|
+
const step = c === "\\" ? 2 : 1;
|
|
406
|
+
out += text.slice(i, i + step);
|
|
407
|
+
if (c === '"') inString = false;
|
|
408
|
+
i += step;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const next = skipNoise(text, i);
|
|
412
|
+
if (next >= 0) {
|
|
413
|
+
i = next;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (c === '"') inString = true;
|
|
417
|
+
out += c;
|
|
418
|
+
i += 1;
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
367
421
|
}
|
|
368
422
|
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
function geminiModelsUrl(baseUrl, key) {
|
|
423
|
+
// { value } for a readable JSONC text, { unreadable: true } for a broken one, {} when absent.
|
|
424
|
+
function readJsonc(text) {
|
|
425
|
+
if (text == null) return {};
|
|
373
426
|
try {
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
url.search = '';
|
|
377
|
-
url.pathname = url.pathname.replace(/\/+$/, '') + GEMINI_MODELS_PATH;
|
|
378
|
-
url.searchParams.set(GEMINI_KEY_PARAM, key);
|
|
379
|
-
return url.toString();
|
|
427
|
+
const value = JSON.parse(stripJsonc(String(text)));
|
|
428
|
+
return value && typeof value === "object" && !Array.isArray(value) ? { value } : { unreadable: true };
|
|
380
429
|
} catch {
|
|
381
|
-
return
|
|
430
|
+
return { unreadable: true };
|
|
382
431
|
}
|
|
383
432
|
}
|
|
384
433
|
|
|
385
|
-
//
|
|
386
|
-
function
|
|
434
|
+
// codex's bundled catalog: listed, API-served slugs by priority; null when it cannot be read.
|
|
435
|
+
function readCatalog(res, notes) {
|
|
436
|
+
if (res?.status !== 0) return null;
|
|
387
437
|
try {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
438
|
+
const models = JSON.parse(res.stdout).models;
|
|
439
|
+
if (!Array.isArray(models)) return null;
|
|
440
|
+
const slugs = models
|
|
441
|
+
.filter((m) => m?.slug && m.visibility !== "hide" && m.supported_in_api !== false)
|
|
442
|
+
.sort((a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity))
|
|
443
|
+
.map((m) => m.slug);
|
|
444
|
+
return safeOnly(slugs, "codex catalog", notes);
|
|
394
445
|
} catch {
|
|
395
446
|
return null;
|
|
396
447
|
}
|
|
397
448
|
}
|
|
398
449
|
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return finish({ status: 0, reason: 'bad-url', body: '' });
|
|
410
|
-
}
|
|
411
|
-
const mod = parsed.protocol === 'https:' ? https : http;
|
|
412
|
-
let req = null;
|
|
413
|
-
const timer = setTimeout(() => {
|
|
414
|
-
try { if (req) req.destroy(); } catch { /* 이미 닫힘 */ }
|
|
415
|
-
finish({ status: 0, reason: 'timeout', body: '' });
|
|
416
|
-
}, timeoutMs);
|
|
417
|
-
if (typeof timer.unref === 'function') timer.unref();
|
|
418
|
-
const headers = { accept: 'application/json', ...(extraHeaders || {}) };
|
|
419
|
-
try {
|
|
420
|
-
req = mod.request(parsed, { method: 'GET', headers }, (res) => {
|
|
421
|
-
const status = res.statusCode || 0;
|
|
422
|
-
let data = '';
|
|
423
|
-
let bytes = 0;
|
|
424
|
-
res.on('data', (chunk) => { bytes += chunk.length; if (bytes <= MAX_BODY_BYTES) data += chunk; });
|
|
425
|
-
res.on('end', () => { clearTimeout(timer); finish({ status, reason: null, body: data }); });
|
|
426
|
-
res.on('error', () => { clearTimeout(timer); finish({ status, reason: 'unreachable', body: '' }); });
|
|
427
|
-
});
|
|
428
|
-
req.on('error', () => { clearTimeout(timer); finish({ status: 0, reason: 'unreachable', body: '' }); });
|
|
429
|
-
req.setTimeout(timeoutMs, () => { try { req.destroy(); } catch { /* 이미 닫힘 */ } });
|
|
430
|
-
req.end(); // GET — 요청 본문 없음.
|
|
431
|
-
} catch {
|
|
432
|
-
clearTimeout(timer);
|
|
433
|
-
finish({ status: 0, reason: 'unreachable', body: '' });
|
|
434
|
-
}
|
|
435
|
-
});
|
|
450
|
+
// Models split by what the probe may do with them: independent ones it may adopt or offer,
|
|
451
|
+
// unclassified ones only the person may pick; the author's own family is dropped.
|
|
452
|
+
function sortModels(models, authorFamilies) {
|
|
453
|
+
const independent = [];
|
|
454
|
+
const unclassified = [];
|
|
455
|
+
for (const m of new Set(models.filter(Boolean))) {
|
|
456
|
+
if (isAuthorFamily(m, authorFamilies)) continue;
|
|
457
|
+
(familyOf(m) ? independent : unclassified).push(m);
|
|
458
|
+
}
|
|
459
|
+
return { independent, unclassified };
|
|
436
460
|
}
|
|
437
461
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
if (
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
return 'http-error';
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Authorization: Bearer 를 먼저 쓰고, 401·403 이면 api-key 헤더로 한 번 더 시도한다
|
|
447
|
-
// (이 머신의 프록시는 두 헤더 모두 200 을 낸다 — 다른 프록시는 한쪽만 받을 수 있다).
|
|
448
|
-
// 재시도는 allowHeaderRetry 일 때만 — 저자가 지목하지 않은 기본 엔드포인트로 같은 키를 두 번
|
|
449
|
-
// 보내면 오배송 1건이 2건이 된다. requests 는 실제로 낸 요청 수이며 호출자가 그대로 보고한다.
|
|
450
|
-
async function probeModels(baseUrl, key, timeoutMs, allowHeaderRetry) {
|
|
451
|
-
const url = modelsUrl(baseUrl);
|
|
452
|
-
const first = await httpGet(url, { authorization: 'Bearer ' + key }, timeoutMs);
|
|
453
|
-
if (allowHeaderRetry && (first.status === 401 || first.status === 403)) {
|
|
454
|
-
const second = await httpGet(url, { 'api-key': key }, timeoutMs);
|
|
455
|
-
if (second.status >= 200 && second.status < 300) return { ...second, header: 'api-key', requests: 2 };
|
|
456
|
-
return { ...first, header: 'authorization', requests: 2 };
|
|
457
|
-
}
|
|
458
|
-
return { ...first, header: 'authorization', requests: 1 };
|
|
462
|
+
// The seat a flag-given model makes, or null when no model was given.
|
|
463
|
+
function flagged(base, model, authorFamilies) {
|
|
464
|
+
if (!model) return null;
|
|
465
|
+
if (isAuthorFamily(model, authorFamilies)) return { ...base, decision: "unseated", reason: "self-family" };
|
|
466
|
+
return { ...base, decision: "adopt", model, reason: "flag", familyKnown: familyOf(model) !== null };
|
|
459
467
|
}
|
|
460
468
|
|
|
461
|
-
//
|
|
469
|
+
// An ask: options and fallback hold only models whose family is known (the rest stay listed
|
|
470
|
+
// under `unclassified`); with no fallback, `noAskReason` is what to report when nobody can be asked.
|
|
471
|
+
function ask(seat, options, fallback, reason, noAskReason = "no-default-model") {
|
|
472
|
+
const known = [...new Set(options.filter((m) => m && familyOf(m)))];
|
|
473
|
+
const safe = fallback === CLI_DEFAULT || (fallback && familyOf(fallback)) ? fallback : null;
|
|
474
|
+
return { ...seat, decision: "ask", options: known, fallback: safe, reason, ...(safe ? {} : { noAskReason }) };
|
|
475
|
+
}
|
|
462
476
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
//
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
477
|
+
const codexConfigPath = (world) => join(world.env.CODEX_HOME || join(world.home, ".codex"), "config.toml");
|
|
478
|
+
const CODEX_MANAGED = "/etc/codex/managed_config.toml";
|
|
479
|
+
const CODEX_REQUIREMENTS = "/etc/codex/requirements.toml";
|
|
480
|
+
|
|
481
|
+
// codex's config files the probe can read, highest precedence first (codex 0.144.5): the legacy
|
|
482
|
+
// managed file, the user's config.toml, the system one. MDM and cloud layers are not files here.
|
|
483
|
+
function codexConfigFiles(world) {
|
|
484
|
+
const user = codexConfigPath(world);
|
|
485
|
+
return world.platform === "win32" ? [user] : [CODEX_MANAGED, user, "/etc/codex/config.toml"];
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Those layers as one: the first layer with a value wins, MCP servers add up.
|
|
489
|
+
function codexConfig(world) {
|
|
490
|
+
const layers = codexConfigFiles(world).map((f) => readToml(world.readFile(f)));
|
|
491
|
+
const pick = (key) => layers.map((l) => l.values[key]).find((v) => v !== undefined);
|
|
492
|
+
return {
|
|
493
|
+
cfg: { model: pick("model"), model_provider: pick("model_provider"), profile: pick("profile") },
|
|
494
|
+
mcpServers: [...new Set(layers.flatMap((l) => l.mcpServers))],
|
|
495
|
+
mcpUnread: layers.some((l) => l.mcpUnread),
|
|
496
|
+
modelKeys: layers.some((l) => l.modelKeys),
|
|
497
|
+
broken: layers.some((l) => l.broken),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// What `-c mcp_servers.<name>.enabled=false` can address: codex splits the key on dots.
|
|
502
|
+
const MCP_NAME = /^[A-Za-z0-9_-]+$/;
|
|
503
|
+
|
|
504
|
+
// The unseated seat when the seat command could not turn every MCP server off (a name `-c`
|
|
505
|
+
// cannot address, servers the probe could not list, a config it could not read to the end);
|
|
506
|
+
// null otherwise. An MCP tool runs outside codex's read-only sandbox.
|
|
507
|
+
function mcpBlockedSeat(seat, conf, notes) {
|
|
508
|
+
const bad = conf.mcpServers.filter((n) => !MCP_NAME.test(n)).length;
|
|
509
|
+
if (!bad && !conf.mcpUnread && !conf.broken) return null;
|
|
510
|
+
if (bad) notes.push(`${bad} MCP server name(s) hold characters \`-c mcp_servers.<name>.enabled=false\` cannot address: rename them to letters, digits, _ and -`);
|
|
511
|
+
if (conf.mcpUnread) notes.push("the probe could not list every MCP server the codex config defines (one inline mcp_servers table, or a name its walk did not place): write each server as its own [mcp_servers.<name>] table");
|
|
512
|
+
if (conf.broken) notes.push("the probe could not read the codex config to its end (a multi-line string, array or inline table left open), so its list of MCP servers may be short");
|
|
513
|
+
return { ...seat, decision: "unseated", reason: "cli-call-failed" };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// What the seat command turns off or pins with -c, --disable and -s. codex loads its legacy
|
|
517
|
+
// managed file last, over -c (0.144.5: "Overridden by legacy managed_config.toml"), so such a key
|
|
518
|
+
// there undoes the seat's guard.
|
|
519
|
+
const SEAT_KEYS = ["notify", "otel", "mcp_servers", "web_search", "tools", "analytics", "features", "hooks",
|
|
520
|
+
"sandbox_mode", "sandbox_workspace_write", "approval_policy"];
|
|
521
|
+
|
|
522
|
+
// The unseated seat when the managed file sets one of those keys; null otherwise.
|
|
523
|
+
function managedBlockedSeat(seat, world, notes) {
|
|
524
|
+
if (world.platform === "win32") return null;
|
|
525
|
+
const keys = readToml(world.readFile(CODEX_MANAGED)).roots.filter((k) => SEAT_KEYS.includes(k));
|
|
526
|
+
if (!keys.length) return null;
|
|
527
|
+
notes.push(`${CODEX_MANAGED} sets ${keys.join(", ")}, which codex applies over the seat's -c flags: the seat cannot hold its guards`);
|
|
528
|
+
return { ...seat, decision: "unseated", reason: "cli-call-failed" };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// What the seat sets that a requirements file may allow, and keys that hold no seat guard. codex
|
|
532
|
+
// holds /etc/codex/requirements.toml over -c, --disable and -s and runs the required value
|
|
533
|
+
// instead (0.144.5 under a fake /etc: a pinned features.shell_tool brings exec_command back under
|
|
534
|
+
// --disable shell_tool, a disallowed approval policy falls back with a warning, a sandbox list
|
|
535
|
+
// without read-only stops every run). Any other key could do the same to a guard.
|
|
536
|
+
const SEAT_ALLOWS = { allowed_sandbox_modes: "read-only", allowed_approval_policies: "never", allowed_web_search_modes: "disabled" };
|
|
537
|
+
const GUARD_FREE = ["enforce_residency"];
|
|
538
|
+
|
|
539
|
+
// The unseated seat when the requirements file could undo a seat guard; null otherwise.
|
|
540
|
+
function requirementsBlockedSeat(seat, world, notes) {
|
|
541
|
+
if (world.platform === "win32") return null;
|
|
542
|
+
const text = world.readFile(CODEX_REQUIREMENTS);
|
|
543
|
+
if (text === null || text === undefined) return null;
|
|
544
|
+
const read = readToml(text);
|
|
545
|
+
const allows = (k) => SEAT_ALLOWS[k] !== undefined && (read.arrays[k] ?? []).includes(SEAT_ALLOWS[k]);
|
|
546
|
+
const keys = read.roots.filter((k) => !GUARD_FREE.includes(k) && !allows(k));
|
|
547
|
+
if (!keys.length && !read.broken) return null;
|
|
548
|
+
notes.push(`${CODEX_REQUIREMENTS} ${keys.length ? `sets ${keys.join(", ")}` : "could not be read to its end"}, which codex holds over the seat's -c, --disable and -s flags: the seat cannot hold its guards`);
|
|
549
|
+
return { ...seat, decision: "unseated", reason: "cli-call-failed" };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function codexSeatBase(world, runtime) {
|
|
553
|
+
const present = Boolean(world.which("codex"));
|
|
554
|
+
if (runtime === "codex") return { cli: "codex", present, decision: "unseated", reason: "self-runtime" };
|
|
555
|
+
if (!runtime) return { cli: "codex", present, decision: "unseated", reason: "runtime-unknown" };
|
|
556
|
+
if (!present) return absent("codex");
|
|
557
|
+
return null;
|
|
507
558
|
}
|
|
508
559
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const id = row && typeof row === 'object' ? row.id : row;
|
|
517
|
-
if (typeof id === 'string' && id) ids.push(id);
|
|
560
|
+
// True when `version` (its first x.y.z) is at least `floor`.
|
|
561
|
+
function atLeast(version, floor) {
|
|
562
|
+
const v = /(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? ""));
|
|
563
|
+
if (!v) return false;
|
|
564
|
+
for (let i = 0; i < 3; i += 1) {
|
|
565
|
+
const d = Number(v[i + 1]) - floor[i];
|
|
566
|
+
if (d !== 0) return d > 0;
|
|
518
567
|
}
|
|
519
|
-
return
|
|
568
|
+
return true;
|
|
520
569
|
}
|
|
521
570
|
|
|
522
|
-
//
|
|
571
|
+
// codex 0.144.5 refuses every run while config.toml keeps a top-level `profile` key.
|
|
572
|
+
const LEGACY_PROFILE_REFUSED_FROM = [0, 144, 5];
|
|
573
|
+
|
|
574
|
+
// The adopt-or-ask call for codex once its catalog's top model and the configuration are known.
|
|
575
|
+
// `ownModel`: config.toml sets a top-level model, whether or not it may be used. `cliDefault`:
|
|
576
|
+
// what running without a model flag may stand for, null when the config names any model.
|
|
577
|
+
function codexDecision(seat, { top, configured, ownModel, cliDefault, customProvider, unsure }) {
|
|
578
|
+
if (unsure) return ask(seat, [top, configured], configured, "config-not-understood");
|
|
579
|
+
if (!top) return ask(seat, [configured], configured ?? cliDefault, "no-catalog");
|
|
580
|
+
if (configured === top) return adopt(seat, top, "catalog-top-is-configured");
|
|
581
|
+
if (!ownModel && !customProvider) return adopt(seat, top, "catalog-top");
|
|
582
|
+
if (!ownModel) return ask(seat, [top], cliDefault, "custom-provider");
|
|
583
|
+
return ask(seat, [top, configured], configured, "top-differs-from-configured");
|
|
584
|
+
}
|
|
523
585
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if (
|
|
586
|
+
// A configured model a seat may run: not the author's family (noted when it is).
|
|
587
|
+
function vetConfigured(raw, authorFamilies, notes) {
|
|
588
|
+
if (!raw) return null;
|
|
589
|
+
if (!isAuthorFamily(raw, authorFamilies)) return raw;
|
|
590
|
+
notes.push(`the configured model is of the author's family (${raw}): it is neither offered nor run as a fallback`);
|
|
527
591
|
return null;
|
|
528
592
|
}
|
|
529
593
|
|
|
530
|
-
//
|
|
594
|
+
// What codex's config says about a seat run, for codexDecision.
|
|
595
|
+
function codexRunFacts({ cfg, modelKeys }) {
|
|
596
|
+
return {
|
|
597
|
+
ownModel: Boolean(cfg.model),
|
|
598
|
+
// A model key in any table (a profile, say) may be what codex runs without -m.
|
|
599
|
+
cliDefault: modelKeys ? null : CLI_DEFAULT,
|
|
600
|
+
customProvider: Boolean(cfg.model_provider && cfg.model_provider !== "openai"),
|
|
601
|
+
unsure: Boolean(cfg.profile),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// The unseated seat when this codex refuses the config's legacy `profile` key; null otherwise.
|
|
606
|
+
function legacyProfileSeat(seat, cfg, notes) {
|
|
607
|
+
if (!cfg.profile || !atLeast(seat.version, LEGACY_PROFILE_REFUSED_FROM)) return null;
|
|
608
|
+
notes.push("config.toml has a top-level `profile` key, which this codex refuses for every run; move it to `--profile <name>` with <name>.config.toml");
|
|
609
|
+
return { ...seat, decision: "unseated", reason: "cli-call-failed" };
|
|
610
|
+
}
|
|
531
611
|
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
|
|
612
|
+
function codexSeat(flag, { world, runtime, authorFamilies }) {
|
|
613
|
+
const early = codexSeatBase(world, runtime);
|
|
614
|
+
if (early) return early;
|
|
535
615
|
const notes = [];
|
|
536
|
-
|
|
616
|
+
const conf = codexConfig(world);
|
|
617
|
+
const configured = vetConfigured(safeOnly([conf.cfg.model], "config.toml", notes)[0], authorFamilies, notes);
|
|
618
|
+
const catalog = readCatalog(world.run("codex", ["debug", "models", "--bundled"]), notes) ?? [];
|
|
619
|
+
const sorted = sortModels([...catalog, configured], authorFamilies);
|
|
620
|
+
const version = firstLine(world.run("codex", ["--version"]));
|
|
621
|
+
const seat = { cli: "codex", present: true, version, configured, candidates: sorted.independent, unclassified: sorted.unclassified, mcpServers: conf.mcpServers };
|
|
622
|
+
const refused = legacyProfileSeat(seat, conf.cfg, notes) ?? managedBlockedSeat(seat, world, notes)
|
|
623
|
+
?? requirementsBlockedSeat(seat, world, notes) ?? mcpBlockedSeat(seat, conf, notes);
|
|
624
|
+
if (refused) return withNotes(refused, notes);
|
|
625
|
+
const top = sortModels(catalog, authorFamilies).independent[0] ?? null;
|
|
626
|
+
const decided = flagged(seat, flag, authorFamilies) ?? codexDecision(seat, { top, configured, ...codexRunFacts(conf) });
|
|
627
|
+
return withNotes(decided, notes);
|
|
628
|
+
}
|
|
537
629
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
if (
|
|
542
|
-
|
|
543
|
-
|
|
630
|
+
function geminiSystemSettings(world) {
|
|
631
|
+
if (world.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH) return world.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
|
|
632
|
+
if (world.platform === "darwin") return "/Library/Application Support/GeminiCli/settings.json";
|
|
633
|
+
if (world.platform === "win32") return "C:\\ProgramData\\gemini-cli\\settings.json";
|
|
634
|
+
return "/etc/gemini-cli/settings.json";
|
|
635
|
+
}
|
|
544
636
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const transport = detectTransport(env);
|
|
570
|
-
const codexPresent = Boolean(whichExecutable('codex', env));
|
|
571
|
-
const geminiPresent = Boolean(whichExecutable('gemini', env));
|
|
572
|
-
// GA1. CLI 좌석과 크리덴셜 좌석이 같은 env 목록을 보되, 관측은 한 곳에서만 한다.
|
|
573
|
-
const geminiApi = resolveGeminiApi(env, notes);
|
|
574
|
-
// CLI 좌석(G1)이 보는 것은 "인증 env 가 있는가"이지 "엔드포인트가 해석됐는가"가 아니다 —
|
|
575
|
-
// 기본 엔드포인트를 껐다고 gemini CLI 의 자체 인증이 사라지는 것은 아니다.
|
|
576
|
-
const geminiAuthEnvPresent = GEMINI_AUTH_ENVS.some((name) => (env[name] || '').trim());
|
|
577
|
-
// 키는 있는데 목적지가 없는 상태를 사유 토큰에서 가르기 위해 필요하다(default-endpoint-off).
|
|
578
|
-
const apiKeyEnvPresent = API_KEY_ENVS.some((name) => (env[name] || '').trim());
|
|
579
|
-
|
|
580
|
-
if (codexPresent) {
|
|
581
|
-
notes.push('codex login status·auth.json 은 유효성 근거로 쓰지 않았다 — 실측상 거짓 음성이다(Not logged in 을 출력하면서 codex 는 정상 동작하고 exit code 도 0이라 코드로도 갈리지 않는다). 인증은 provider env 키에서 온다.');
|
|
582
|
-
}
|
|
583
|
-
if (geminiPresent && !geminiAuthEnvPresent) {
|
|
584
|
-
let geminiHome = false;
|
|
585
|
-
try { geminiHome = statSync(join(env.HOME || env.USERPROFILE || homedir(), GEMINI_HOME_DIR)).isDirectory(); } catch { geminiHome = false; }
|
|
586
|
-
if (geminiHome) {
|
|
587
|
-
notes.push('gemini 홈 설정 디렉터리는 존재하나 인증 env 는 부재다 — 프로브는 env 만 인증 신호로 인정한다(fail-closed).');
|
|
588
|
-
}
|
|
637
|
+
// gemini's system-defaults layer: $GEMINI_CLI_SYSTEM_DEFAULTS_PATH, or system-defaults.json beside
|
|
638
|
+
// the system settings (0.62.0).
|
|
639
|
+
const geminiSystemDefaults = (world) => world.env.GEMINI_CLI_SYSTEM_DEFAULTS_PATH
|
|
640
|
+
|| join(dirname(geminiSystemSettings(world)), "system-defaults.json");
|
|
641
|
+
|
|
642
|
+
// gemini's system, user and system-defaults settings, in the order gemini lets them override
|
|
643
|
+
// (system over user over system-defaults); the user's sit under $GEMINI_CLI_HOME when it is set.
|
|
644
|
+
// Workspace settings are not read: the seat runs in the payload folder, where the
|
|
645
|
+
// repository's do not apply.
|
|
646
|
+
const geminiSettings = (world) => [geminiSystemSettings(world), join(world.env.GEMINI_CLI_HOME || world.home, ".gemini", "settings.json"),
|
|
647
|
+
geminiSystemDefaults(world)]
|
|
648
|
+
.map((file) => ({ file, ...readJsonc(world.readFile(file)) }));
|
|
649
|
+
|
|
650
|
+
// gemini's configured model and which settings files could not be read: $GEMINI_MODEL first,
|
|
651
|
+
// then the settings.
|
|
652
|
+
function geminiConfigured(world, settings, notes) {
|
|
653
|
+
const unreadable = [];
|
|
654
|
+
const pick = (name, where) => safeOnly([name], where, notes)[0] ?? null;
|
|
655
|
+
if (world.env.GEMINI_MODEL) return { configured: pick(world.env.GEMINI_MODEL, "$GEMINI_MODEL"), unreadable };
|
|
656
|
+
for (const read of settings) {
|
|
657
|
+
if (read.unreadable) unreadable.push(read.file);
|
|
658
|
+
const model = read.value?.model;
|
|
659
|
+
const name = typeof model === "string" ? model : model?.name;
|
|
660
|
+
if (name) return { configured: pick(name, read.file), unreadable };
|
|
589
661
|
}
|
|
662
|
+
return { configured: null, unreadable };
|
|
663
|
+
}
|
|
590
664
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
// 죽었다"와 구분이 사라진다. 낸 경우에만 probed:true 이고 그때부터 숫자가 의미를 갖는다.
|
|
597
|
-
let status = null;
|
|
598
|
-
let live = null;
|
|
599
|
-
let apiProbed = false;
|
|
600
|
-
let apiReason = null;
|
|
601
|
-
let candidates = [];
|
|
602
|
-
let unclassified = 0;
|
|
603
|
-
let catalogClassified = 0;
|
|
604
|
-
|
|
605
|
-
if (externalOff) {
|
|
606
|
-
apiReason = 'external-off'; // 미실행. 아래 좌석 게이트는 별도로 author-off 를 적는다.
|
|
607
|
-
} else if (!api.configured) {
|
|
608
|
-
apiReason = 'not-configured';
|
|
609
|
-
} else if (!api.keyEnv || !hasKey) {
|
|
610
|
-
apiReason = 'no-key'; // T1 에서 무효 — env 키가 비어 있으면 T2 를 낼 이유가 없다.
|
|
611
|
-
} else {
|
|
612
|
-
const result = await probeModels(api.baseUrl, api.key, timeoutMs, isExplicitSource(api.source));
|
|
613
|
-
httpRequests += result.requests;
|
|
614
|
-
apiProbed = true;
|
|
615
|
-
live = false;
|
|
616
|
-
status = result.status;
|
|
617
|
-
apiReason = statusReason(result.status, result.reason);
|
|
618
|
-
if (!apiReason) {
|
|
619
|
-
const ids = catalogIds(result.body);
|
|
620
|
-
if (!ids) {
|
|
621
|
-
apiReason = 'catalog-unreadable';
|
|
622
|
-
} else {
|
|
623
|
-
for (const id of ids) {
|
|
624
|
-
const family = classify(id);
|
|
625
|
-
if (!family) { unclassified += 1; continue; } // 미분류는 fail-closed 로 제외.
|
|
626
|
-
catalogClassified += 1;
|
|
627
|
-
if (excludedFamilies.indexOf(family) >= 0) continue;
|
|
628
|
-
candidates.push({ id, family });
|
|
629
|
-
}
|
|
630
|
-
if (catalogClassified === 0) apiReason = 'unclassifiable-catalog';
|
|
631
|
-
else if (candidates.length === 0) apiReason = 'no-independent-model';
|
|
632
|
-
else live = true;
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
}
|
|
665
|
+
function geminiPoliciesDir(world) {
|
|
666
|
+
if (world.platform === "darwin") return "/Library/Application Support/GeminiCli/policies";
|
|
667
|
+
if (world.platform === "win32") return "C:\\ProgramData\\gemini-cli\\policies";
|
|
668
|
+
return "/etc/gemini-cli/policies";
|
|
669
|
+
}
|
|
636
670
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
671
|
+
// gemini ignores --admin-policy while its system policy folder holds a policy (0.62.0), and the
|
|
672
|
+
// seat's read-only tools rest on that flag: such a seat is not seated.
|
|
673
|
+
function geminiPolicySeat(seat, world, notes) {
|
|
674
|
+
const dir = geminiPoliciesDir(world);
|
|
675
|
+
if (!world.listDir(dir).some((f) => f.endsWith(".toml"))) return null;
|
|
676
|
+
notes.push(`gemini ignores --admin-policy while ${dir} holds policies: the seat cannot be held to its read-only tools`);
|
|
677
|
+
return { ...seat, decision: "unseated", reason: "cli-call-failed" };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// gemini adds the settings' context.includeDirectories to the seat's workspace and lists them in
|
|
681
|
+
// its first request, and in IDE mode (ide.enabled) it adds the IDE's open files and selection
|
|
682
|
+
// (0.62.0): the seat would no longer keep to its payload folder.
|
|
683
|
+
function geminiWorkspaceSeat(seat, settings, notes) {
|
|
684
|
+
const widened = settings.some((r) => (r.value?.context?.includeDirectories ?? []).length > 0);
|
|
685
|
+
const ide = settings.map((r) => r.value?.ide?.enabled).find((v) => v !== undefined) === true;
|
|
686
|
+
if (!widened && !ide) return null;
|
|
687
|
+
if (widened) notes.push("the gemini settings add folders to the workspace (context.includeDirectories), whose listing would go with the payload: take them out for this run");
|
|
688
|
+
if (ide) notes.push("the gemini settings turn on IDE mode (ide.enabled), which adds the IDE's open files to the request: turn it off for this run");
|
|
689
|
+
return { ...seat, decision: "unseated", reason: "cli-call-failed" };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// gemini sends usage statistics (model, session and tool metadata, not the payload) to
|
|
693
|
+
// play.googleapis.com unless the settings turn them off; no flag does.
|
|
694
|
+
function geminiStatsNotes(settings) {
|
|
695
|
+
const set = settings.map((r) => r.value?.privacy?.usageStatisticsEnabled).find((v) => v !== undefined);
|
|
696
|
+
return set === false ? [] : ["gemini sends usage statistics (metadata, not the payload) to play.googleapis.com; set privacy.usageStatisticsEnabled to false in its settings to stop it"];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// gemini runs the hooks its settings define in every session, this seat too; no flag turns them off.
|
|
700
|
+
// gemini's own sandbox (tools.sandbox, or GEMINI_SANDBOX, which wins) restarts gemini in a docker or
|
|
701
|
+
// podman container that the admin policy file does not reach, so every tool opens (0.62.0). The
|
|
702
|
+
// seat command turns it off with GEMINI_SANDBOX=false; this says so when it is on.
|
|
703
|
+
function geminiSandboxNotes(settings, env) {
|
|
704
|
+
const fromEnv = String(env.GEMINI_SANDBOX ?? "").trim().toLowerCase();
|
|
705
|
+
const set = settings.map((r) => r.value?.tools?.sandbox).find((v) => v !== undefined);
|
|
706
|
+
const on = fromEnv ? !["0", "false"].includes(fromEnv) : Boolean(set && (typeof set !== "object" || set.enabled));
|
|
707
|
+
return on ? ["gemini's own sandbox is on (tools.sandbox or GEMINI_SANDBOX): the seat command turns it off with GEMINI_SANDBOX=false, as the admin policy does not reach its container"] : [];
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function geminiHookNotes(settings) {
|
|
711
|
+
const off = settings.some((r) => r.value?.hooksConfig?.enabled === false);
|
|
712
|
+
const hooks = settings.some((r) => Object.keys(r.value?.hooks ?? {}).length > 0);
|
|
713
|
+
return hooks && !off ? ["the gemini settings define hooks, which run in this seat too: no flag turns them off"] : [];
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// gemini has no catalog to rank; its `pro` alias resolves to the strongest Pro model the
|
|
717
|
+
// account may use, while `auto` routes each request to Pro or Flash.
|
|
718
|
+
function geminiSeat(flag, { world, authorFamilies }) {
|
|
719
|
+
if (!world.which("gemini")) return absent("gemini");
|
|
720
|
+
const notes = [];
|
|
721
|
+
const settings = geminiSettings(world);
|
|
722
|
+
const { configured: raw, unreadable } = geminiConfigured(world, settings, notes);
|
|
723
|
+
const configured = vetConfigured(raw, authorFamilies, notes);
|
|
724
|
+
const sorted = sortModels(["pro", configured], authorFamilies);
|
|
725
|
+
const seat = { cli: "gemini", present: true, configured, candidates: sorted.independent, unclassified: sorted.unclassified };
|
|
726
|
+
notes.push(...unreadable.map((f) => `settings not readable: ${f}`), ...geminiHookNotes(settings), ...geminiStatsNotes(settings),
|
|
727
|
+
...geminiSandboxNotes(settings, world.env));
|
|
728
|
+
const blocked = geminiPolicySeat(seat, world, notes) ?? geminiWorkspaceSeat(seat, settings, notes);
|
|
729
|
+
if (blocked) return withNotes(blocked, notes);
|
|
730
|
+
const out = flagged(seat, flag, authorFamilies) ?? geminiDecision(seat, configured, unreadable.length > 0);
|
|
731
|
+
return withNotes(out, notes);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function geminiDecision(seat, configured, unsure) {
|
|
735
|
+
if (unsure) return ask(seat, ["pro", configured], configured, "config-not-understood");
|
|
736
|
+
if (!configured || configured === "pro") return adopt(seat, "pro", "cli-pro-alias");
|
|
737
|
+
return ask(seat, ["pro", configured], configured, "configured-vs-pro");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function managedOpencodeDir(world) {
|
|
741
|
+
if (world.platform === "darwin") return "/Library/Application Support/opencode";
|
|
742
|
+
if (world.platform === "win32") return join(world.env.ProgramData || "C:\\ProgramData", "opencode");
|
|
743
|
+
return "/etc/opencode";
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// opencode's config sources the seat will run with, lowest precedence first (opencode 1.3.10).
|
|
747
|
+
// $OPENCODE_CONFIG_CONTENT is not one of them: the seat command sets it to its reviewer agent.
|
|
748
|
+
function opencodeSources(world) {
|
|
749
|
+
const env = world.env;
|
|
750
|
+
const dirFiles = (dir) => (dir ? [join(dir, "opencode.jsonc"), join(dir, "opencode.json")] : []);
|
|
751
|
+
const global = join(env.XDG_CONFIG_HOME || join(world.home, ".config"), "opencode");
|
|
752
|
+
return [
|
|
753
|
+
...["config.json", "opencode.json", "opencode.jsonc"].map((f) => join(global, f)),
|
|
754
|
+
...(env.OPENCODE_CONFIG ? [env.OPENCODE_CONFIG] : []),
|
|
755
|
+
...dirFiles(join(world.home, ".opencode")),
|
|
756
|
+
...dirFiles(env.OPENCODE_CONFIG_DIR),
|
|
757
|
+
...dirFiles(managedOpencodeDir(world)),
|
|
758
|
+
];
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// One opencode config source merged into `merged` as opencode merges them: the last `model`
|
|
762
|
+
// and provider lists win, provider models add up.
|
|
763
|
+
function mergeOpencode(merged, cfg) {
|
|
764
|
+
if (typeof cfg.model === "string" && cfg.model) merged.model = cfg.model;
|
|
765
|
+
if (Array.isArray(cfg.enabled_providers)) merged.enabled = cfg.enabled_providers;
|
|
766
|
+
if (Array.isArray(cfg.disabled_providers)) merged.disabled = cfg.disabled_providers;
|
|
767
|
+
for (const [name, def] of Object.entries(cfg.provider ?? {})) {
|
|
768
|
+
merged.provider[name] = [...new Set([...(merged.provider[name] ?? []), ...Object.keys(def?.models ?? {})])];
|
|
660
769
|
}
|
|
770
|
+
}
|
|
661
771
|
|
|
662
|
-
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
if (
|
|
667
|
-
|
|
668
|
-
return { valid: true, reason: null };
|
|
669
|
-
})();
|
|
670
|
-
const codexSeat = (() => {
|
|
671
|
-
if (externalOff) return { valid: false, reason: 'author-off' };
|
|
672
|
-
if (!codexPresent) return { valid: false, reason: 'cli-absent' };
|
|
673
|
-
if (runtime === 'codex') return { valid: false, reason: 'author-runtime' }; // 저자 자신은 외부 좌석 부적격.
|
|
674
|
-
if (!api.configured) return { valid: false, reason: 'provider-unresolved' };
|
|
675
|
-
if (!live) return { valid: false, reason: apiReason };
|
|
676
|
-
return { valid: true, reason: null };
|
|
677
|
-
})();
|
|
678
|
-
const geminiSeat = (() => {
|
|
679
|
-
if (externalOff) return { valid: false, reason: 'author-off' };
|
|
680
|
-
if (!geminiPresent) return { valid: false, reason: 'cli-absent' };
|
|
681
|
-
if (!geminiAuthEnvPresent) return { valid: false, reason: 'gemini-no-auth' };
|
|
682
|
-
// G2 를 수행하지 않으므로 여기서는 "생존 미확인"까지가 최선이다. 첫 검증 호출이 곧 프로브다.
|
|
683
|
-
return { valid: true, reason: null };
|
|
684
|
-
})();
|
|
685
|
-
// 크리덴셜 좌석은 CLI 좌석을 거치지 않는다 — geminiPresent 를 조건에 넣는 순간 B1(유효한 키를
|
|
686
|
-
// 관측해 놓고 cli-absent 로 보고)이 되살아난다.
|
|
687
|
-
const geminiApiSeat = (() => {
|
|
688
|
-
if (externalOff) return { valid: false, reason: 'author-off' };
|
|
689
|
-
if (!geminiApi.configured) return { valid: false, reason: geminiApi.reason };
|
|
690
|
-
if (transport === 'none') return { valid: false, reason: 'no-transport' };
|
|
691
|
-
if (!geminiApiLive) return { valid: false, reason: geminiApiReason };
|
|
692
|
-
return { valid: true, reason: null };
|
|
693
|
-
})();
|
|
694
|
-
|
|
695
|
-
// 크리덴셜을 관측해 놓고 좌석이 안 서면 그 이유를 반드시 남긴다. 여기서 침묵하면 보고에는
|
|
696
|
-
// 좌석 0 만 남고, 읽는 쪽은 그걸 "환경이 아무것도 주지 않았다"로 읽는다.
|
|
697
|
-
if (geminiApi.configured && !geminiApiSeat.valid && !externalOff) {
|
|
698
|
-
notes.push('gemini 모델 크리덴셜(' + geminiApi.keyEnv + ')은 있으나 좌석이 서지 못했다 — 사유 '
|
|
699
|
-
+ geminiApiSeat.reason + (geminiApiStatus ? ' (status ' + geminiApiStatus + ')' : '')
|
|
700
|
-
+ '. gemini CLI 부재는 이 경로와 무관하다.');
|
|
772
|
+
function opencodeConfig(world) {
|
|
773
|
+
const merged = { model: null, provider: {}, enabled: null, disabled: null, unreadable: [] };
|
|
774
|
+
for (const file of opencodeSources(world)) {
|
|
775
|
+
const read = readJsonc(world.readFile(file));
|
|
776
|
+
if (read.unreadable) merged.unreadable.push(file);
|
|
777
|
+
mergeOpencode(merged, read.value ?? {});
|
|
701
778
|
}
|
|
779
|
+
return merged;
|
|
780
|
+
}
|
|
702
781
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
if (
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
782
|
+
// opencode's `provider/model` names: the configured one first, then each usable provider's.
|
|
783
|
+
function opencodeModels(cfg) {
|
|
784
|
+
const usable = (p) => (!cfg.enabled || cfg.enabled.includes(p)) && !(cfg.disabled ?? []).includes(p);
|
|
785
|
+
const listed = Object.entries(cfg.provider).filter(([p]) => usable(p)).flatMap(([p, ms]) => ms.map((m) => `${p}/${m}`));
|
|
786
|
+
const own = cfg.model && usable(String(cfg.model).split("/")[0]) ? cfg.model : null;
|
|
787
|
+
return { configured: own, models: [own, ...listed] };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// The providers opencode's local copy of the models.dev list knows; null when it cannot be read.
|
|
791
|
+
function catalogProviders(world) {
|
|
792
|
+
const file = join(world.env.XDG_CACHE_HOME || join(world.home, ".cache"), "opencode", "models.json");
|
|
793
|
+
const read = readJsonc(world.readFile(file));
|
|
794
|
+
return read.value ? new Set(Object.keys(read.value)) : null;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// The models the config lists are all opencode can run only when enabled_providers names
|
|
798
|
+
// nothing but providers that list their models here and that models.dev (`catalog`) does not know.
|
|
799
|
+
function closedSet(cfg, catalog) {
|
|
800
|
+
if (!cfg.enabled?.length) return false;
|
|
801
|
+
return Boolean(catalog) && cfg.enabled.every((p) => cfg.provider[p]?.length > 0 && !catalog.has(p));
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// opencode has no ranking: it adopts only when the configured model is the one model it can run.
|
|
805
|
+
// With one known model it asks: `no-catalog` when the models.dev copy could not be read,
|
|
806
|
+
// `open-model-set` when it was and opencode may still run models beyond the one known.
|
|
807
|
+
function opencodeDecision(seat, { configured, unsure, closed, catalog }) {
|
|
808
|
+
const known = seat.candidates;
|
|
809
|
+
if (unsure) return ask(seat, known, configured, "config-not-understood");
|
|
810
|
+
if (known.length === 0) return ask(seat, [], null, "no-candidate", seat.unclassified.length ? "no-default-model" : "no-independent-model");
|
|
811
|
+
if (known.length > 1) return ask(seat, known, configured, "several-candidates");
|
|
812
|
+
if (closed && known[0] === configured) return adopt(seat, configured, "single-candidate");
|
|
813
|
+
return ask(seat, known, configured, catalog ? "open-model-set" : "no-catalog");
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function opencodeSeat(flag, { world, authorFamilies }) {
|
|
817
|
+
if (!world.which("opencode")) return absent("opencode");
|
|
818
|
+
const notes = [];
|
|
819
|
+
const version = firstLine(world.run("opencode", ["--version"], OPENCODE_QUIET));
|
|
820
|
+
const cfg = opencodeConfig(world);
|
|
821
|
+
const { configured: raw, models } = opencodeModels(cfg);
|
|
822
|
+
const usable = safeOnly(models, "opencode config", notes);
|
|
823
|
+
const configured = vetConfigured(usable.includes(raw) ? raw : null, authorFamilies, notes);
|
|
824
|
+
const sorted = sortModels(usable, authorFamilies);
|
|
825
|
+
const seat = { cli: "opencode", present: true, version, configured, candidates: sorted.independent, unclassified: sorted.unclassified };
|
|
826
|
+
notes.push(...cfg.unreadable.map((f) => `config not readable: ${f}`));
|
|
827
|
+
if (world.env.OPENCODE_CONFIG_CONTENT) notes.push("$OPENCODE_CONFIG_CONTENT is not read: the seat command replaces it with its own reviewer agent");
|
|
828
|
+
const catalog = catalogProviders(world);
|
|
829
|
+
const closed = closedSet(cfg, catalog);
|
|
830
|
+
const out = flagged(seat, flag, authorFamilies) ?? opencodeDecision(seat, { configured, unsure: cfg.unreadable.length > 0, closed, catalog });
|
|
831
|
+
return withNotes(out, notes);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const SEATS = { codex: codexSeat, gemini: geminiSeat, opencode: opencodeSeat };
|
|
835
|
+
|
|
836
|
+
// Two adopted seats on one model, provider prefix aside: one model axis, not two.
|
|
837
|
+
function sameModelNotes(seats) {
|
|
838
|
+
const bare = (m) => String(m).toLowerCase().split("/").pop();
|
|
839
|
+
const adopted = seats.filter((s) => s.decision === "adopt");
|
|
840
|
+
const notes = [];
|
|
841
|
+
for (let i = 0; i < adopted.length; i += 1) {
|
|
842
|
+
for (let j = i + 1; j < adopted.length; j += 1) {
|
|
843
|
+
if (bare(adopted[i].model) === bare(adopted[j].model)) notes.push(`same model on two seats: ${adopted[i].cli} and ${adopted[j].cli} (${adopted[i].model})`);
|
|
731
844
|
}
|
|
732
845
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const code = geminiApiStatus > 0 ? String(geminiApiStatus) : (geminiApiReason || 'unreachable');
|
|
771
|
-
return 'probe-unseated status=' + code;
|
|
772
|
-
}
|
|
773
|
-
if (geminiPresent && !geminiAuthEnvPresent) {
|
|
774
|
-
// 닫힌 정의역에 gemini-no-auth 가 없다 — 사용 가능한 외부 경로가 없다는 뜻으로 cli-absent 를
|
|
775
|
-
// 쓰고, 정확한 사유는 좌석 레코드(observed.external.gemini.reason)와 이 note 가 담는다.
|
|
776
|
-
notes.push('gemini 는 PATH 에 있으나 인증 env 가 없어 착석 전 무효다(G1) — 좌석 사유는 gemini-no-auth 다.');
|
|
777
|
-
}
|
|
778
|
-
return 'cli-absent';
|
|
779
|
-
})();
|
|
846
|
+
return notes;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// The author's families. A Codex author also runs the family its config.toml names (a local
|
|
850
|
+
// model through a custom provider, say), so a seat on that family would be the author again.
|
|
851
|
+
function authorFamiliesFor(rt, world, notes) {
|
|
852
|
+
const base = AUTHOR_FAMILIES[rt] ?? UNKNOWN_AUTHOR;
|
|
853
|
+
if (rt !== "codex") return base;
|
|
854
|
+
const model = codexConfig(world).cfg.model;
|
|
855
|
+
const family = model ? familyOf(model) : null;
|
|
856
|
+
if (model && !family) notes.push("the Codex session's configured model has no known family: a seat running that model is not recognised as the author's");
|
|
857
|
+
return family && !base.includes(family) ? [...base, family] : base;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const knownRuntime = (runtime) => (runtime === "claude" || runtime === "codex" ? runtime : null);
|
|
861
|
+
const unknownRuntimeNote = (runtime) => `runtime unknown${runtime ? ` (got "${runtime}")` : ""}: `
|
|
862
|
+
+ "claude and gpt are both left out and codex is not seated; pass --runtime=claude|codex";
|
|
863
|
+
|
|
864
|
+
// The seat for one requested CLI; a flag value that is not a safe model name is dropped first.
|
|
865
|
+
function seatFor(name, value, ctx, notes) {
|
|
866
|
+
const flag = safeOnly([value], `--${name}`, notes)[0] ?? null;
|
|
867
|
+
return SEATS[name](flag, ctx);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export function probe({ requested, runtime, unknown = [], world = realWorld() }) {
|
|
871
|
+
const rt = knownRuntime(runtime);
|
|
872
|
+
const notes = [];
|
|
873
|
+
const authorFamilies = authorFamiliesFor(rt, world, notes);
|
|
874
|
+
const out = { runtime: rt ?? "unknown", authorFamilies, seats: [], notes };
|
|
875
|
+
if (!rt) notes.push(unknownRuntimeNote(runtime));
|
|
876
|
+
if (unknown.length) notes.push(`arguments not understood: ${unknown.join(" ")}`);
|
|
877
|
+
const names = SEAT_NAMES.filter((n) => n in requested);
|
|
878
|
+
if (names.length === 0) return { ...out, reason: "no-flag" };
|
|
879
|
+
for (const name of names) out.seats.push(seatFor(name, requested[name], { world, runtime: rt, authorFamilies }, notes));
|
|
880
|
+
notes.push(...sameModelNotes(out.seats));
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
780
883
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
884
|
+
// The executable `name` resolves to on PATH, only from absolute PATH entries (never the current
|
|
885
|
+
// folder, which cmd.exe would search first). Windows tries its executable extensions first.
|
|
886
|
+
function onPath(name) {
|
|
887
|
+
const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
888
|
+
for (const dir of String(process.env.PATH ?? "").split(delimiter)) {
|
|
889
|
+
if (!dir || !isAbsolute(dir)) continue;
|
|
890
|
+
for (const ext of exts) {
|
|
891
|
+
try {
|
|
892
|
+
const file = join(dir, name + ext);
|
|
893
|
+
if (statSync(file).isFile()) return file;
|
|
894
|
+
} catch {
|
|
895
|
+
/* not here */
|
|
896
|
+
}
|
|
897
|
+
}
|
|
784
898
|
}
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
785
901
|
|
|
902
|
+
function realWorld() {
|
|
786
903
|
return {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
live,
|
|
806
|
-
reason: apiReason,
|
|
807
|
-
},
|
|
808
|
-
geminiApi: {
|
|
809
|
-
configured: geminiApi.configured,
|
|
810
|
-
source: geminiApi.source,
|
|
811
|
-
// 요청 URL 이 아니라 base 다 — redactUrl 이 search 를 떼므로 쿼리스트링의 키가 따라오지 않는다.
|
|
812
|
-
baseUrl: redactUrl(geminiApi.baseUrl),
|
|
813
|
-
keyEnv: geminiApi.keyEnv, // 키 이름만. 값은 어디에도 내지 않는다.
|
|
814
|
-
probed: geminiApiProbed, // api.probed 와 같은 뜻이다.
|
|
815
|
-
status: geminiApiStatus,
|
|
816
|
-
live: geminiApiLive,
|
|
817
|
-
reason: geminiApiReason,
|
|
818
|
-
},
|
|
819
|
-
external: { api: apiSeat, codex: codexSeat, gemini: geminiSeat, geminiApi: geminiApiSeat },
|
|
820
|
-
candidates: sampled,
|
|
821
|
-
candidateCount: candidates.length,
|
|
822
|
-
unclassified, // 0 이어도 키를 생략하지 않는다 — 없으면 보고에서 침묵으로 읽힌다.
|
|
904
|
+
home: homedir(),
|
|
905
|
+
cwd: process.cwd(),
|
|
906
|
+
platform: process.platform,
|
|
907
|
+
env: process.env,
|
|
908
|
+
which: onPath,
|
|
909
|
+
readFile: (path) => {
|
|
910
|
+
try {
|
|
911
|
+
return readFileSync(path, "utf8");
|
|
912
|
+
} catch {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
},
|
|
916
|
+
listDir: (path) => {
|
|
917
|
+
try {
|
|
918
|
+
return readdirSync(path);
|
|
919
|
+
} catch {
|
|
920
|
+
return [];
|
|
921
|
+
}
|
|
823
922
|
},
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
923
|
+
run: (cmd, args, env = {}) => {
|
|
924
|
+
const exe = onPath(cmd);
|
|
925
|
+
if (!exe) return { status: 127, stdout: "" };
|
|
926
|
+
// Windows runs .cmd and .bat shims through cmd.exe; the path is quoted and the arguments are fixed words.
|
|
927
|
+
const viaCmd = process.platform === "win32" && /\.(cmd|bat)$/i.test(exe);
|
|
928
|
+
const r = spawnSync(viaCmd ? `"${exe}"` : exe, args, {
|
|
929
|
+
encoding: "utf8",
|
|
930
|
+
timeout: 15000,
|
|
931
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
932
|
+
shell: viaCmd,
|
|
933
|
+
env: { ...process.env, ...env },
|
|
934
|
+
});
|
|
935
|
+
return { status: r.status, stdout: r.stdout ?? "" };
|
|
834
936
|
},
|
|
835
|
-
notes,
|
|
836
937
|
};
|
|
837
938
|
}
|
|
838
939
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
catch {
|
|
843
|
-
|
|
940
|
+
const invokedDirectly = () => {
|
|
941
|
+
try {
|
|
942
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
943
|
+
} catch {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
};
|
|
844
947
|
|
|
845
|
-
if (
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
948
|
+
if (invokedDirectly()) {
|
|
949
|
+
const { requested, runtime, unknown } = parseArgs(process.argv.slice(2));
|
|
950
|
+
const rt = resolveRuntime(runtime, process.env);
|
|
951
|
+
process.stdout.write(JSON.stringify(probe({ requested, runtime: rt, unknown }), null, 2) + "\n");
|
|
849
952
|
}
|
|
850
|
-
|
|
851
|
-
// resolveApi·resolveGeminiApi 는 HTTP 없이 해석 단계만 in-process 로 검증하기 위해 내보낸다
|
|
852
|
-
// (기본 base URL 로 내려가는 경로는 스텁으로 못 가리므로 네트워크 없이 여기서 잠근다).
|
|
853
|
-
// probeModels·isExplicitSource 는 재시도 게이트를 스텁 서버로 직접 재기 위해 내보낸다 — 기본
|
|
854
|
-
// 엔드포인트 경로는 자식 프로세스로 돌리면 실 벤더 호스트로 나가므로 in-process 로만 잰다.
|
|
855
|
-
export {
|
|
856
|
-
run, classify, whichExecutable, detectTransport, readTomlFields, resolveApi, resolveGeminiApi,
|
|
857
|
-
probeModels, isExplicitSource,
|
|
858
|
-
ABSENT_REASONS, FAMILY_RULES, DEFAULT_OPENAI_BASE_URL, DEFAULT_GEMINI_BASE_URL, GEMINI_BASE_URL_ENV,
|
|
859
|
-
NO_DEFAULT_ENDPOINT_ENV,
|
|
860
|
-
};
|