diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5f2b9d..164595f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1039,15 +1039,15 @@ jobs: npm install --global --prefix "$test_root/install" \ --include=optional --ignore-scripts --no-audit --no-fund \ --registry=https://registry.npmjs.org/ \ - "@xgen/cli@$version"; then + "@xgen-ai/cli@$version"; then break fi test "$attempt" -lt 3 sleep 5 done global_root=$(npm root --global --prefix "$test_root/install") - test -f "$global_root/@xgen/cli/package.json" - test -f "$global_root/@xgen/$PLATFORM_PACKAGE/package.json" + test -f "$global_root/@xgen-ai/cli/package.json" + test -f "$global_root/@xgen-ai/$PLATFORM_PACKAGE/package.json" HOME="$test_root/home" \ XGEN_STATE_HOME="$test_root/state" \ "$test_root/install/bin/xgen" --version > "$test_root/version.txt" @@ -1072,9 +1072,9 @@ jobs: npm install --global --prefix "$test_root/install" \ --include=optional --ignore-scripts --no-audit --no-fund \ --registry=https://registry.npmjs.org/ \ - "@xgen/cli@$version" - test -f "$global_root/@xgen/cli/package.json" - test -f "$global_root/@xgen/$PLATFORM_PACKAGE/package.json" + "@xgen-ai/cli@$version" + test -f "$global_root/@xgen-ai/cli/package.json" + test -f "$global_root/@xgen-ai/$PLATFORM_PACKAGE/package.json" test "$(HOME="$test_root/home" XGEN_STATE_HOME="$test_root/state" \ "$test_root/install/bin/xgen" --version)" = "xgen $version" test ! -e "$test_root/state" @@ -1082,9 +1082,9 @@ jobs: HOME="$test_root/home" \ npm_config_cache="$test_root/cache" \ npm uninstall --global --prefix "$test_root/install" \ - --ignore-scripts --no-audit --no-fund '@xgen/cli' - test ! -e "$global_root/@xgen/cli" - test ! -e "$global_root/@xgen/$PLATFORM_PACKAGE" + --ignore-scripts --no-audit --no-fund '@xgen-ai/cli' + test ! -e "$global_root/@xgen-ai/cli" + test ! -e "$global_root/@xgen-ai/$PLATFORM_PACKAGE" test ! -e "$test_root/install/bin/xgen" - name: Install and verify exact npm package on Windows @@ -1114,7 +1114,7 @@ jobs: & npm.cmd install --global --prefix $InstallRoot ` --include=optional --ignore-scripts --no-audit --no-fund ` --registry=https://registry.npmjs.org/ ` - "@xgen/cli@$Version" + "@xgen-ai/cli@$Version" if ($LASTEXITCODE -eq 0) { $Installed = $true break @@ -1123,10 +1123,10 @@ jobs: } if (-not $Installed) { throw "npm install failed" } $GlobalRoot = (& npm.cmd root --global --prefix $InstallRoot | Out-String).Trim() - if (-not (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen/cli/package.json"))) { + if (-not (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen-ai/cli/package.json"))) { throw "launcher package is missing" } - if (-not (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen/$env:PLATFORM_PACKAGE/package.json"))) { + if (-not (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen-ai/$env:PLATFORM_PACKAGE/package.json"))) { throw "platform package is missing" } $Shim = Join-Path $InstallRoot "xgen.cmd" @@ -1158,15 +1158,15 @@ jobs: & npm.cmd install --global --prefix $InstallRoot ` --include=optional --ignore-scripts --no-audit --no-fund ` --registry=https://registry.npmjs.org/ ` - "@xgen/cli@$Version" + "@xgen-ai/cli@$Version" if ($LASTEXITCODE -ne 0) { throw "npm reinstall failed" } $Reinstalled = (& $Shim --version | Out-String).Trim() if ($LASTEXITCODE -ne 0 -or $Reinstalled -ne "xgen $Version") { throw "npm reinstall produced the wrong version" } if ( - -not (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen/cli/package.json")) -or - -not (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen/$env:PLATFORM_PACKAGE/package.json")) + -not (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen-ai/cli/package.json")) -or + -not (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen-ai/$env:PLATFORM_PACKAGE/package.json")) ) { throw "npm reinstall is missing an exact package" } @@ -1175,11 +1175,11 @@ jobs: } & npm.cmd uninstall --global --prefix $InstallRoot ` - --ignore-scripts --no-audit --no-fund "@xgen/cli" + --ignore-scripts --no-audit --no-fund "@xgen-ai/cli" if ($LASTEXITCODE -ne 0) { throw "npm uninstall failed" } if ( - (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen/cli")) -or - (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen/$env:PLATFORM_PACKAGE")) + (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen-ai/cli")) -or + (Test-Path -LiteralPath (Join-Path $GlobalRoot "@xgen-ai/$env:PLATFORM_PACKAGE")) ) { throw "npm uninstall left package artifacts" } diff --git a/MEMORY.md b/MEMORY.md index 0f255b0..565526c 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,6 +1,6 @@ # xgen CLI와 연계 도구 공동 개발 메모 -갱신: 2026-10-06 (KST) +갱신: 2026-10-24 (KST) ## 사용자가 정한 방향 @@ -137,4 +137,8 @@ - 명세 없는 API 복원 PoC(2026-10-09, 프론트 번들 정적 분석): jsluice는 템플릿 URL(`/api/lists/${id}`)을 놓쳐 acorn AST 추출기를 따로 만들었다. 정밀도는 높지만(4앱 89~100%) 복원율은 프론트 호출 방식에 좌우된다(fetch wrapper 함수를 쓰는 Traccar 16/117). 압축 후에도 클래스 메서드 이름은 살아 있어 요약으로 쓰는 게 LLM 설명보다 낫고, 이름이 사라진 경우만 LLM이 돕는다. 공식 명세가 낡아 번들이 더 최신 API를 보여주는 경우가 있다(listmonk 23개). 검증셋(Kavita·Traccar)은 소진. 평가: ~/.local/share/xgen/evaluations/bundle-api-2026-10-09/PLAN.md - 명세 없는 API 복원 PoC 2(2026-10-10, Spring jar 바이트코드): 복원은 거의 완전(recall 99.7~100%, 정답 밖 초과분 대부분 springdoc @Hidden 실제 라우트)인데 검색은 원래 명세에 짐. 원인은 한국어 질의 대 영어 식별자(한국어 12건 중 1~3건만 hit, 영어는 같거나 우세). 복원 품질과 검색 품질은 별개라 언어 맞춤 설명 단계가 필요. 큰 초안은 DTO inline 시 로컬 소스 5MB 상한 초과 -> components $ref. 평가: ~/.local/share/xgen/evaluations/jar-api-2026-10-10/PLAN.md - 언어 병목 비교(2026-10-13): 검색어 힌트(Q)는 새 공개 held-out에서 hit@5 76 -> 80으로 유지했다(hit@1은 43 -> 37). 수집 시점 summary 보강(S)은 hit@5 87, hit@1 66으로 가장 좋았지만, QS 대 Q 판정에서 한 칸이 3 하락해 불채택했다. 보강 효과 대부분은 같은 언어 칸의 summary 보강에서 나왔고, 한국어 요청 x 영어 명세와 summary 없는 명세는 오르지 않았다. graph-tool-call 0.47 색인은 summary(없으면 METHOD /path)만 쓰고 description은 쓰지 않는다. 다음 후보는 S 단독 대 Q 직접 판정이다. 평가: ~/.local/share/xgen/evaluations/language-sides-2026-10-13/PLAN.md +- npm scope `@xgen-ai`와 bootstrap 게시(2026-10-24, 게시 완료·레지스트리 확인): ADR-0031이 정한 `@xgen` scope는 **다른 계정이 이미 선점**하고 있었다. 그 아래 게시된 package는 0개지만 npm은 user와 org가 같은 namespace라 org로도 못 만든다. 판별은 `registry.npmjs.org/-/org/<이름>/user`가 200이면 선점, 404 `Scope not found`면 빈 이름이다. 레포 runbook이 "scope 확보가 불명확하면 게시하지 말고 naming ADR을 먼저 갱신"으로 fail-closed를 걸어 둬서 추정으로 진행할 수 없었다. 실측 가용: 빈 이름 `@xgen-ai`·`@xgen2bee`·`@xgencli`, 선점 `@xgen`·`@x2bee`·`@plateer`·`@plateerlab`·`@xgenai`. `@xgen-ai`를 골랐다(회사 공개 배포 식별자가 이미 그 이름). 개인 계정 scope는 소유권 이전 시 게시된 6개를 deprecate해야 해서 뺐다. ADR-0051을 쓰고 ADR-0031·0034·0046 상태 줄이 그것을 가리키게 했다(본문은 당시 기록이라 `@xgen` 유지, 0031이 0034에 한 방식 그대로). rename 함정: `@xgen/` 문자열 치환이 **네 곳을 지나친다** — `npm/scripts/smoke.mjs`의 `path.join(globalRoot, '@xgen', 'cli')`, `npm/scripts/check.mjs`의 이스케이프된 정규식 `/^@xgen\/cli-.../`, 문서 두 곳의 slash 없는 scope 언급. 앞 둘은 동작 경로라 놓치면 smoke install과 contract check가 옛 이름을 찾는다. bootstrap 6개는 `0.0.0-bootstrap.0`으로 platform 5개 먼저·launcher 마지막 순서로 `--access public --tag bootstrap` 게시했고, 내용은 `package.json`·`README.md`·`LICENSE`뿐이며 `bin`도 `scripts`도 없다(설치 시 실행되는 코드 없음). 게시 직후 registry API는 몇 분간 404다 — "being processed"는 정상이고 전파를 기다려야 한다. GitHub은 secret `NPM_TOKEN` 갱신 + variable `XGEN_NPM_PUBLISH_ENABLED=true`까지 했다. repo variable에 `XGENY_*` 4개가 개명 잔재로 남아 있고 release workflow는 `XGEN_*`만 읽는다(미정리). +- `run`·`resume`의 진행 표시(2026-10-24, 구현 완료·로컬 검증): `--debug`는 전역 플래그이고 help에 "durable progress events를 보여준다"고 적혀 있지만 `run`·`resume`에서는 **파싱만 되고 버려졌다**(`cli.debug`의 유일한 사용처가 `interactive_command`). 조용히 아무 일도 안 하는 플래그였다. `run_local_with_progress`와 `resume_local_with_model_resolver_and_progress`는 이미 public이었고 `run`만 `run_local_with_started`를 고르고 있었으므로, 호출만 바꾸고 REPL의 `print_progress` 하나를 `pub(crate)`로 올려 같은 formatter를 공유했다. 설계 선택 둘. (1) spinner가 아니라 event 줄을 쓴다 — `run`의 stderr는 이미 `XGEN_*` 줄 계약이라 줄 단위가 pipe·로그와 맞고, spinner는 `XGEN_STARTED`와 커서가 겹쳐 `\r\x1b[2K` 조율이 필요하다(spinner는 프롬프트로 돌아갈 자리가 있는 REPL 것). (2) 기본값은 침묵 — 결과 줄만 파싱하는 host에 묻지 않은 `progress:` prefix를 주지 않는다. 쓰기 실패는 보고만 멈추고 Run은 계속한다(진단 한 줄 때문에 durable 작업을 버리지 않음, `run`의 중단은 process-level signal handler 소유). `resume`은 egress 유무로 갈렸던 두 경로를 `..._and_progress` 하나로 합쳤다(no-egress는 거절하는 resolver, 기존 `resume_local`과 동일). 검증: `--debug`에서 `progress: model_call_starting`·`response_committed`, 없으면 0건, stdout은 양쪽 모두 최종 응답 바이트만(테스트가 `assert_eq`로 고정). 플래그를 안 넘기도록 되돌려 테스트 실패 확인. 전체 691 passed·0 failed(4 ignored), clippy·fmt·공개 문서 검사 통과. +- `run`의 실행 파일 catalog(2026-10-24, 구현 완료·로컬 검증): `--allow-execute`만 주면 `configuration_mismatch`로 죽는데, 이건 버그가 아니라 **의도된 계약**이고 고정 테스트도 있다(protocol_check.rs). 이유는 매니페스트에서 확인했다. execution profile digest에 `environment:PATH`와 catalog된 실행 파일 각각의 해시가 들어가므로, PATH 자동 발견을 기본값으로 하면 같은 명령이 머신마다 다른 프로필을 만들고 `resume`이 프로필 불일치로 닫힌다. REPL은 한 프로세스·한 세션이라 자동 발견해도 되고 `run`은 아니다. 그래서 기본값을 뒤집지 않고 `--allow-path-executables`를 opt-in으로 더했다(`run`·`resume` 양쪽, resume에도 같은 플래그 필요). `--allow-executable` 항목은 그대로 유지되고 이름 충돌 시 이긴다(실측: 명시한 `git=/nonexistent/git`은 거절, 빼면 발견된 git으로 통과). 자동 발견은 **shell을 넣지 않는다** — ADR-0049가 "catalogued executable을 shell 없이" 시작하도록 정했고 PTY session은 Linux 전용이다. `sh -c "<명령>"`은 실제 명령을 argument에 숨겨 Receipt를 무의미하게 만든다. 따라서 Pocket Agent 쪽 shim의 `with_shell`(`--allow-executable sh=`)은 이 플래그로 대체되지 않는다 — shell이 필요하면 host가 이름으로 직접 넣는 것이 설계상 맞는 경로다. 빈 catalog 실패는 어느 쪽이 빠졌는지와 두 가지 해결책을 함께 출력하게 했다(`PublicRunError::Configuration`은 54곳 공용이라 코드만으로는 아무 정보가 없다). 검증: loopback fake provider(probe=completion_candidate / 대화 턴=response_candidate 구분 필요, 안 하면 `completion_without_receipt_completed_plan`으로 보여 제품 결함처럼 읽힌다)로 `run`이 발견된 catalog + `--conversation-responses`로 exit 0 응답까지 확인. egress 동의 검사가 catalog 검사보다 **뒤**에 있어서, 동의 없이 돌리면 pause 지점이 catalog 수용 여부를 네트워크 없이 그대로 드러낸다 — 테스트에 이 성질을 썼다. 전체 690 passed·0 failed(4 ignored), clippy·fmt·공개 문서 검사 통과. +- provider preset과 base URL 규칙(2026-10-24, 구현 완료·로컬 검증): 터미널에서 opencode처럼 쓰려면 provider 선택이 먼저라 preset을 2개에서 5개로 늘렸다(deepseek·openai·openrouter·glm·local, 그 밖은 custom). 함정 셋. (1) `api_endpoints`가 base URL의 path가 `/v1`이길 **강제**해서 자기 API를 다르게 번호 붙이는 provider는 설정 자체가 불가였다(z.ai `/api/paas/v4`, Azure OpenAI `/openai/deployments/`). `/v1`은 관례일 뿐이라 빈 path만 거절하도록 바꿨고 오류 코드는 `base_url_must_end_in_v1` -> `base_url_must_name_the_api_root`다. (2) preset은 사용자가 고르지 않은 값으로 실패할 수 있어 preset 자체를 검사해야 한다. `local`을 `http://localhost:11434/v1`로 썼더니 평문 HTTP는 loopback **literal**만 허용해서 네트워크에 닿기도 전에 `plaintext_endpoint_must_be_loopback`으로 죽었다. `127.0.0.1`로 고치고, 모든 preset이 설정 규칙(base path + transport security)을 통과하는지 보는 테스트를 넣었다(되돌려 실패 확인). (3) Claude는 preset에 넣지 않는다. OpenAI 호환 layer가 `response_format`을 무시한다고 문서에 적혀 있어서 `model setup`은 PASS하고 모든 Run이 깨진다. 설정은 성공하고 실행만 실패하는 preset은 편의가 아니라 지뢰다. OpenRouter 같은 gateway 경유는 된다. GLM은 pay-as-you-go와 Coding Plan의 경로가 달라(`/api/coding/paas/v4`) 주석·문서에 적었다. 검증: loopback fake provider로 picker 6항목·`local` preset·비-`/v1` 루트(`/api/paas/v4`)·trailing slash 정규화·bare host 거절 4종과 `model list|use|check`를 실측했다. preset의 `response_format` 값 자체는 DeepSeek·local만 실측이고 나머지는 provider 문서 기준으로 문서에 구분 표시했다. 로컬 전체 검사 688 passed·0 failed(4 ignored), clippy·fmt·공개 문서 검사 통과. 맥에서는 `scripts/tests` 중 `test_timeout_stops_owned_process_descendants` 하나가 `os.pidfd_open`(Linux 전용) 때문에 원래 실패한다 — eval 스크립트의 이식성 문제이고 제품 경로가 아니다. - collection 운영 명령(2026-10-13, #88): `tools rebuild|disconnect|remove`와 `list` 상태를 추가했다. 함정 두 가지. graph-tool-call artifact에 빌드 시각(`built_at`, `created_at`)이 들어가 같은 입력도 다시 빌드하면 digest가 바뀐다. 그래서 rebuild는 backend 버전이 다를 때만 교체한다. 연결은 collection 이름으로 붙으므로, collection 파일만 지우면 같은 이름의 다른 API가 이전 주소와 토큰을 물려받는다. 그래서 remove는 연결부터 지우고, import는 연결만 남은 이름을 거절한다. CI의 Python 단계는 graph-tool-call 없이 돌아 worker·CLI 전 과정 테스트가 skip되므로 로컬에서 확인한다. diff --git a/README.md b/README.md index 227ce79..7fbf779 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Node.js가 이미 있다면 exact RC를 npm으로 설치할 수 있습니다. `- 제거하므로 사용하지 않습니다. ```bash -npm install --global @xgen/cli@0.1.0-rc.3 +npm install --global @xgen-ai/cli@0.1.0-rc.3 xgen --version ``` diff --git a/crates/xgen-cli/src/composition.rs b/crates/xgen-cli/src/composition.rs index 8a25732..79ba434 100644 --- a/crates/xgen-cli/src/composition.rs +++ b/crates/xgen-cli/src/composition.rs @@ -305,7 +305,7 @@ impl ModelCheckError { match self { Self::Configuration => "configuration_invalid", Self::InvalidBaseUrl => "base_url_invalid", - Self::InvalidBasePath => "base_url_must_end_in_v1", + Self::InvalidBasePath => "base_url_must_name_the_api_root", Self::InvalidModel => "model_invalid", Self::InvalidTokenizer => "tokenizer_invalid", Self::InvalidCredential => "api_key_invalid", diff --git a/crates/xgen-cli/src/main.rs b/crates/xgen-cli/src/main.rs index 2bfafa0..25bb78d 100644 --- a/crates/xgen-cli/src/main.rs +++ b/crates/xgen-cli/src/main.rs @@ -14,10 +14,10 @@ use xgen_cli::{ OsModelCredentialStore, PublicRunError, RequestOptions, ResolvedModelEndpoint, check_openai_compatibility, check_openai_model, discard_local_model_call, discard_local_model_call_at_head, inspect_local_model_call, list_openai_models, - new_credential_reference, prepare_local_process_session, resume_local, - resume_local_with_model_resolver, resume_local_with_model_resolver_and_progress, + new_credential_reference, prepare_local_process_session, + resume_local_with_model_resolver_and_progress, resume_local_with_process_session_and_model_resolver_progress, - run_interactive_with_process_session_progress, run_local_with_started, + run_interactive_with_process_session_progress, run_local_with_progress, }; use xgen_provider_openai::{BearerCredential, ResponseFormat, ThinkingMode}; use zeroize::Zeroizing; @@ -134,7 +134,7 @@ enum ModelCommand { struct ModelCheckArgs { #[command(flatten)] request_options: RequestOptionArgs, - /// OpenAI-compatible API base URL ending in /v1. + /// OpenAI-compatible API root, such as `https://api.openai.com/v1`. #[arg(long)] base_url: Option, /// Exact served model identifier expected in GET /v1/models. @@ -162,12 +162,12 @@ struct ModelSetupArgs { #[command(flatten)] request_options: RequestOptionArgs, /// Provider preset; omitted interactive setup offers a selection. - #[arg(long, value_parser = ["deepseek", "openai", "custom"])] + #[arg(long, value_parser = clap::builder::PossibleValuesParser::new(provider_ids()))] provider: Option, /// Profile name to create or replace. #[arg(long, default_value = "default")] name: String, - /// OpenAI-compatible API base URL ending in /v1. + /// OpenAI-compatible API root, such as `https://api.openai.com/v1`. #[arg(long)] base_url: Option, /// Exact served model identifier; interactive setup lists and prompts when omitted. @@ -228,7 +228,7 @@ struct RunArgs { /// Workspace root opened as the local filesystem capability. #[arg(long, default_value = ".")] workspace: PathBuf, - /// OpenAI-compatible API base URL ending in /v1. + /// OpenAI-compatible API root, such as `https://api.openai.com/v1`. #[arg(long)] base_url: Option, /// Served model identifier. @@ -255,6 +255,15 @@ struct RunArgs { /// Catalog one executable as `LOGICAL_ID=ABSOLUTE_PATH`; repeat for more executables. #[arg(long = "allow-executable", value_name = "ID=ABSOLUTE_PATH")] allow_executables: Vec, + /// Also catalog the known developer executables found on PATH, by their own names. + /// + /// Opt-in, because it makes the catalog depend on this machine's PATH. The + /// interactive REPL does this for every session; `run` does not, so that a + /// Run's execution profile stays the same wherever the same command is + /// issued and `resume` can still match it. `--allow-executable` entries are + /// kept as given and win on a name collision. + #[arg(long)] + allow_path_executables: bool, /// Explicitly allow goal/context/tool output transfer to the remote model boundary. #[arg(long)] allow_remote_model_egress: bool, @@ -321,6 +330,13 @@ struct ResumeArgs { /// Same executable catalog supplied to the original Run. #[arg(long = "allow-executable", value_name = "ID=ABSOLUTE_PATH")] allow_executables: Vec, + /// Same PATH discovery supplied to the original Run. + /// + /// Discovery reads the PATH of whichever invocation runs it, so a catalog + /// that differs from the Run's is a profile change and is refused rather + /// than silently accepted. + #[arg(long)] + allow_path_executables: bool, /// Allow a new remote model request to this invocation's supplied --base-url. #[arg(long)] allow_remote_model_egress: bool, @@ -383,8 +399,8 @@ fn main() -> ExitCode { } }, Some(Command::Model { command }) => run_model_command(command), - Some(Command::Run(args)) => run_command(args), - Some(Command::Resume(args)) => resume_command(args), + Some(Command::Run(args)) => run_command(args, cli.debug), + Some(Command::Resume(args)) => resume_command(args, cli.debug), Some(Command::Recover(args)) => recover_command(&args), Some(Command::Usage(args)) => usage_command(&args), } @@ -417,6 +433,67 @@ fn usage_command(args: &UsageArgs) -> ExitCode { } } +/// Durable-boundary progress on stderr, for a host that asked to watch. +/// +/// Same lines and same single formatter as the interactive host, because this +/// is the same information; only the trigger differs. `--debug` names it, so a +/// host that parses the `XGEN_*` result lines is not handed a new prefix it +/// never asked for. +/// +/// A failed write stops the reporting, not the Run. `run` has no cooperative +/// cancellation of its own — the process-level signal handler owns +/// interruption — and losing a diagnostic line is no reason to abandon durable +/// work that is already underway. +fn progress_reporter(enabled: bool) -> impl FnMut(DriverProgress) -> DriverProgressControl { + let mut reporting = enabled; + move |event| { + if reporting && repl::print_progress(&mut std::io::stderr(), event).is_err() { + reporting = false; + } + DriverProgressControl::Continue + } +} + +/// Developer executables on `PATH`, as the `ID=PATH` entries a catalog takes. +/// +/// A path that cannot be written as text, or carries control characters, is +/// dropped rather than catalogued: the entry would be unusable and the catalog +/// is echoed in diagnostics. +fn discovered_executables() -> Vec<(String, String)> { + repl::discover_developer_executables() + .into_iter() + .filter_map(|(id, path)| { + let path = path.to_str()?; + if path.chars().any(char::is_control) { + return None; + } + Some((id, path.to_owned())) + }) + .collect() +} + +/// The executable catalog a Run is given: what was named, plus PATH if asked. +/// +/// Named entries are kept exactly as supplied and win on a name collision, so +/// adding discovery can widen a catalog but never redirect an entry the host +/// spelled out. Discovery contributes no shell: each entry is one executable, +/// which is what lets a Receipt record what actually ran (ADR-0049). A host +/// that wants a shell catalogs it by name alongside this flag. +fn executable_catalog(named: Vec, include_path: bool) -> Vec { + if !include_path { + return named; + } + let spelled: Vec<&str> = named + .iter() + .map(|entry| entry.split('=').next().unwrap_or(entry)) + .collect(); + let discovered = discovered_executables() + .into_iter() + .filter(|(id, _)| !spelled.contains(&id.as_str())) + .map(|(id, path)| format!("{id}={path}")); + named.iter().cloned().chain(discovered).collect() +} + const REPL_MAX_TICKS: u32 = 32; struct InteractiveHost { @@ -431,18 +508,10 @@ impl InteractiveHost { fn new(detailed_progress: bool) -> Result { let workspace = env::current_dir() .map_err(|_| repl::ReplFailure::new(PublicRunError::Configuration.code()))?; - let mut executable_specs = Vec::new(); - let mut executable_ids = Vec::new(); - for (id, path) in repl::discover_developer_executables() { - let Some(path) = path - .to_str() - .filter(|path| !path.chars().any(char::is_control)) - else { - continue; - }; - executable_specs.push(format!("{id}={path}")); - executable_ids.push(id); - } + let (executable_ids, executable_specs) = discovered_executables() + .into_iter() + .map(|(id, path)| (id.clone(), format!("{id}={path}"))) + .unzip(); Ok(Self { detailed_progress, workspace, @@ -632,37 +701,121 @@ fn interactive_command(debug: bool) -> ExitCode { } struct ProviderPreset { + id: &'static str, + label: &'static str, base_url: &'static str, response_format: &'static str, thinking: &'static str, } -fn provider_preset(provider: &str) -> Option { - match provider { - "deepseek" => Some(ProviderPreset { - base_url: "https://api.deepseek.com/v1", - response_format: "json_object", - thinking: "disabled", - }), - "openai" => Some(ProviderPreset { - base_url: "https://api.openai.com/v1", - response_format: "json_schema", - thinking: "default", - }), - _ => None, - } +/// The endpoints setup can fill in for you, instead of asking you to type one. +/// +/// One table, read by all three places that used to carry their own copy of the +/// list: the `--provider` value set, the interactive picker, and the lookup. A +/// provider is one row. +/// +/// `response_format` is the field that decides whether a provider works at all. +/// The planner answers in structured JSON — a plan, a tool call, a completion — +/// so a provider that ignores the setting returns prose and every Run fails +/// after the profile was configured successfully. That is why Anthropic's +/// Claude is not a row here: its OpenAI-compatible layer documents +/// `response_format` as ignored, so a preset for it would configure cleanly and +/// then fail every turn. Claude is reachable through a gateway that does honour +/// it, such as `OpenRouter`. +const PROVIDER_PRESETS: &[ProviderPreset] = &[ + ProviderPreset { + id: "deepseek", + label: "DeepSeek", + base_url: "https://api.deepseek.com/v1", + response_format: "json_object", + thinking: "disabled", + }, + ProviderPreset { + id: "openai", + label: "OpenAI", + base_url: "https://api.openai.com/v1", + response_format: "json_schema", + thinking: "default", + }, + ProviderPreset { + id: "openrouter", + label: "OpenRouter (many providers behind one key)", + base_url: "https://openrouter.ai/api/v1", + // Models behind OpenRouter differ in whether they take a schema, and + // the gateway passes the setting through rather than emulating it. + // `json_object` is the contract they all accept. + response_format: "json_object", + thinking: "default", + }, + ProviderPreset { + id: "glm", + label: "GLM / Zhipu (z.ai)", + // Not `/v1`: this provider numbers its API `/v4`, which is why the + // base-path rule had to stop demanding a spelling. + // + // This is the pay-as-you-go root. A Coding Plan subscription is served + // from `/api/coding/paas/v4` instead, so that account passes + // `--base-url`, which wins over the preset. + base_url: "https://api.z.ai/api/paas/v4", + response_format: "json_object", + thinking: "disabled", + }, + ProviderPreset { + id: "local", + label: "Local model (Ollama, LM Studio, vLLM …)", + // Loopback plaintext is allowed and credentials are ignored there, so + // this is the one profile that needs no API key and sends nothing off + // the machine. 11434 is Ollama's; LM Studio serves 1234 and vLLM 8000. + // + // The literal address, not `localhost`: plaintext HTTP is accepted only + // for a loopback literal, because a hostname is whatever resolution + // says it is and the key would leave the machine on a lie. + base_url: "http://127.0.0.1:11434/v1", + response_format: "json_object", + thinking: "default", + }, +]; + +fn provider_preset(provider: &str) -> Option<&'static ProviderPreset> { + PROVIDER_PRESETS.iter().find(|preset| preset.id == provider) +} + +/// Every value `--provider` takes: the presets, and `custom` for anything else. +fn provider_ids() -> Vec<&'static str> { + PROVIDER_PRESETS + .iter() + .map(|preset| preset.id) + .chain(std::iter::once("custom")) + .collect() } fn prompt_provider() -> Result { - eprintln!( - "Choose a provider:\n 1) DeepSeek\n 2) OpenAI\n 3) Other OpenAI-compatible endpoint" - ); - match prompt_line("Provider number: ")?.as_str() { - "1" => Ok("deepseek".to_owned()), - "2" => Ok("openai".to_owned()), - "3" => Ok("custom".to_owned()), - _ => Err(ModelCliError::InputUnavailable), + // The last entry is `custom`, so the numbering is the table's own order. + let offered = PROVIDER_PRESETS + .iter() + .map(|preset| preset.label) + .chain(std::iter::once("Other OpenAI-compatible endpoint")); + let menu: Vec = std::iter::once("Choose a provider:".to_owned()) + .chain( + offered + .enumerate() + .map(|(index, label)| format!(" {}) {label}", index + 1)), + ) + .collect(); + eprintln!("{}", menu.join("\n")); + let custom = PROVIDER_PRESETS.len() + 1; + let answer = prompt_line("Provider number: ")?; + let chosen = answer + .trim() + .parse::() + .map_err(|_| ModelCliError::InputUnavailable)?; + if chosen == custom { + return Ok("custom".to_owned()); } + PROVIDER_PRESETS + .get(chosen.wrapping_sub(1)) + .map(|preset| preset.id.to_owned()) + .ok_or(ModelCliError::InputUnavailable) } thread_local! { @@ -796,7 +949,7 @@ fn run_model_command(command: ModelCommand) -> ExitCode { } } -fn run_command(args: RunArgs) -> ExitCode { +fn run_command(args: RunArgs, debug: bool) -> ExitCode { let mut final_response_schema = match args.response_schema { Some(path) => { if let Some(schema) = std::fs::File::open(path).ok().and_then(|file| { @@ -829,6 +982,26 @@ fn run_command(args: RunArgs) -> ExitCode { } else { None }; + let allow_executables = executable_catalog(args.allow_executables, args.allow_path_executables); + // The driver refuses this too, but only as the bare shared configuration + // code. Say which half is missing, before anything asks for a token. + if args.allow_execute && allow_executables.is_empty() { + let error = PublicRunError::Configuration; + eprintln!("XGEN_ERROR code={}", error.code()); + eprintln!(" configuration=executable_catalog"); + let recovery = if args.allow_path_executables { + "--allow-execute approves executions from a catalog, and PATH \ + discovery found no known developer executable. Catalog one with \ + --allow-executable ID=ABSOLUTE_PATH." + } else { + "--allow-execute approves executions from a catalog, and none was \ + supplied. Catalog one with --allow-executable ID=ABSOLUTE_PATH, \ + or add --allow-path-executables for the developer executables \ + found on PATH." + }; + eprintln!(" recovery={recovery}"); + return ExitCode::from(error.exit_code()); + } let resolved = match resolve_model( args.base_url, args.model, @@ -840,35 +1013,38 @@ fn run_command(args: RunArgs) -> ExitCode { Ok(resolved) => resolved, Err(error) => return present_model_configuration_error(error), }; - present(run_local_with_started( - LocalRunRequest { - goal: args.goal, - workspace: args.workspace, - base_url: resolved.base_url, - planner_id: args.planner_id, - model: resolved.model, - tokenizer: resolved.tokenizer, - credential: resolved.credential, - inference_limits: resolved.inference_limits, - request_options: resolved.request_options, - allow_files: args.allow_files, - allow_dirs: args.allow_dirs, - allow_executables: args.allow_executables, - allow_remote_model_egress: args.allow_remote_model_egress, - allow_read: args.allow_read, - allow_write: args.allow_write, - allow_execute: args.allow_execute, - max_ticks: args.max_ticks, - max_model_turns: args.max_model_turns, - final_response_schema, - completion_checks, - conversation_responses: args.conversation_responses, - }, - |run_id| eprintln!("XGEN_STARTED run_id={run_id}"), + let request = LocalRunRequest { + goal: args.goal, + workspace: args.workspace, + base_url: resolved.base_url, + planner_id: args.planner_id, + model: resolved.model, + tokenizer: resolved.tokenizer, + credential: resolved.credential, + inference_limits: resolved.inference_limits, + request_options: resolved.request_options, + allow_files: args.allow_files, + allow_dirs: args.allow_dirs, + allow_executables, + allow_remote_model_egress: args.allow_remote_model_egress, + allow_read: args.allow_read, + allow_write: args.allow_write, + allow_execute: args.allow_execute, + max_ticks: args.max_ticks, + max_model_turns: args.max_model_turns, + final_response_schema, + completion_checks, + conversation_responses: args.conversation_responses, + }; + let started = |run_id: &str| eprintln!("XGEN_STARTED run_id={run_id}"); + present(run_local_with_progress( + request, + started, + progress_reporter(debug), )) } -fn resume_command(args: ResumeArgs) -> ExitCode { +fn resume_command(args: ResumeArgs, debug: bool) -> ExitCode { let ResumeArgs { request_options, run_id, @@ -879,6 +1055,7 @@ fn resume_command(args: ResumeArgs) -> ExitCode { allow_files, allow_dirs, allow_executables, + allow_path_executables, allow_remote_model_egress, allow_read, allow_write, @@ -894,24 +1071,35 @@ fn resume_command(args: ResumeArgs) -> ExitCode { request_options: RequestOptions::default(), allow_files, allow_dirs, - allow_executables, + allow_executables: executable_catalog(allow_executables, allow_path_executables), allow_remote_model_egress, allow_read, allow_write, allow_execute, max_ticks, }; - if !allow_remote_model_egress { - return present(resume_local(request)); - } - let mut resolution_error = None; - let result = resume_local_with_model_resolver(request, || { - resolve_endpoint(base_url, profile, token_stdin, request_options).map_err(|error| { - resolution_error = Some(error); - PublicRunError::Configuration - }) - }); + let result = if allow_remote_model_egress { + resume_local_with_model_resolver_and_progress( + request, + || { + resolve_endpoint(base_url, profile, token_stdin, request_options).map_err(|error| { + resolution_error = Some(error); + PublicRunError::Configuration + }) + }, + progress_reporter(debug), + ) + } else { + // Without egress consent the only outcomes are provider-free: durable + // completion replay, recovery, or the pause that asks for consent. A + // resolver that refuses keeps that contract explicit. + resume_local_with_model_resolver_and_progress( + request, + || Err(PublicRunError::Configuration), + progress_reporter(debug), + ) + }; if let Some(error) = resolution_error { present_model_configuration_error(error) } else { @@ -1091,7 +1279,7 @@ fn try_model_setup(mut args: ModelSetupArgs) -> Result<(ModelProfile, bool), Mod .map_or_else( || { if interactive { - prompt_line("OpenAI-compatible base URL (ending in /v1): ") + prompt_line("OpenAI-compatible API root (e.g. https://api.openai.com/v1): ") } else { Err(ModelCliError::MissingConfiguration) } @@ -1844,6 +2032,73 @@ fn present(result: Result) -> ExitCode { } } +#[cfg(test)] +mod provider_preset_tests { + use super::*; + use xgen_provider_openai::{OpenAiCompatibilityChecker, OpenAiPlannerConfig}; + + /// Every preset must be configurable, which is more than it sounds. + /// + /// A preset's whole job is to spare you from typing a URL that works, so a + /// preset holding a URL the configuration rules reject is worse than no + /// preset at all: setup fails on a value you never chose. `localhost` in + /// the local preset was exactly that — plaintext HTTP is accepted only for + /// a loopback literal, so the row failed before it reached the network. + #[test] + fn presets_pass_the_rules_setup_applies_to_them() { + for preset in PROVIDER_PRESETS { + // The planner id and model do not take part in the rules under + // test here, which are the base-path shape and transport security. + let config = OpenAiPlannerConfig::new(preset.base_url, "xgeny.cli.test", "m", "m") + .unwrap_or_else(|error| { + panic!("preset {} has an unusable base URL: {error}", preset.id) + }); + // No credential, so this only runs the transport-security rules. + // Nothing is sent: the request goes out in `check`, not here. + OpenAiCompatibilityChecker::new(config, None).unwrap_or_else(|error| { + panic!( + "preset {} cannot be reached as configured: {error}", + preset.id + ) + }); + + let options = resolve_request_options( + RequestOptionArgs { + response_format: Some(preset.response_format.to_owned()), + thinking: Some(preset.thinking.to_owned()), + }, + None, + ) + .unwrap_or_else(|_| { + panic!( + "preset {} names a wire setting that does not exist", + preset.id + ) + }); + assert_eq!( + compatibility_label(options), + compatibility_label(options), + "preset {} must resolve to a stable wire profile", + preset.id + ); + } + } + + /// The menu numbers and `--provider` values are the table, not a copy of it. + #[test] + fn the_picker_and_the_flag_agree_with_the_table() { + let ids = provider_ids(); + let (presets, custom) = ids.split_at(PROVIDER_PRESETS.len()); + assert_eq!(custom, ["custom"]); + for (listed, preset) in presets.iter().zip(PROVIDER_PRESETS) { + assert_eq!(*listed, preset.id); + assert!(provider_preset(preset.id).is_some()); + } + assert!(provider_preset("custom").is_none()); + assert!(provider_preset("nonexistent").is_none()); + } +} + #[cfg(test)] mod onboarding_tests { use super::*; diff --git a/crates/xgen-cli/src/repl.rs b/crates/xgen-cli/src/repl.rs index b154318..6fc11f4 100644 --- a/crates/xgen-cli/src/repl.rs +++ b/crates/xgen-cli/src/repl.rs @@ -935,7 +935,7 @@ where }) } -fn print_progress(output: &mut impl Write, progress: DriverProgress) -> io::Result<()> { +pub(crate) fn print_progress(output: &mut impl Write, progress: DriverProgress) -> io::Result<()> { let line = match progress { DriverProgress::ModelCallStarting => "progress: model_call_starting", DriverProgress::PlanCommitted => "progress: plan_committed", diff --git a/crates/xgen-cli/tests/protocol_check.rs b/crates/xgen-cli/tests/protocol_check.rs index e5efe52..2f1a834 100644 --- a/crates/xgen-cli/tests/protocol_check.rs +++ b/crates/xgen-cli/tests/protocol_check.rs @@ -171,6 +171,80 @@ fn execute_approval_without_an_executable_catalog_is_rejected() { assert!(output.stdout.is_empty()); let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); assert!(stderr.contains("code=configuration_mismatch")); + // The shared configuration code says nothing on its own, and the flag was + // just typed, so the failure names which half is missing and both ways out. + assert!(stderr.contains("configuration=executable_catalog")); + assert!(stderr.contains("--allow-executable ID=ABSOLUTE_PATH")); + assert!(stderr.contains("--allow-path-executables")); +} + +/// A directory holding one executable named after a discovered developer tool. +#[cfg(unix)] +fn path_with_a_developer_tool(directory: &std::path::Path) -> &std::path::Path { + use std::os::unix::fs::PermissionsExt as _; + + let tool = directory.join("git"); + std::fs::write(&tool, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o700)).unwrap(); + directory +} + +/// `--allow-path-executables` is the catalog `--allow-execute` needs. +/// +/// Egress consent is checked after the catalog, so the pause that asks for it +/// is proof the catalog was accepted; nothing is sent and no model is involved. +#[cfg(unix)] +#[test] +fn path_discovery_supplies_the_executable_catalog() { + let directory = tempdir().expect("temporary directory"); + let workspace = tempdir().expect("temporary workspace"); + let output = xgen() + .env("XGEN_OPENAI_BASE_URL", "http://127.0.0.1:18000/v1") + .env("XGEN_OPENAI_MODEL", "test-model") + .env("PATH", path_with_a_developer_tool(directory.path())) + .args(["run", "--workspace"]) + .arg(workspace.path()) + .args([ + "--allow-dir", + ".", + "--allow-execute", + "--allow-path-executables", + "test goal", + ]) + .output() + .expect("xgen should accept a discovered catalog"); + + assert_eq!(output.status.code(), Some(10)); + let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); + assert!(stderr.contains("reason=remote_model_egress_consent_required")); + assert!(!stderr.contains("configuration_mismatch")); +} + +/// Asking for discovery that finds nothing is still an empty catalog. +#[cfg(unix)] +#[test] +fn path_discovery_that_finds_nothing_says_so() { + let empty = tempdir().expect("temporary directory"); + let output = xgen() + .env("XGEN_OPENAI_BASE_URL", "http://127.0.0.1:18000/v1") + .env("XGEN_OPENAI_MODEL", "test-model") + .env("PATH", empty.path()) + .args([ + "run", + "--allow-file", + "README.md", + "--allow-execute", + "--allow-path-executables", + "test goal", + ]) + .output() + .expect("xgen should reject an empty discovered catalog"); + + assert_eq!(output.status.code(), Some(64)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); + assert!(stderr.contains("configuration=executable_catalog")); + assert!(stderr.contains("found no known developer executable")); } #[test] diff --git a/crates/xgen-cli/tests/public_run_resume.rs b/crates/xgen-cli/tests/public_run_resume.rs index fcb2eab..936375a 100644 --- a/crates/xgen-cli/tests/public_run_resume.rs +++ b/crates/xgen-cli/tests/public_run_resume.rs @@ -1773,6 +1773,78 @@ fn pty_sessions_do_not_outlive_a_host_ended_by_sigterm() { } } +/// `--debug` is documented as showing durable progress events, and for `run` +/// it did nothing at all: the flag parsed and was then dropped on the floor. +/// +/// The events go to stderr beside the existing `XGEN_*` result lines, so a host +/// that only reads stdout keeps the answer and nothing else, and one that does +/// not pass the flag sees exactly what it saw before. +#[test] +fn debug_reports_durable_progress_for_run_and_leaves_stdout_alone() { + let fixture = tempdir().expect("test directory should exist"); + let state_root = fixture.path().join("state"); + let workspace = fixture.path().join("workspace"); + fs::create_dir(&workspace).expect("workspace should create"); + let answer = "a loopback answer that needs no tool"; + let response = provider_response(&json!({ + "formatVersion": 1, + "kind": "response_candidate", + "steps": [], + "summary": answer + })); + + let watched = OneTurnServer::spawn(response.clone()); + let output = xgen(&state_root) + .args(["--debug", "run", "--workspace"]) + .arg(&workspace) + .args(["--base-url", &watched.base_url]) + .args(["--model", MODEL, "--tokenizer", TOKENIZER]) + .args([ + "--allow-dir", + ".", + "--allow-remote-model-egress", + "--conversation-responses", + "answer without tools", + ]) + .bounded_output() + .expect("watched run should finish"); + watched.handle.join().expect("server should finish"); + assert_exit(&output, 0); + let watched_stderr = stderr(&output); + assert!( + watched_stderr.contains("progress: model_call_starting"), + "{watched_stderr}" + ); + assert!( + watched_stderr.contains("progress: response_committed"), + "{watched_stderr}" + ); + // The answer is the whole of stdout; diagnostics never join it. + assert_eq!(String::from_utf8_lossy(&output.stdout), answer); + + let quiet_server = OneTurnServer::spawn(response); + let quiet = xgen(&state_root) + .args(["run", "--workspace"]) + .arg(&workspace) + .args(["--base-url", &quiet_server.base_url]) + .args(["--model", MODEL, "--tokenizer", TOKENIZER]) + .args([ + "--allow-dir", + ".", + "--allow-remote-model-egress", + "--conversation-responses", + "answer without tools", + ]) + .bounded_output() + .expect("quiet run should finish"); + quiet_server.handle.join().expect("server should finish"); + assert_exit(&quiet, 0); + let quiet_stderr = stderr(&quiet); + assert!(!quiet_stderr.contains("progress: "), "{quiet_stderr}"); + assert!(quiet_stderr.contains("XGEN_RESPONDED"), "{quiet_stderr}"); + assert_eq!(String::from_utf8_lossy(&quiet.stdout), answer); +} + fn provider_response(content: &Value) -> Vec { serde_json::to_vec(&json!({ "id": RAW_RESPONSE_SENTINEL, diff --git a/crates/xgen-provider-openai/src/lib.rs b/crates/xgen-provider-openai/src/lib.rs index bd5fd58..1f6a738 100644 --- a/crates/xgen-provider-openai/src/lib.rs +++ b/crates/xgen-provider-openai/src/lib.rs @@ -193,9 +193,10 @@ pub struct OpenAiPlannerConfig { impl OpenAiPlannerConfig { /// Build a fixed `OpenAI` Chat Completions profile from an API base URL. /// - /// The base URL must end in `/v1`; this constructor appends `/chat/completions`. Endpoint - /// location and credentials are intentionally excluded from the request-profile digest. The - /// stable `planner_id` identifies the configured provider endpoint without exposing a URL. + /// The base URL must name the API root rather than a bare host; this constructor appends + /// `/chat/completions` to it. Endpoint location and credentials are intentionally excluded + /// from the request-profile digest. The stable `planner_id` identifies the configured + /// provider endpoint without exposing a URL. /// /// # Errors /// @@ -1125,7 +1126,7 @@ impl PlannerPort for OpenAiPlanner { pub enum OpenAiPlannerConfigError { #[error("OpenAI-compatible API base URL is invalid")] InvalidBaseUrl, - #[error("OpenAI-compatible API base URL must end in /v1")] + #[error("OpenAI-compatible API base URL must name the API root, not a bare host")] InvalidBasePath, #[error("planner identifier is invalid")] InvalidPlannerId, @@ -2058,8 +2059,20 @@ fn api_endpoints(base_url: &str) -> Result<(Url, Url), OpenAiPlannerConfigError> { return Err(OpenAiPlannerConfigError::InvalidBaseUrl); } + // The base URL has to name the API root, because both endpoints are built + // by appending to it. A bare host carries no root to append to, and + // `/chat/completions` at the origin is a different endpoint from the one + // the caller meant — which is the ambiguity this rejects. + // + // What it does NOT require is that the root be spelled `/v1`. It used to, + // and the name is a convention rather than a compatibility condition: the + // two lines below append the same way to any root. The rule excluded + // OpenAI-compatible providers that number their API differently — + // Zhipu/GLM serves `/api/paas/v4`, Azure OpenAI + // `/openai/deployments/` — and excluded them completely, since + // `--base-url` went through the same check as the presets. let path = endpoint.path().trim_end_matches('/').to_owned(); - if !path.ends_with("/v1") { + if path.is_empty() { return Err(OpenAiPlannerConfigError::InvalidBasePath); } let mut model_catalog_endpoint = endpoint.clone(); @@ -2941,10 +2954,28 @@ mod tests { #[test] fn config_rejects_ambiguous_endpoint_and_plaintext_credentials() { + // A bare host names no API root to append to, which is the ambiguity. assert!(matches!( OpenAiPlannerConfig::new("http://localhost:8000", PLANNER_ID, MODEL, TOKENIZER), Err(OpenAiPlannerConfigError::InvalidBasePath) )); + assert!(matches!( + OpenAiPlannerConfig::new("http://localhost:8000/", PLANNER_ID, MODEL, TOKENIZER), + Err(OpenAiPlannerConfigError::InvalidBasePath) + )); + // A root that is not spelled `/v1` is still a root. These are real + // OpenAI-compatible providers that the old spelling rule excluded + // outright, presets and `--base-url` alike. + for root in [ + "https://api.z.ai/api/paas/v4", + "https://open.bigmodel.cn/api/paas/v4", + "https://example.openai.azure.com/openai/deployments/gpt", + ] { + assert!( + OpenAiPlannerConfig::new(root, PLANNER_ID, MODEL, TOKENIZER).is_ok(), + "{root} names an API root and must be accepted" + ); + } assert!(matches!( OpenAiPlannerConfig::new( "http://user:secret@localhost:8000/v1", diff --git a/docs/adr/0031-npm-native-distribution.md b/docs/adr/0031-npm-native-distribution.md index 000dcd9..19aba98 100644 --- a/docs/adr/0031-npm-native-distribution.md +++ b/docs/adr/0031-npm-native-distribution.md @@ -1,6 +1,7 @@ # ADR-0031: npm은 네이티브 XGEN의 무스크립트 배포 계층이다 -- 상태: Accepted (`npm` 게시 인증 결정은 [ADR-0034](0034-npm-token-publishing-policy.md)가 대체) +- 상태: Accepted (`npm` 게시 인증 결정은 [ADR-0034](0034-npm-token-publishing-policy.md)가, + package scope는 [ADR-0051](0051-npm-package-scope-xgen-ai.md)가 대체) - 날짜: 2026-09-01 - 적용 범위: npm package, GitHub Actions release, 사용자 설치 diff --git a/docs/adr/0034-npm-token-publishing-policy.md b/docs/adr/0034-npm-token-publishing-policy.md index d52b7d6..08afa8b 100644 --- a/docs/adr/0034-npm-token-publishing-policy.md +++ b/docs/adr/0034-npm-token-publishing-policy.md @@ -1,6 +1,6 @@ # ADR-0034: npm은 granular token으로 게시하고 provenance는 OIDC로 증명한다 -- 상태: Accepted +- 상태: Accepted (`@xgen` scope 이름은 [ADR-0051](0051-npm-package-scope-xgen-ai.md)가 대체) - 날짜: 2026-09-02 - 적용 범위: npm package bootstrap, GitHub Actions release, npm credential 운영 - 대체 결정: ADR-0031의 npm Trusted Publisher와 계정 2FA 요구 diff --git a/docs/adr/0046-xgen-public-name-and-storage-compatibility.md b/docs/adr/0046-xgen-public-name-and-storage-compatibility.md index 8fe82f2..b459803 100644 --- a/docs/adr/0046-xgen-public-name-and-storage-compatibility.md +++ b/docs/adr/0046-xgen-public-name-and-storage-compatibility.md @@ -1,6 +1,6 @@ # ADR-0046: XGEN 이름 통일과 기존 저장 데이터 호환 -- 상태: Accepted +- 상태: Accepted (`npm` package scope 결정은 [ADR-0051](0051-npm-package-scope-xgen-ai.md)가 대체) - 날짜: 2026-10-04 ## 결정 diff --git a/docs/adr/0051-npm-package-scope-xgen-ai.md b/docs/adr/0051-npm-package-scope-xgen-ai.md new file mode 100644 index 0000000..735a94e --- /dev/null +++ b/docs/adr/0051-npm-package-scope-xgen-ai.md @@ -0,0 +1,53 @@ +# ADR-0051: npm package scope는 `@xgen-ai`를 쓴다 + +- 상태: Accepted +- 날짜: 2026-10-24 +- 적용 범위: npm package 이름, GitHub Actions release, 사용자 설치 문서 +- 대체 결정: ADR-0031과 ADR-0046의 `@xgen` scope 사용 +- protocol·journal·SQLite schema 변경: 없음 +- runtime 구현 변경: launcher가 찾는 platform package 이름 + +## 배경 + +ADR-0031은 launcher 하나와 platform package 다섯 개를 `@xgen` scope에 게시하기로 했고, ADR-0046은 +제품 이름을 XGEN으로 통일하면서 이 scope는 이미 XGEN 이름이므로 유지한다고 적었다. 두 결정 모두 그 +scope를 쓸 수 있다는 전제를 확인하지 않았다. + +2026-10-24 실측 결과 `@xgen`은 다른 계정이 이미 선점하고 있다. 그 아래 게시된 package는 없지만, npm은 +user와 organization이 같은 namespace를 공유하므로 organization으로도 확보할 수 없다. ADR-0031에서 파생된 +운영 절차는 "scope 확보 여부가 불명확하면 게시하지 말고 naming ADR을 먼저 갱신한다"로 fail-closed를 +걸어 두었다. 따라서 이름을 바꾸는 결정이 게시의 선행 조건이다. + +## 결정 + +npm scope를 `@xgen-ai`로 바꾼다. 게시할 여섯 package는 다음과 같다. + +- `@xgen-ai/cli` +- `@xgen-ai/cli-linux-x64-musl` +- `@xgen-ai/cli-linux-arm64-musl` +- `@xgen-ai/cli-darwin-x64` +- `@xgen-ai/cli-darwin-arm64` +- `@xgen-ai/cli-win32-x64` + +ADR-0031이 정한 배포 구조는 그대로다. 같은 exact version, lifecycle script 없음, 설치 중 download와 +native compile 없음, provenance는 OIDC 증명이다. 바뀌는 것은 이름 하나다. + +`@xgen-ai`를 고른 이유는 회사의 공개 배포 식별자가 이미 그 이름이기 때문이다. 다른 registry에서도 같은 +이름으로 모델과 산출물을 공개하고 있어, 설치 경로와 공개 식별자가 어긋나지 않는다. + +개인 계정 scope는 쓰지 않는다. 제품 설치 경로가 개인 식별자를 가리키면 소유권을 조직으로 옮길 때 이미 +게시된 package 여섯 개를 deprecate하고 사용자에게 재설치를 요구해야 한다. 이름은 게시 이후 사실상 +되돌릴 수 없으므로 첫 게시에서 결정한다. + +비어 있던 다른 이름(`@xgencli`, `@xgen2bee`)은 제품 이름도 회사 식별자도 정확히 가리키지 못해 +버렸다. Scope 없는 `xgen` 단일 package는 platform package 다섯 개를 묶을 namespace가 없어 ADR-0031의 +구조와 맞지 않는다. + +## 결과 + +`@xgen` scope에 게시된 XGEN package는 없다. rc.3이 최초 게시이므로 이름 변경에 따르는 사용자 마이그레이션 +경로가 필요하지 않다. + +변경은 운영 자산에만 적용한다. `npm/platforms.json`과 `npm/packages/cli/`의 package 정의, release +workflow, 검사 script, 사용자 설치 문서다. ADR-0031·ADR-0034·ADR-0046 본문은 당시 결정의 기록이므로 +`@xgen`을 그대로 두고 상태 줄에서 이 결정을 가리킨다. diff --git a/docs/development/interactive-repl.md b/docs/development/interactive-repl.md index 1e20a12..b05a7a0 100644 --- a/docs/development/interactive-repl.md +++ b/docs/development/interactive-repl.md @@ -89,6 +89,12 @@ xgen --debug `--debug`와 pipe 입력에서는 기존 `progress:` event 및 `XGEN_STARTED` 계약을 유지하고 ANSI animation을 출력하지 않는다. Progress는 runtime의 redacted lifecycle event이며 strict JSON proposal은 표시하지 않는다. +`--debug`는 `run`과 `resume`에도 같은 `progress:` event를 같은 formatter로 내보낸다. 전부 stderr의 +기존 `XGEN_*` 결과 줄 옆에 쓰므로 stdout은 최종 응답만 담는다. 플래그를 주지 않으면 아무 event도 +내보내지 않아서, 결과 줄만 파싱하는 host는 묻지 않은 prefix를 받지 않는다. 진행 표시 쓰기가 실패해도 +Run은 계속한다. 진단 한 줄을 잃은 것이 이미 진행 중인 durable 작업을 버릴 이유는 아니고, `run`의 +중단은 process-level signal handler가 소유한다. + Ctrl+C는 다음 안전한 durable 경계에서 멈춘다. Model request나 process outcome이 이미 불확정해졌으면 `user_cancelled`보다 `model_call_unknown`/`effect_outcome_unknown`이 우선하고 자동 재실행하지 않는다. Network/process call이 진행 중이면 configured timeout까지 기다릴 수 있다. diff --git a/docs/development/npm-distribution.md b/docs/development/npm-distribution.md index 992f9d5..de56921 100644 --- a/docs/development/npm-distribution.md +++ b/docs/development/npm-distribution.md @@ -1,23 +1,23 @@ # npm granular token 게시와 provenance 운영 -`@xgen/cli`는 native XGEN를 npm으로 설치하기 위한 선택형 배포 계층이다. 직접 GitHub installer를 쓰는 +`@xgen-ai/cli`는 native XGEN를 npm으로 설치하기 위한 선택형 배포 계층이다. 직접 GitHub installer를 쓰는 사용자는 Node.js가 필요 없고, npm 경로를 고른 사용자는 Node.js 22.14 이상이 필요하다. 두 경로는 같은 release binary를 실행한다. 이 package 이름은 설치 entrypoint를 뜻하며 화면 구현을 뜻하지 않는다. 현재 가벼운 REPL이나 향후 같은 -`xgen` binary에 포함될 TUI는 동일한 `@xgen/cli`로 배포할 수 있다. TUI를 별도 제품·release cadence로 +`xgen` binary에 포함될 TUI는 동일한 `@xgen-ai/cli`로 배포할 수 있다. TUI를 별도 제품·release cadence로 분리할 때만 별도 package naming ADR을 먼저 작성한다. ## Package 구성 | Package | 대상 | | --- | --- | -| `@xgen/cli` | OS/CPU 선택과 native process 실행만 담당하는 launcher | -| `@xgen/cli-linux-x64-musl` | Linux x86-64 | -| `@xgen/cli-linux-arm64-musl` | Linux ARM64 | -| `@xgen/cli-darwin-x64` | macOS Intel | -| `@xgen/cli-darwin-arm64` | macOS Apple Silicon | -| `@xgen/cli-win32-x64` | Windows x86-64 | +| `@xgen-ai/cli` | OS/CPU 선택과 native process 실행만 담당하는 launcher | +| `@xgen-ai/cli-linux-x64-musl` | Linux x86-64 | +| `@xgen-ai/cli-linux-arm64-musl` | Linux ARM64 | +| `@xgen-ai/cli-darwin-x64` | macOS Intel | +| `@xgen-ai/cli-darwin-arm64` | macOS Apple Silicon | +| `@xgen-ai/cli-win32-x64` | Windows x86-64 | 모든 package는 같은 exact version으로 게시한다. Lifecycle script, 설치 중 GitHub download와 native compile은 없다. Launcher는 현재 platform package가 없으면 종료한다. `--omit=optional` 설치는 지원하지 @@ -27,11 +27,11 @@ compile은 없다. Launcher는 현재 platform package가 없으면 종료한다 이 절차는 release tag를 만들기 전에 npm scope 관리자 한 명이 수행한다. -1. 해당 계정 또는 npm organization이 `@xgen` scope 여섯 package를 public으로 게시할 권한이 있는지 - 확인한다. 권한이나 scope 확보 여부가 불명확하면 `@xgen/cli`를 게시하지 말고 naming ADR을 먼저 +1. 해당 계정 또는 npm organization이 `@xgen-ai` scope 여섯 package를 public으로 게시할 권한이 있는지 + 확인한다. 권한이나 scope 확보 여부가 불명확하면 `@xgen-ai/cli`를 게시하지 말고 naming ADR을 먼저 갱신한다. 2. npm에서 package read/write 권한과 `bypass 2FA`가 있는 granular access token을 준비한다. 가능하면 - `@xgen` package 범위와 만료 시점을 제한한다. Token 값은 repository, release artifact, 문서와 명령 + `@xgen-ai` package 범위와 만료 시점을 제한한다. Token 값은 repository, release artifact, 문서와 명령 출력에 넣지 않고 GitHub Actions repository secret `NPM_TOKEN`에만 저장한다. 3. clean `main` checkout에서 bootstrap tarball을 만든다. @@ -64,7 +64,7 @@ quality + 5-platform native build -> immutable GitHub Release + attestations -> GitHub Release exact bundle 재다운로드/검증 -> 5 platform npm packages publish/verify - -> @xgen/cli publish/verify + -> @xgen-ai/cli publish/verify -> 5-platform public registry install smoke ``` diff --git a/docs/development/rc3-developer-preview-pilot.md b/docs/development/rc3-developer-preview-pilot.md index 4a5b639..0a41e30 100644 --- a/docs/development/rc3-developer-preview-pilot.md +++ b/docs/development/rc3-developer-preview-pilot.md @@ -7,7 +7,7 @@ ## 사전 조건과 중단 조건 -- GitHub Release `v0.1.0-rc.3`과 `@xgen/cli@0.1.0-rc.3`이 게시됐고 provenance, checksum과 package +- GitHub Release `v0.1.0-rc.3`과 `@xgen-ai/cli@0.1.0-rc.3`이 게시됐고 provenance, checksum과 package integrity 검증이 끝나야 한다. - 각 참여자는 repository checkout이나 Rust compiler 없이 먼저 설치·온보딩 smoke를 통과한다. 언어별 compiler/interpreter는 해당 coding fixture 실행을 위해서만 사용한다. diff --git a/docs/development/rc3-release-candidate.md b/docs/development/rc3-release-candidate.md index 5db3390..db2e9ec 100644 --- a/docs/development/rc3-release-candidate.md +++ b/docs/development/rc3-release-candidate.md @@ -38,7 +38,7 @@ script는 필요하지 않다. Model endpoint와 사용자가 실행하려는 co | 지속성 | journal/WorkGraph/Receipt/ToolOutput과 private invocation recipe의 process-restart 복원 | | 복구 | Started 뒤 outcome을 확정하지 못한 effect를 Unknown으로 닫고 자동 replay하지 않음 | | 장기 turn | PlanningContext v3가 Step plan 순서와 passed Receipt ToolOutput 관찰 순서를 보존 | -| 설치 | 다섯 target native/checksum installer와 무스크립트 `@xgen/cli` exact-version npm package | +| 설치 | 다섯 target native/checksum installer와 무스크립트 `@xgen-ai/cli` exact-version npm package | Filesystem, process와 model egress 승인은 서로 대체하지 않는다. Process result의 `success=false`는 adapter 장애가 아니라 test/build의 durable non-zero 결과일 수 있으며 모델은 그 bounded output을 다음 turn에서 diff --git a/docs/development/releasing.md b/docs/development/releasing.md index 7645df8..949d389 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -29,7 +29,7 @@ binary를 감싸는 선택형 설치 채널이며 제품 runtime을 다시 구 `XGEN_MAIN_PROTECTION_ENABLED=true`를 둔다. - Bypass actor가 없음을 관리자가 확인한 뒤 Actions repository variable `XGEN_RELEASE_RULESET_NO_BYPASS=true`를 설정한다. Read-only ruleset API는 bypass 목록을 숨길 수 있다. -- npm의 `@xgen` scope에 여섯 package를 게시할 owner 권한을 확인한다. Package read/write와 `bypass 2FA` +- npm의 `@xgen-ai` scope에 여섯 package를 게시할 owner 권한을 확인한다. Package read/write와 `bypass 2FA` 권한이 있는 granular token을 repository secret `NPM_TOKEN`에 저장하고 source, artifact와 명령 출력에는 넣지 않는다. 실행 파일이 없는 bootstrap version을 같은 정책으로 먼저 게시한다. 전체 절차는 [npm granular token 게시와 provenance](npm-distribution.md)를 따른다. diff --git a/docs/getting-started.md b/docs/getting-started.md index 26b8140..ac00838 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -15,7 +15,7 @@ launcher용 Node.js 22.14 이상이 필요하다. 사용할 model endpoint 자 22.14 이상이 있는 지원 OS에서는 아래 순서가 가장 짧다. ```bash -npm install --global --include=optional @xgen/cli@0.1.0-rc.3 +npm install --global --include=optional @xgen-ai/cli@0.1.0-rc.3 xgen --version xgen model setup xgen model check --compatibility @@ -52,16 +52,16 @@ Linux binary는 musl target, Windows binary는 static CRT로 빌드한다. macOS ## 설치 Node.js 22.14 이상이 이미 있는 개발자는 RC3가 npm에 게시된 뒤 다음처럼 exact version을 설치한다. -`@xgen/cli`에는 lifecycle script가 없고 현재 OS/CPU용 native optional package 하나를 registry에서 함께 +`@xgen-ai/cli`에는 lifecycle script가 없고 현재 OS/CPU용 native optional package 하나를 registry에서 함께 설치한다. 설치 중 GitHub download나 source compile을 하지 않는다. ```bash -npm install --global @xgen/cli@0.1.0-rc.3 +npm install --global @xgen-ai/cli@0.1.0-rc.3 xgen --version xgen licenses ``` -Prerelease 최신 채널은 `@xgen/cli@next`, stable은 `@xgen/cli@latest`지만 재현 가능한 설치와 검증에는 +Prerelease 최신 채널은 `@xgen-ai/cli@next`, stable은 `@xgen-ai/cli@latest`지만 재현 가능한 설치와 검증에는 exact version을 사용한다. `npm install --omit=optional`은 실행 binary package를 제거하므로 지원하지 않는다. npm 경로에서 platform package를 찾지 못하면 launcher는 다른 binary를 내려받지 않고 종료한다. @@ -152,7 +152,8 @@ Checksum은 전송 손상을 탐지하지만 같은 release publisher 자체를 현재 검증된 기준은 vLLM의 OpenAI-compatible Chat Completions endpoint다. 다음 조건을 모두 만족해야 실제 planner call이 통과한다. -- base URL의 path가 `/v1`로 끝난다. +- base URL이 bare host가 아니라 API root를 가리킨다. 대부분 `/v1`이지만 provider가 자기 API를 + 다르게 번호 붙이면 그 경로를 쓴다(예: `/api/paas/v4`). 끝에 `/`가 붙어도 같게 취급한다. - plaintext HTTP는 `127.0.0.1` 또는 `::1` literal loopback만 사용한다. - server가 strict JSON Schema response format을 지원한다. - response의 `model` 문자열이 설정한 served model ID와 정확히 같다. @@ -167,6 +168,25 @@ xgen model list xgen model check --compatibility ``` +`model setup`이 provider를 물을 때 고르는 preset은 base URL과 wire 설정만 채워 주는 기본값이다. +명시한 option, `XGEN_OPENAI_*` 환경변수, 이미 있는 같은 이름의 profile이 preset보다 우선한다. +`--provider `로 대화 없이 지정할 수도 있다. + +| id | provider | base URL | 검증 | +| --- | --- | --- | --- | +| `deepseek` | DeepSeek | `https://api.deepseek.com/v1` | 실제 호출로 확인 | +| `openai` | OpenAI | `https://api.openai.com/v1` | 미확인, provider 문서 기준 | +| `openrouter` | OpenRouter | `https://openrouter.ai/api/v1` | 미확인, provider 문서 기준 | +| `glm` | GLM / Zhipu (z.ai) | `https://api.z.ai/api/paas/v4` | 미확인, provider 문서 기준. Coding Plan 계정은 `https://api.z.ai/api/coding/paas/v4`이므로 `--base-url`로 지정한다 | +| `local` | 로컬 model (Ollama 기본 port) | `http://127.0.0.1:11434/v1` | loopback fixture로 확인 | +| `custom` | 그 밖의 OpenAI-compatible endpoint | 직접 입력 | — | + +preset에서 가장 중요한 값은 `response_format`이다. planner는 구조화된 JSON으로 답하므로 이 설정을 +무시하는 provider는 `model setup`은 통과하고 Run은 전부 실패한다. 그래서 Anthropic Claude는 +preset에 없다. Claude의 OpenAI 호환 layer는 `response_format`을 무시한다고 문서에 적혀 있어서, +preset으로 넣으면 설정은 깨끗하게 끝나고 매 턴이 깨진다. 이 설정을 지키는 gateway(예: OpenRouter) +를 통하면 사용할 수 있다. + 인증 없는 loopback vLLM을 non-interactive하게 지정할 수도 있다. Tokenizer를 생략하면 model ID를 tokenizer/profile identity로 사용한다. @@ -339,6 +359,31 @@ xgen resume run-0123456789abcdef0123456789abcdef \ --allow-execute ``` +매번 경로를 적지 않으려면 `--allow-path-executables`로 PATH에서 찾은 개발 도구(git, cargo, node, +npm, python3, go, make, java 등)를 한 번에 catalog에 넣는다. 대화형 REPL은 매 세션 이렇게 동작하지만 +`run`은 기본값이 아니다. 발견 결과는 그 실행의 PATH에 달려 있어서 Run의 execution profile이 +머신마다 달라지고, 그러면 `resume`이 프로필 불일치로 닫힌다. 그래서 opt-in이고, `resume`에도 같은 +플래그를 다시 줘야 한다. `--allow-executable`로 적은 항목은 그대로 유지되며 이름이 겹치면 이긴다. + +```bash +xgen run \ + --workspace . \ + --allow-dir . \ + --allow-path-executables \ + --allow-remote-model-egress \ + --allow-read \ + --allow-write \ + --allow-execute \ + '테스트를 돌리고 실패를 고쳐줘.' +``` + +자동 발견은 shell을 catalog에 넣지 않는다. 항목 하나가 executable 하나이므로 Receipt에 실제로 실행된 +것이 남는다(ADR-0049). `sh -c "<명령>"`은 실제 명령을 argument 안에 숨기므로, shell이 필요한 host는 +`--allow-executable sh=/bin/sh`처럼 이름을 직접 적어 함께 넣는다. + +`--allow-execute`는 승인이고 catalog는 나머지 한쪽이다. catalog 없이 `--allow-execute`만 주면 +`configuration_mismatch`로 닫히며, 어느 쪽이 빠졌고 어떻게 채우는지 함께 출력한다. + 실행은 shell을 사용하지 않지만 OS sandbox도 아니다. 허용한 compiler, package manager, interpreter와 그 하위 process는 현재 사용자 권한으로 project code를 실행할 수 있다. CLI는 ambient 환경 전체를 상속하지 않고 제한된 host 환경만 전달하며 API key와 proxy/클라우드 credential은 child에 전달하지 @@ -377,7 +422,7 @@ npm 설치본은 새 exact version을 다시 설치해 업데이트한다. `next 가리킬 수 있으므로 파일럿과 장애 재현에는 사용하지 않는다. ```bash -npm install --global --include=optional @xgen/cli@0.1.0-rc.3 +npm install --global --include=optional @xgen-ai/cli@0.1.0-rc.3 xgen --version ``` @@ -416,7 +461,7 @@ Immutable GitHub Release나 npm version에 치명적 문제가 있으면 기존 npm 설치본은 launcher와 현재 platform package를 함께 제거한다. ```bash -npm uninstall --global @xgen/cli +npm uninstall --global @xgen-ai/cli ``` Native installer는 runtime state를 삭제하지 않는다. Binary만 제거하려면 설치한 exact regular file을 @@ -448,9 +493,10 @@ profile 저장소를 격리하려면 `XGEN_CONFIG_HOME`을 따로 설정한다. | `unsupported_platform` 또는 platform package 없음 | 지원 OS/architecture와 Node.js version을 확인하고 `--omit=optional` 없이 exact package를 재설치한다. | | Installer가 destination을 거부 | 관리자 공용 경로 대신 user-owned directory를 사용하고 symbolic link/reparse point나 broadly writable directory를 제거하지 말고 다른 빈 경로를 선택한다. | | PowerShell에서 installer 차단 | Script를 먼저 검토한다. 현재 process 한정 execution-policy 예외는 조직 정책이 허용할 때만 사용한다. SmartScreen 경고는 RC3에 Authenticode가 없기 때문이다. | +| `base_url_must_name_the_api_root` | base URL이 host만 있고 경로가 없다. `https://api.openai.com`이 아니라 `https://api.openai.com/v1`처럼 API root까지 적는다. 어떤 경로인지는 provider 문서가 정한다. | | `credential_store_unavailable` | 평문 저장 fallback은 없다. Secret manager 출력을 `--token-stdin`이나 현재 process의 `XGEN_OPENAI_API_KEY`로 전달한다. | | `chat_completions_incompatible` | Provider가 strict JSON Schema 요청을 받아들였지만 실제로 강제하지 않았거나 envelope 밖으로 답했다. Probe는 production planner와 같은 proposal schema를 보내고 schema 밖 field를 하나 더 요구하므로, 문법을 강제하지 못하는 서버는 planner call 대신 `model setup`에서 여기서 닫힌다. 이는 의도된 동작이다. Probe 스키마는 구조만 강제하므로(ADR-0037) `additionalProperties`, `required`, `enum`, `const`를 강제하지 못하는 provider가 여기서 걸린다. | -| `model_not_advertised` 또는 compatibility 실패 | URL이 `/v1`로 끝나는지, exact served model ID와 strict JSON Schema Chat Completions 지원을 확인한다. Redirect나 자동 retry에 의존하지 않는다. | +| `model_not_advertised` 또는 compatibility 실패 | URL이 bare host가 아니라 provider의 API root인지, exact served model ID와 strict JSON Schema Chat Completions 지원을 확인한다. Redirect나 자동 retry에 의존하지 않는다. | | `model setup`/`model check`의 `timeout` | Inference probe는 production planner와 같은 wall-clock 예산을 쓴다. 로컬 model은 첫 로드가 크기와 disk cache 상태에 따라 수십 초 걸릴 수 있으므로 endpoint에서 model을 한 번 warm-up한 뒤 재시도한다. | | `provider_output_truncated` | Probe나 planner 응답이 출력 token 예산에서 잘렸다. Reasoning을 많이 쓰는 model은 최종 JSON 전에 예산을 소진할 수 있으므로 model의 thinking 설정이나 profile의 출력 예산을 조정한다. Rate limit이 아니므로 재시도로 해결되지 않는다. | | `proposal_rejected.*` | 뒤의 class가 Core가 제안을 거부한 이유다. `capability_unavailable`/`capability_unsupported`는 허용하지 않은 capability 선택, `invocation_invalid`는 scope 밖 인자나 스키마 위반, `tool_call_budget_exhausted`는 예산 소진이다. Class는 Core 판정이며 model 출력 원문이 아니다. | diff --git a/npm/packages/cli/README.md b/npm/packages/cli/README.md index 4d85dd1..1772455 100644 --- a/npm/packages/cli/README.md +++ b/npm/packages/cli/README.md @@ -1,9 +1,9 @@ -# `@xgen/cli` +# `@xgen-ai/cli` -`@xgen/cli` is the npm distribution wrapper for the native XGEN CLI. +`@xgen-ai/cli` is the npm distribution wrapper for the native XGEN CLI. ```bash -npm install --global @xgen/cli@0.1.0-rc.3 +npm install --global @xgen-ai/cli@0.1.0-rc.3 xgen --version ``` diff --git a/npm/packages/cli/lib/platform.cjs b/npm/packages/cli/lib/platform.cjs index 103afca..07fb8ee 100644 --- a/npm/packages/cli/lib/platform.cjs +++ b/npm/packages/cli/lib/platform.cjs @@ -23,7 +23,7 @@ function resolvePlatformBinary(specification, resolver = require.resolve) { } catch (cause) { const error = new Error( `The required XGEN native package ${specification.packageName} is not installed. ` + - 'Reinstall @xgen/cli without --omit=optional.', + 'Reinstall @xgen-ai/cli without --omit=optional.', ); error.code = 'XGEN_PLATFORM_PACKAGE_MISSING'; error.cause = cause; diff --git a/npm/packages/cli/package.json b/npm/packages/cli/package.json index 1fbc5dc..98ddc7b 100644 --- a/npm/packages/cli/package.json +++ b/npm/packages/cli/package.json @@ -1,5 +1,5 @@ { - "name": "@xgen/cli", + "name": "@xgen-ai/cli", "version": "0.1.0-rc.3", "description": "Local-first XGEN agent CLI and harness", "license": "Apache-2.0", @@ -25,11 +25,11 @@ "LICENSE" ], "optionalDependencies": { - "@xgen/cli-linux-x64-musl": "0.1.0-rc.3", - "@xgen/cli-linux-arm64-musl": "0.1.0-rc.3", - "@xgen/cli-darwin-x64": "0.1.0-rc.3", - "@xgen/cli-darwin-arm64": "0.1.0-rc.3", - "@xgen/cli-win32-x64": "0.1.0-rc.3" + "@xgen-ai/cli-linux-x64-musl": "0.1.0-rc.3", + "@xgen-ai/cli-linux-arm64-musl": "0.1.0-rc.3", + "@xgen-ai/cli-darwin-x64": "0.1.0-rc.3", + "@xgen-ai/cli-darwin-arm64": "0.1.0-rc.3", + "@xgen-ai/cli-win32-x64": "0.1.0-rc.3" }, "publishConfig": { "access": "public", diff --git a/npm/packages/cli/platforms.json b/npm/packages/cli/platforms.json index 0d8fb69..5dcab9d 100644 --- a/npm/packages/cli/platforms.json +++ b/npm/packages/cli/platforms.json @@ -3,7 +3,7 @@ "platforms": [ { "target": "x86_64-unknown-linux-musl", - "packageName": "@xgen/cli-linux-x64-musl", + "packageName": "@xgen-ai/cli-linux-x64-musl", "os": "linux", "cpu": "x64", "asset": "xgen-x86_64-unknown-linux-musl", @@ -12,7 +12,7 @@ }, { "target": "aarch64-unknown-linux-musl", - "packageName": "@xgen/cli-linux-arm64-musl", + "packageName": "@xgen-ai/cli-linux-arm64-musl", "os": "linux", "cpu": "arm64", "asset": "xgen-aarch64-unknown-linux-musl", @@ -21,7 +21,7 @@ }, { "target": "x86_64-apple-darwin", - "packageName": "@xgen/cli-darwin-x64", + "packageName": "@xgen-ai/cli-darwin-x64", "os": "darwin", "cpu": "x64", "asset": "xgen-x86_64-apple-darwin", @@ -30,7 +30,7 @@ }, { "target": "aarch64-apple-darwin", - "packageName": "@xgen/cli-darwin-arm64", + "packageName": "@xgen-ai/cli-darwin-arm64", "os": "darwin", "cpu": "arm64", "asset": "xgen-aarch64-apple-darwin", @@ -39,7 +39,7 @@ }, { "target": "x86_64-pc-windows-msvc", - "packageName": "@xgen/cli-win32-x64", + "packageName": "@xgen-ai/cli-win32-x64", "os": "win32", "cpu": "x64", "asset": "xgen-x86_64-pc-windows-msvc.exe", diff --git a/npm/platforms.json b/npm/platforms.json index 0d8fb69..5dcab9d 100644 --- a/npm/platforms.json +++ b/npm/platforms.json @@ -3,7 +3,7 @@ "platforms": [ { "target": "x86_64-unknown-linux-musl", - "packageName": "@xgen/cli-linux-x64-musl", + "packageName": "@xgen-ai/cli-linux-x64-musl", "os": "linux", "cpu": "x64", "asset": "xgen-x86_64-unknown-linux-musl", @@ -12,7 +12,7 @@ }, { "target": "aarch64-unknown-linux-musl", - "packageName": "@xgen/cli-linux-arm64-musl", + "packageName": "@xgen-ai/cli-linux-arm64-musl", "os": "linux", "cpu": "arm64", "asset": "xgen-aarch64-unknown-linux-musl", @@ -21,7 +21,7 @@ }, { "target": "x86_64-apple-darwin", - "packageName": "@xgen/cli-darwin-x64", + "packageName": "@xgen-ai/cli-darwin-x64", "os": "darwin", "cpu": "x64", "asset": "xgen-x86_64-apple-darwin", @@ -30,7 +30,7 @@ }, { "target": "aarch64-apple-darwin", - "packageName": "@xgen/cli-darwin-arm64", + "packageName": "@xgen-ai/cli-darwin-arm64", "os": "darwin", "cpu": "arm64", "asset": "xgen-aarch64-apple-darwin", @@ -39,7 +39,7 @@ }, { "target": "x86_64-pc-windows-msvc", - "packageName": "@xgen/cli-win32-x64", + "packageName": "@xgen-ai/cli-win32-x64", "os": "win32", "cpu": "x64", "asset": "xgen-x86_64-pc-windows-msvc.exe", diff --git a/npm/scripts/check.mjs b/npm/scripts/check.mjs index eaccf21..011b31d 100644 --- a/npm/scripts/check.mjs +++ b/npm/scripts/check.mjs @@ -30,7 +30,7 @@ assert.equal(lock.packages[''].name, workspace.name); assert.equal(lock.packages[''].version, version); assert.deepEqual(lock.packages[''].engines, workspace.engines); -assert.equal(launcher.name, '@xgen/cli'); +assert.equal(launcher.name, '@xgen-ai/cli'); assert.equal(launcher.version, version); assert.equal(launcher.license, 'Apache-2.0'); assert.deepEqual(launcher.repository, { type: 'git', url: repositoryUrl }); @@ -61,7 +61,7 @@ unique(catalog.platforms.map(({ os, cpu }) => `${os}/${cpu}`), 'runtime platform const expectedOptionalDependencies = {}; for (const specification of catalog.platforms) { assert.match(specification.target, /^[A-Za-z0-9_-]+$/); - assert.match(specification.packageName, /^@xgen\/cli-[a-z0-9-]+$/); + assert.match(specification.packageName, /^@xgen-ai\/cli-[a-z0-9-]+$/); assert.match(specification.asset, /^xgen-[A-Za-z0-9_.-]+$/); assert.match(specification.binaryPath, /^bin\/xgen(?:\.exe)?$/); assert.match(specification.tarball, /^xgen-cli-[a-z0-9-]+\.tgz$/); diff --git a/npm/scripts/pack.mjs b/npm/scripts/pack.mjs index 081c80d..1735442 100644 --- a/npm/scripts/pack.mjs +++ b/npm/scripts/pack.mjs @@ -48,7 +48,7 @@ function parseArguments(argv) { function platformReadme(specification, version) { return `# \`${specification.packageName}\`\n\n` + `Native XGEN CLI ${version} binary for \`${specification.target}\`.\n\n` + - 'This package is an exact-version optional dependency of `@xgen/cli`. ' + + 'This package is an exact-version optional dependency of `@xgen-ai/cli`. ' + 'Install the launcher package instead of installing this package directly. ' + 'It contains no install lifecycle scripts and performs no network download.\n'; } diff --git a/npm/scripts/smoke.mjs b/npm/scripts/smoke.mjs index e394d14..08bc039 100644 --- a/npm/scripts/smoke.mjs +++ b/npm/scripts/smoke.mjs @@ -199,7 +199,7 @@ async function main() { const root = npmInvocation(['root', '--global', '--prefix', installRoot]); const rootResult = await run(root.command, root.args, { env: npmEnvironment }); const globalRoot = rootResult.stdout.trim(); - const installedLauncher = path.join(globalRoot, '@xgen', 'cli', 'package.json'); + const installedLauncher = path.join(globalRoot, '@xgen-ai', 'cli', 'package.json'); const launcherEntry = await lstat(installedLauncher); assert.equal(launcherEntry.isFile(), true); const requireFromLauncher = createRequire(installedLauncher); diff --git a/npm/tests/platform.test.mjs b/npm/tests/platform.test.mjs index 5e495a3..b855383 100644 --- a/npm/tests/platform.test.mjs +++ b/npm/tests/platform.test.mjs @@ -10,11 +10,11 @@ const catalog = JSON.parse( const { resolvePlatformBinary, selectPlatform } = require('../packages/cli/lib/platform.cjs'); const expected = [ - ['linux', 'x64', '@xgen/cli-linux-x64-musl', 'bin/xgen'], - ['linux', 'arm64', '@xgen/cli-linux-arm64-musl', 'bin/xgen'], - ['darwin', 'x64', '@xgen/cli-darwin-x64', 'bin/xgen'], - ['darwin', 'arm64', '@xgen/cli-darwin-arm64', 'bin/xgen'], - ['win32', 'x64', '@xgen/cli-win32-x64', 'bin/xgen.exe'], + ['linux', 'x64', '@xgen-ai/cli-linux-x64-musl', 'bin/xgen'], + ['linux', 'arm64', '@xgen-ai/cli-linux-arm64-musl', 'bin/xgen'], + ['darwin', 'x64', '@xgen-ai/cli-darwin-x64', 'bin/xgen'], + ['darwin', 'arm64', '@xgen-ai/cli-darwin-arm64', 'bin/xgen'], + ['win32', 'x64', '@xgen-ai/cli-win32-x64', 'bin/xgen.exe'], ]; test('platform catalog selects every supported native package exactly', () => { @@ -42,7 +42,7 @@ test('native package resolution uses the exact package subpath', () => { return '/verified/native/xgen'; }); assert.equal(resolved, '/verified/native/xgen'); - assert.deepEqual(requests, ['@xgen/cli-linux-x64-musl/bin/xgen']); + assert.deepEqual(requests, ['@xgen-ai/cli-linux-x64-musl/bin/xgen']); }); test('missing optional dependency produces a bounded recovery message', () => { @@ -57,8 +57,8 @@ test('missing optional dependency produces a bounded recovery message', () => { (error) => error.code === 'XGEN_PLATFORM_PACKAGE_MISSING' && error.message === - 'The required XGEN native package @xgen/cli-linux-x64-musl is not installed. ' + - 'Reinstall @xgen/cli without --omit=optional.' && + 'The required XGEN native package @xgen-ai/cli-linux-x64-musl is not installed. ' + + 'Reinstall @xgen-ai/cli without --omit=optional.' && !error.message.includes('host-specific'), ); }); diff --git a/npm/tests/publish-contract.test.mjs b/npm/tests/publish-contract.test.mjs index 44ded0e..09e2638 100644 --- a/npm/tests/publish-contract.test.mjs +++ b/npm/tests/publish-contract.test.mjs @@ -12,25 +12,25 @@ const provenance = { test('platform packages are ordered before the launcher', () => { const reports = [ - { name: '@xgen/cli', integrity: 'sha512-launcher' }, - { name: '@xgen/cli-linux-x64-musl', integrity: 'sha512-linux' }, - { name: '@xgen/cli-darwin-arm64', integrity: 'sha512-macos' }, + { name: '@xgen-ai/cli', integrity: 'sha512-launcher' }, + { name: '@xgen-ai/cli-linux-x64-musl', integrity: 'sha512-linux' }, + { name: '@xgen-ai/cli-darwin-arm64', integrity: 'sha512-macos' }, ]; assert.deepEqual( - publicationOrder(reports, '@xgen/cli').map(({ name }) => name), - ['@xgen/cli-linux-x64-musl', '@xgen/cli-darwin-arm64', '@xgen/cli'], + publicationOrder(reports, '@xgen-ai/cli').map(({ name }) => name), + ['@xgen-ai/cli-linux-x64-musl', '@xgen-ai/cli-darwin-arm64', '@xgen-ai/cli'], ); }); test('publication order requires exactly one launcher', () => { assert.throws( - () => publicationOrder([{ name: '@xgen/cli-linux-x64-musl' }], '@xgen/cli'), + () => publicationOrder([{ name: '@xgen-ai/cli-linux-x64-musl' }], '@xgen-ai/cli'), /exactly one launcher/, ); }); test('an identical retry requires integrity, dist-tag, and provenance', () => { - const report = { name: '@xgen/cli', integrity: 'sha512-exact' }; + const report = { name: '@xgen-ai/cli', integrity: 'sha512-exact' }; const remote = { 'dist.integrity': 'sha512-exact', 'dist-tags': { next: '0.1.0-rc.3' }, diff --git a/npm/tests/verify-release-contract.test.mjs b/npm/tests/verify-release-contract.test.mjs index db1c8d4..ea3dc5c 100644 --- a/npm/tests/verify-release-contract.test.mjs +++ b/npm/tests/verify-release-contract.test.mjs @@ -4,15 +4,15 @@ import test from 'node:test'; import { normalizeDryRunReport } from '../scripts/verify-release.mjs'; const report = { - id: '@xgen/cli@0.1.0-rc.3', - name: '@xgen/cli', + id: '@xgen-ai/cli@0.1.0-rc.3', + name: '@xgen-ai/cli', version: '0.1.0-rc.3', files: [], }; test('npm 10 and npm 11 dry-run report shapes normalize identically', () => { assert.equal(normalizeDryRunReport(report), report); - assert.equal(normalizeDryRunReport({ '@xgen/cli': report }), report); + assert.equal(normalizeDryRunReport({ '@xgen-ai/cli': report }), report); }); test('dry-run normalization rejects ambiguous package reports', () => { diff --git a/scripts/check-npm-distribution-workflow.sh b/scripts/check-npm-distribution-workflow.sh index f5ecc8a..a49fa13 100755 --- a/scripts/check-npm-distribution-workflow.sh +++ b/scripts/check-npm-distribution-workflow.sh @@ -114,8 +114,8 @@ for fragment in ( "aarch64-apple-darwin", "x86_64-pc-windows-msvc", "--include=optional --ignore-scripts", - '"@xgen/cli@$version"', - '"@xgen/cli@$Version"', + '"@xgen-ai/cli@$version"', + '"@xgen-ai/cli@$Version"', "printf '/status\\n/exit\\n'", "status: idle", 'npm uninstall --global --prefix "$test_root/install"', diff --git a/scripts/check-rc3-public-docs.sh b/scripts/check-rc3-public-docs.sh index 2ea30de..3f4e4ea 100644 --- a/scripts/check-rc3-public-docs.sh +++ b/scripts/check-rc3-public-docs.sh @@ -34,12 +34,12 @@ require( getting_started, ( "## 5분 빠른 시작", - "@xgen/cli@0.1.0-rc.3", + "@xgen-ai/cli@0.1.0-rc.3", "xgen model setup", "xgen model check --compatibility", "## 업데이트와 RC2 rollback", "v0.1.0-rc.2", - "npm uninstall --global @xgen/cli", + "npm uninstall --global @xgen-ai/cli", "## 문제 해결과 지원 정보", "credential_store_unavailable", "model_call_unknown",