aptly 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -1,23 +1,40 @@
1
+ <div align="center">
2
+
1
3
  # Aptly
2
4
 
5
+ [![npm version](https://img.shields.io/npm/v/aptly)](https://www.npmjs.com/package/aptly)
6
+ [![npm downloads](https://img.shields.io/npm/dm/aptly)](https://www.npmjs.com/package/aptly)
7
+ [![MIT license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/abruption/aptly/blob/main/LICENSE)
8
+
9
+ **Model and reasoning recommendations for subagents, powered by the OpenAI Decisions API.**
10
+
3
11
  [한국어](README.md) · **English** · [简体中文](README.zh-CN.md) · [日本語](README.ja.md)
4
12
 
5
13
  **A local MCP server that selects Claude Code and Codex subagent models and reasoning effort to suit the task.**
6
14
 
15
+ </div>
16
+
7
17
  Aptly classifies a minimal task summary with the OpenAI Decisions API and recommends an allowlisted execution profile. Its installed Hook can automatically select defaults for fresh, general-purpose subagents without explicit choices. The host retains control of execution and permissions.
8
18
 
19
+ Our own tests showed **about 52–58% lower API-equivalent costs**. See the [test conditions and detailed results](docs/guide/en.md#cost-benchmark).
20
+
9
21
  ## Quick start
10
22
 
11
- Use Node.js **24 or later** and Claude Code or Codex. This is an **MIT-licensed local 0.1.0 package draft**, not a release published to the npm registry. Replace the archive path below with the actual reviewed archive.
23
+ Requires Node.js **24 or later** and Claude Code or Codex. Install the latest stable version globally with these commands.
12
24
 
13
25
  ```sh
14
- npm install --prefix ./aptly-local /absolute/path/aptly-0.1.0.tgz
15
- ./aptly-local/node_modules/.bin/aptly setup --client all --mode defaults
26
+ npm install -g aptly@latest
27
+ aptly setup --client all --mode defaults
28
+ aptly --version
16
29
  ```
17
30
 
31
+ Check the installed version with `aptly --version`.
32
+
33
+ Before upgrading, stop all related host, MCP, and CLI processes and review the ledger transition in the [installation and upgrade guide](docs/guide/en.md#installation).
34
+
18
35
  During installation, `postinstall` registers the MCP server and a `PreToolUse` Hook in existing host configuration directories. Run `setup` explicitly if automatic registration was skipped or a host has no configuration directory yet. Other settings and Hooks, and any existing Aptly launch command and environment, are preserved.
19
36
 
20
- Pass the classification key, `OPENAI_API_KEY`, to the MCP process through a secure execution environment. Do not put it in command arguments, requests, configuration JSON, or the repository. Restart the host or MCP server, then **review and trust the new Hook through `/hooks` in Codex**. See [installation, keys, and registration](docs/guide/en.md#installation).
37
+ Store the classification key, `OPENAI_API_KEY`, in the OS credential store using the hidden prompt in `aptly set env OPENAI_API_KEY`, or pass it to the MCP process through a secure execution environment. Do not put it in command arguments, requests, configuration JSON, or the repository. Restart the host or MCP server, then **review and trust the new Hook through `/hooks` in Codex**. See [installation, keys, and registration](docs/guide/en.md#installation).
21
38
 
22
39
  ## Usage modes
23
40
 
@@ -31,8 +48,9 @@ Automatic selection targets Codex calls with `fork_turns: "none"` and Claude Cod
31
48
 
32
49
  ## Documentation
33
50
 
51
+ - [CLI configuration](docs/guide/en.md#configure-with-the-cli)
34
52
  - [User guide](docs/guide/en.md): installation and removal, keys, Hooks, direct recommendations, and error recovery
35
- - [Installation and removal](docs/guide/en.md#installation): local archives, host registration, restarting, and trust review
53
+ - [Installation and removal](docs/guide/en.md#installation): global npm installation, host registration, restarting, and trust review
36
54
  - [Models and environment variables](docs/guide/en.md#models-and-environment-variables): six default profiles and candidate configuration
37
55
  - [Providing context](docs/guide/en.md#providing-context): encrypted messages and repository URL limitations
38
56
  - [Cost benchmark](docs/guide/en.md#cost-benchmark): tokens, converted costs, and measurement scope for a README translation review
package/README.ja.md CHANGED
@@ -1,23 +1,40 @@
1
+ <div align="center">
2
+
1
3
  # Aptly
2
4
 
5
+ [![npm version](https://img.shields.io/npm/v/aptly)](https://www.npmjs.com/package/aptly)
6
+ [![npm downloads](https://img.shields.io/npm/dm/aptly)](https://www.npmjs.com/package/aptly)
7
+ [![MIT license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/abruption/aptly/blob/main/LICENSE)
8
+
9
+ **Model and reasoning recommendations for subagents, powered by the OpenAI Decisions API.**
10
+
3
11
  [한국어](README.md) · [English](README.en.md) · [简体中文](README.zh-CN.md) · **日本語**
4
12
 
5
13
  **タスクに適した Claude Code・Codex のサブエージェント用モデルと effort を選ぶローカル MCP です。**
6
14
 
15
+ </div>
16
+
7
17
  Aptly は必要最小限のタスク要約を OpenAI Decisions API で分類し、あらかじめ許可された実行プロファイルを推薦します。インストールした Hook は、明示的な指定のない新しい汎用サブエージェントのデフォルト値を自動で選択できます。サブエージェントの実行と権限に関する判断は、既存のホストが行います。
8
18
 
19
+ 独自テストでは、**API 換算コストが約 52~58% 減少しました**。[測定条件と詳細結果](docs/guide/ja.md#費用実測)をご覧ください。
20
+
9
21
  ## クイックスタート
10
22
 
11
- Node.js **24 以上**と Claude Code または Codex が必要です。現在は **MIT ライセンスの 0.1.0 ローカルパッケージ案**で、npm registry にはまだ公開していません。確認済みのアーカイブの実際のパスに置き換えてインストールしてください。
23
+ Node.js **24 以上**と Claude Code または Codex が必要です。以下のコマンドで最新の安定版をグローバルインストールしてください。
12
24
 
13
25
  ```sh
14
- npm install --prefix ./aptly-local /absolute/path/aptly-0.1.0.tgz
15
- ./aptly-local/node_modules/.bin/aptly setup --client all --mode defaults
26
+ npm install -g aptly@latest
27
+ aptly setup --client all --mode defaults
28
+ aptly --version
16
29
  ```
17
30
 
31
+ `aptly --version` でインストール済みのバージョンを確認してください。
32
+
33
+ アップグレード前に、関連するホスト・MCP・CLI の全プロセスを停止し、[インストールとアップグレードの案内](docs/guide/ja.md#インストール)で台帳の移行手順を確認してください。
34
+
18
35
  インストール時の `postinstall` は、既存のホスト設定ディレクトリに MCP と `PreToolUse` Hook を登録します。自動登録をスキップした場合や、ホストに設定ディレクトリがまだない場合も、`setup` を明示的に実行して登録できます。他の既存設定・Hook と、既存の Aptly の実行コマンド・環境は保持します。
19
36
 
20
- 分類用の `OPENAI_API_KEY` は、安全な実行環境から MCP プロセスに渡してください。キーをコマンドライン・リクエスト・設定 JSON・リポジトリに記載しないでください。ホストまたは MCP を再起動した後、**Codex では `/hooks` で新しい Hook を確認し、信頼を承認**してください。[インストール・キー・登録の詳細](docs/guide/ja.md#インストール)を確認してください。
37
+ 分類用の `OPENAI_API_KEY` は、`aptly set env OPENAI_API_KEY` の非表示入力で OS の資格情報ストアに保存するか、安全な実行環境から MCP プロセスに渡してください。キーをコマンドライン・リクエスト・設定 JSON・リポジトリに記載しないでください。ホストまたは MCP を再起動した後、**Codex では `/hooks` で新しい Hook を確認し、信頼を承認**してください。[インストール・キー・登録の詳細](docs/guide/ja.md#インストール)を確認してください。
21
38
 
22
39
  ## 使用方法
23
40
 
@@ -31,8 +48,9 @@ npm install --prefix ./aptly-local /absolute/path/aptly-0.1.0.tgz
31
48
 
32
49
  ## ドキュメント
33
50
 
51
+ - [CLI 設定](docs/guide/ja.md#cli-による設定)
34
52
  - [使用ガイド](docs/guide/ja.md): インストール・削除、キー、Hook、直接推薦、エラー復旧
35
- - [インストール・削除](docs/guide/ja.md#インストール): ローカルアーカイブ、ホスト登録、再起動と信頼の承認
53
+ - [インストール・削除](docs/guide/ja.md#インストール): npm のグローバルインストール、ホスト登録、再起動と信頼の承認
36
54
  - [モデルと環境変数](docs/guide/ja.md#モデルと環境変数): 6 つのデフォルトプロファイルと候補の設定
37
55
  - [コンテキストの提供](docs/guide/ja.md#コンテキストの提供): 暗号化されたメッセージとリポジトリ URL の制約
38
56
  - [費用実測](docs/guide/ja.md#費用実測): README 翻訳レビューのトークン・換算費用と測定範囲
package/README.md CHANGED
@@ -1,23 +1,40 @@
1
+ <div align="center">
2
+
1
3
  # Aptly
2
4
 
5
+ [![npm version](https://img.shields.io/npm/v/aptly)](https://www.npmjs.com/package/aptly)
6
+ [![npm downloads](https://img.shields.io/npm/dm/aptly)](https://www.npmjs.com/package/aptly)
7
+ [![MIT license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/abruption/aptly/blob/main/LICENSE)
8
+
9
+ **Model and reasoning recommendations for subagents, powered by the OpenAI Decisions API.**
10
+
3
11
  **한국어** · [English](README.en.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md)
4
12
 
5
13
  **작업에 맞는 Claude Code·Codex 서브에이전트 모델과 effort를 선택하는 로컬 MCP입니다.**
6
14
 
15
+ </div>
16
+
7
17
  Aptly는 최소한의 작업 요약을 OpenAI Decisions API로 분류하고 허용된 실행 프로필을 추천합니다. 설치한 Hook은 명시 선택이 없는 새 일반 서브에이전트의 기본값을 자동으로 선택할 수 있습니다. 실제 실행과 권한 결정은 기존 호스트가 담당합니다.
8
18
 
19
+ 자체 테스트에서 **API 환산 비용이 약 52~58% 감소했습니다**. [측정 조건과 상세 결과](docs/guide/ko.md#비용-실측)를 확인하세요.
20
+
9
21
  ## 빠른 시작
10
22
 
11
- Node.js **24 이상**과 Claude Code 또는 Codex가 필요합니다. 현재는 **MIT 라이선스의 0.1.0 로컬 패키지 초안**이며 npm 레지스트리에 게시하지 않았습니다. 검토한 아카이브의 실제 경로로 바꿔 설치하십시오.
23
+ Node.js **24 이상**과 Claude Code 또는 Codex가 필요합니다. 다음 명령으로 최신 안정 버전을 전역 설치하십시오.
12
24
 
13
25
  ```sh
14
- npm install --prefix ./aptly-local /absolute/path/aptly-0.1.0.tgz
15
- ./aptly-local/node_modules/.bin/aptly setup --client all --mode defaults
26
+ npm install -g aptly@latest
27
+ aptly setup --client all --mode defaults
28
+ aptly --version
16
29
  ```
17
30
 
31
+ `aptly --version`으로 설치된 버전을 확인하십시오.
32
+
33
+ 업그레이드 전에는 관련 호스트·MCP·CLI 프로세스를 모두 중지하고 [설치·업그레이드 안내](docs/guide/ko.md#설치)의 원장 전환 절차를 확인하십시오.
34
+
18
35
  설치 시 `postinstall`은 기존 호스트 설정 디렉터리에 MCP와 `PreToolUse` Hook을 등록합니다. 명시적인 `setup`은 자동 등록을 건너뛰었거나 아직 설정 디렉터리가 없는 호스트에도 사용할 수 있습니다. 기존 다른 설정·Hook과 기존 Aptly 실행 명령·환경은 보존합니다.
19
36
 
20
- 분류용 `OPENAI_API_KEY`는 안전한 실행 환경에서 MCP 프로세스에 전달하십시오. 키를 명령행·요청·설정 JSON·저장소에 기록하지 마십시오. 호스트 또는 MCP를 재시작한 뒤, **Codex에서는 `/hooks`에서 새 Hook을 검토하고 신뢰를 승인하십시오.** [설치·키·등록 상세](docs/guide/ko.md#설치)를 참고하십시오.
37
+ 분류용 `OPENAI_API_KEY`는 `aptly set env OPENAI_API_KEY`의 숨김 입력으로 OS 자격 저장소에 보관하거나 안전한 실행 환경에서 MCP 프로세스에 전달하십시오. 키를 명령행·요청·설정 JSON·저장소에 기록하지 마십시오. 호스트 또는 MCP를 재시작한 뒤, **Codex에서는 `/hooks`에서 새 Hook을 검토하고 신뢰를 승인하십시오.** [설치·키·등록 상세](docs/guide/ko.md#설치)를 참고하십시오.
21
38
 
22
39
  ## 사용 방식
23
40
 
@@ -31,8 +48,9 @@ npm install --prefix ./aptly-local /absolute/path/aptly-0.1.0.tgz
31
48
 
32
49
  ## 문서
33
50
 
51
+ - [CLI 설정](docs/guide/ko.md#cli로-설정하기)
34
52
  - [사용 가이드](docs/guide/ko.md): 설치·제거, 키, Hook, 직접 추천, 오류 복구
35
- - [설치·제거](docs/guide/ko.md#설치): 로컬 아카이브, 호스트 등록, 재시작과 신뢰 승인
53
+ - [설치·제거](docs/guide/ko.md#설치): npm 전역 설치, 호스트 등록, 재시작과 신뢰 승인
36
54
  - [모델과 환경변수](docs/guide/ko.md#모델과-환경변수): 여섯 기본 프로필과 후보 설정
37
55
  - [맥락 전달](docs/guide/ko.md#맥락-전달): 암호화된 메시지와 저장소 URL의 한계
38
56
  - [비용 실측](docs/guide/ko.md#비용-실측): README 번역 검토의 토큰·환산 비용과 측정 범위
package/README.zh-CN.md CHANGED
@@ -1,23 +1,40 @@
1
+ <div align="center">
2
+
1
3
  # Aptly
2
4
 
5
+ [![npm version](https://img.shields.io/npm/v/aptly)](https://www.npmjs.com/package/aptly)
6
+ [![npm downloads](https://img.shields.io/npm/dm/aptly)](https://www.npmjs.com/package/aptly)
7
+ [![MIT license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/abruption/aptly/blob/main/LICENSE)
8
+
9
+ **Model and reasoning recommendations for subagents, powered by the OpenAI Decisions API.**
10
+
3
11
  [한국어](README.md) · [English](README.en.md) · **简体中文** · [日本語](README.ja.md)
4
12
 
5
13
  **用于为 Claude Code 和 Codex 子代理选择合适模型与推理强度(effort)的本地 MCP 服务器。**
6
14
 
15
+ </div>
16
+
7
17
  Aptly 使用 OpenAI Decisions API 对简要任务摘要进行分类,并推荐允许使用的执行配置。安装 Hook 后,可为未明确指定配置的新建通用子代理自动选择默认值。实际执行与权限决策仍由宿主(Claude Code 或 Codex)负责。
8
18
 
19
+ 在我们的测试中,**API 折算成本降低了约 52%~58%**。详见[测试条件与详细结果](docs/guide/zh-CN.md#成本实测)。
20
+
9
21
  ## 快速开始
10
22
 
11
- 需要 Node.js **24 或更高版本**,以及 Claude Code 或 Codex。目前是**采用 MIT 许可证的 0.1.0 本地软件包草案**,尚未发布到 npm registry。请将示例路径替换为已审核归档文件的实际路径。
23
+ 需要 Node.js **24 或更高版本**以及 Claude Code 或 Codex。请使用以下命令全局安装最新稳定版。
12
24
 
13
25
  ```sh
14
- npm install --prefix ./aptly-local /absolute/path/aptly-0.1.0.tgz
15
- ./aptly-local/node_modules/.bin/aptly setup --client all --mode defaults
26
+ npm install -g aptly@latest
27
+ aptly setup --client all --mode defaults
28
+ aptly --version
16
29
  ```
17
30
 
31
+ 请使用 `aptly --version` 确认已安装的版本。
32
+
33
+ 升级前,请停止所有相关宿主、MCP 和 CLI 进程,并查看[安装与升级指南](docs/guide/zh-CN.md#安装)中的账本迁移步骤。
34
+
18
35
  安装时的 `postinstall` 会在现有宿主配置目录中注册 MCP 和 `PreToolUse` Hook。如果跳过了自动注册,或宿主尚无配置目录,也可以显式运行 `setup`。其他现有配置与 Hook,以及 Aptly 原有的启动命令和环境均会保留。
19
36
 
20
- 请通过安全的运行环境,将分类用的 `OPENAI_API_KEY` 传递给 MCP 进程。不要将密钥写入命令行、请求、配置 JSON 或代码仓库。重启宿主或 MCP 后,**请在 Codex 的 `/hooks` 中审核新 Hook 并批准信任**。详见[安装、密钥与注册](docs/guide/zh-CN.md#安装)。
37
+ 请通过 `aptly set env OPENAI_API_KEY` 的隐藏输入,将分类用的 `OPENAI_API_KEY` 存入系统凭据存储,或通过安全的运行环境传递给 MCP 进程。不要将密钥写入命令行、请求、配置 JSON 或代码仓库。重启宿主或 MCP 后,**请在 Codex 的 `/hooks` 中审核新 Hook 并批准信任**。详见[安装、密钥与注册](docs/guide/zh-CN.md#安装)。
21
38
 
22
39
  ## 使用方式
23
40
 
@@ -31,8 +48,9 @@ npm install --prefix ./aptly-local /absolute/path/aptly-0.1.0.tgz
31
48
 
32
49
  ## 文档
33
50
 
51
+ - [CLI 配置](docs/guide/zh-CN.md#使用-cli-设置)
34
52
  - [使用指南](docs/guide/zh-CN.md):安装与移除、密钥、Hook、直接推荐、错误恢复
35
- - [安装与移除](docs/guide/zh-CN.md#安装):本地归档、宿主注册、重启与信任批准
53
+ - [安装与移除](docs/guide/zh-CN.md#安装):npm 全局安装、宿主注册、重启与信任批准
36
54
  - [模型与环境变量](docs/guide/zh-CN.md#模型与环境变量):六个默认配置与候选设置
37
55
  - [提供上下文](docs/guide/zh-CN.md#提供上下文):加密消息与仓库 URL 的限制
38
56
  - [成本实测](docs/guide/zh-CN.md#成本实测):README 翻译审阅的 token、换算成本与测量范围
package/dist/bin/aptly.js CHANGED
@@ -9,12 +9,14 @@ import { collectDiagnostics } from '../src/diagnostics.js';
9
9
  import { exportDiagnosticReport } from '../src/report.js';
10
10
  import { fetchModelInfo, saveModelInfo } from '../src/model-info.js';
11
11
  import { errorCode } from '../src/types.js';
12
- import { setupHosts, removeSetup } from '../src/setup.js';
12
+ import { setupHosts, removeSetup, setDefaults, setEnvironment, configurableEnvironmentKeys, migrateLauncher } from '../src/setup.js';
13
+ import { readEnvironmentValue } from '../src/cli-input.js';
14
+ import { prepareApiKey } from '../src/credential-setup.js';
13
15
  const [command = 'serve', ...args] = process.argv.slice(2);
14
16
  const output = (value) => process.stdout.write(JSON.stringify(value, null, 2) + '\n');
15
17
  try {
16
18
  if (['--help', '-h', 'help'].includes(command)) {
17
- process.stdout.write('Aptly (preview)\n\nCommands:\n serve Start the stdio MCP server (default: fixture/shadow).\n capabilities Print the active catalog; no API call.\n catalog Print the active catalog; no API call.\n catalog refresh --output ABS_PATH Fetch official model descriptions to a private cache.\n recommend Recommend for a task JSON on stdin; no host evidence required.\n inspect CLIENT FILE Inspect an explicit JSON settings inventory; no API call.\n config CLIENT Print an MCP registration fragment; does not edit settings.\n setup Register MCP and automatic PreToolUse Hooks.\n remove Remove only unchanged Aptly-owned registration.\n report Write redacted local diagnostic attachments (default: md + json).\n --format md|json|both Select report formats.\n --output-dir DIR Private output directory (default: reports).\n --version Print the package version.\n\nSet OPENAI_API_KEY only in the MCP environment. Paid mode additionally requires\nROUTER_PROVIDER=decisions and an absolute ROUTER_LEDGER_PATH in a private directory.\nROUTER_CONFIG_PATH is an optional private operator configuration file.\n');
19
+ process.stdout.write('Aptly (preview)\n\nCommands:\n serve Start the stdio MCP server (default: fixture/shadow).\n capabilities Print the active catalog; no API call.\n catalog Print the active catalog; no API call.\n catalog refresh --output ABS_PATH Fetch official model descriptions to a private cache.\n recommend Recommend for a task JSON on stdin; no host evidence required.\n inspect CLIENT FILE Inspect an explicit JSON settings inventory; no API call.\n config CLIENT Print an MCP registration fragment; does not edit settings.\n set default Add missing default MCP environment settings.\n set env ENV_KEY Prompt for an MCP environment value.\n setup Register MCP and automatic PreToolUse Hooks.\n remove Remove only unchanged Aptly-owned registration.\n report Write redacted local diagnostic attachments (default: md + json).\n --format md|json|both Select report formats.\n --output-dir DIR Private output directory (default: reports).\n --version Print the package version.\n\nUse aptly set env OPENAI_API_KEY for OS storage, or pass it through the MCP environment.\nPaid mode additionally requires\nROUTER_PROVIDER=decisions and an absolute ROUTER_LEDGER_PATH in a private directory.\nROUTER_CONFIG_PATH is an optional private operator configuration file.\n');
18
20
  }
19
21
  else if (command === '--version')
20
22
  process.stdout.write(VERSION + '\n');
@@ -35,19 +37,92 @@ try {
35
37
  throw Object.assign(new Error('report_invalid_options'), { code: 'report_invalid_options' });
36
38
  output(exportDiagnosticReport({ diagnostic: collectDiagnostics(), ...options }));
37
39
  }
40
+ else if (command === 'set') {
41
+ if (args.length === 1 && args[0] === '--help')
42
+ process.stdout.write('Usage: aptly set default [--client codex|claude|all]\n aptly set env ENV_KEY [--client codex|claude|all] [--stdin | --check]\n--check diagnoses OPENAI_API_KEY registration without reading or storing a key.\nValues are entered interactively with hidden input; --stdin explicitly reads a value from standard input. Existing defaults and launchers are preserved. OPENAI_API_KEY is stored in the OS credential store; host files contain references only. Restart MCP after changes.\n');
43
+ else {
44
+ const action = args[0];
45
+ let key;
46
+ let start = 1;
47
+ if (action === 'env') {
48
+ key = args[1];
49
+ start = 2;
50
+ if (!key || (key !== 'OPENAI_API_KEY' && !configurableEnvironmentKeys.includes(key)))
51
+ throw new Error('set_environment_key_unsupported');
52
+ }
53
+ else if (action !== 'default')
54
+ throw new Error('set_invalid_options');
55
+ let clients = ['codex', 'claude'];
56
+ let fromStdin = false;
57
+ let checkOnly = false;
58
+ const seen = new Set();
59
+ for (let i = start; i < args.length; i++) {
60
+ const flag = args[i];
61
+ if (seen.has(flag))
62
+ throw new Error('set_invalid_options');
63
+ seen.add(flag);
64
+ if (flag === '--check' && action === 'env' && key === 'OPENAI_API_KEY') {
65
+ checkOnly = true;
66
+ continue;
67
+ }
68
+ if (flag === '--stdin' && action === 'env') {
69
+ fromStdin = true;
70
+ continue;
71
+ }
72
+ if (flag === '--client' && ['codex', 'claude', 'all'].includes(args[i + 1])) {
73
+ const v = args[++i];
74
+ clients = v === 'all' ? ['codex', 'claude'] : [v];
75
+ }
76
+ else
77
+ throw new Error('set_invalid_options');
78
+ }
79
+ const packageRoot = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../' : '../../', import.meta.url));
80
+ if (checkOnly && fromStdin)
81
+ throw new Error('set_invalid_options');
82
+ const credential = key === 'OPENAI_API_KEY' ? prepareApiKey({ clients, packageRoot }) : undefined;
83
+ let value = '';
84
+ if (credential && (checkOnly || credential.result.status === 'partial')) {
85
+ output(credential.result);
86
+ if (credential.result.status === 'partial')
87
+ process.exitCode = 1;
88
+ }
89
+ else
90
+ try {
91
+ if (action === 'env')
92
+ value = await readEnvironmentValue({ key: key, fromStdin });
93
+ const result = action === 'default' ? setDefaults({ clients, packageRoot }) : key === 'OPENAI_API_KEY' ? credential.apply(value) : setEnvironment(key, value, { clients, packageRoot });
94
+ output(result);
95
+ if (result.status === 'partial')
96
+ process.exitCode = 1;
97
+ }
98
+ finally {
99
+ value = '';
100
+ }
101
+ }
102
+ }
38
103
  else if (['setup', 'remove'].includes(command)) {
39
104
  if (args.length === 1 && args[0] === '--help')
40
- process.stdout.write('Usage: aptly setup [--client codex|claude|all] [--mode defaults|advisory] [--automatic]\n aptly remove [--client codex|claude|all]\nSetup merges MCP and PreToolUse registration. Codex requires native /hooks trust review.\nAutomatic setup skips CI, APTLY_SKIP_SETUP=1, and absent host directories. No keys are stored.\n');
105
+ process.stdout.write('Usage: aptly setup [--client codex|claude|all] [--mode defaults|advisory] [--automatic]\n aptly setup --client codex|claude --migrate-launcher [--dry-run]\n aptly remove [--client codex|claude|all]\nSetup merges MCP and PreToolUse registration. Codex requires native /hooks trust review.\nAutomatic setup skips CI, APTLY_SKIP_SETUP=1, and absent host directories. No keys are stored.\n');
41
106
  else {
42
107
  let clients = ['codex', 'claude'];
43
108
  let mode = 'defaults';
44
109
  let automatic = false;
110
+ let migrate = false;
111
+ let dryRun = false;
45
112
  const seen = new Set();
46
113
  for (let i = 0; i < args.length; i++) {
47
114
  const flag = args[i];
48
115
  if (seen.has(flag))
49
116
  throw new Error('setup_invalid_options');
50
117
  seen.add(flag);
118
+ if (flag === '--migrate-launcher' && command === 'setup') {
119
+ migrate = true;
120
+ continue;
121
+ }
122
+ if (flag === '--dry-run' && command === 'setup') {
123
+ dryRun = true;
124
+ continue;
125
+ }
51
126
  if (flag === '--automatic' && command === 'setup') {
52
127
  automatic = true;
53
128
  continue;
@@ -61,7 +136,9 @@ try {
61
136
  throw new Error('setup_invalid_options');
62
137
  }
63
138
  const packageRoot = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../' : '../../', import.meta.url));
64
- const result = command === 'setup' ? setupHosts({ clients, mode, automatic, packageRoot }) : removeSetup({ clients });
139
+ if ((migrate && (clients.length !== 1 || automatic || seen.has('--mode'))) || (dryRun && !migrate))
140
+ throw new Error('setup_invalid_options');
141
+ const result = migrate ? migrateLauncher({ clients, packageRoot }, dryRun) : command === 'setup' ? setupHosts({ clients, mode, automatic, packageRoot }) : removeSetup({ clients });
65
142
  output(result);
66
143
  if (result.status === 'partial' && !automatic)
67
144
  process.exitCode = 1;
@@ -70,8 +147,8 @@ try {
70
147
  else if (command === 'serve' && args.length === 0)
71
148
  await serve();
72
149
  else if ((command === 'capabilities' || command === 'catalog') && args.length === 0) {
73
- const { router, mode, modelInfo, disabledClients } = runtime();
74
- output({ ...router.capabilities(), provider: mode, model_information: { status: modelInfo.status, account_access_verified: false }, disabled_clients: disabledClients });
150
+ const { router, mode, modelInfo, credentialStatus, disabledClients } = runtime();
151
+ output({ ...router.capabilities(), provider: mode, credential_status: credentialStatus, model_information: { status: modelInfo.status, account_access_verified: false }, disabled_clients: disabledClients });
75
152
  }
76
153
  else if (command === 'catalog' && args.length === 3 && args[0] === 'refresh' && args[1] === '--output') {
77
154
  const snapshot = await fetchModelInfo();
@@ -116,7 +193,8 @@ catch (error) {
116
193
  const safeConfigurationCodes = ['catalog_configuration_invalid', 'catalog_configuration_conflict', 'catalog_environment_typo', 'catalog_facts_invalid', 'model_info_fetch_failed', 'model_info_unavailable', 'model_info_invalid', 'model_info_output_unavailable', 'model_info_path_invalid', 'model_info_refresh_failed', 'model_info_options_invalid', 'model_info_too_large', 'model_info_timeout', 'model_info_parse_failed', 'model_info_save_failed'];
117
194
  const message = error instanceof Error ? error.message : undefined;
118
195
  const configCode = [caughtCode, message].find(value => typeof value === 'string' && safeConfigurationCodes.includes(value));
119
- const code = command === 'report' ? (caughtCode && codes.includes(caughtCode) ? caughtCode : 'report_failed') : configCode ?? 'operation_failed';
196
+ const setCodes = ['set_environment_key_unsupported', 'set_invalid_options', 'set_value_too_large', 'set_interactive_input_required', 'set_input_cancelled'];
197
+ const code = command === 'set' ? (setCodes.includes(message ?? '') ? message : 'set_failed') : command === 'report' ? (caughtCode && codes.includes(caughtCode) ? caughtCode : 'report_failed') : configCode ?? 'operation_failed';
120
198
  process.stderr.write('aptly: ' + code + '\n');
121
199
  process.exitCode = 1;
122
200
  }
@@ -0,0 +1,5 @@
1
+ /** Values never appear in argv, echoed terminal input, diagnostics or temporary files. */
2
+ export declare function readEnvironmentValue({ key, fromStdin }: {
3
+ key: string;
4
+ fromStdin?: boolean;
5
+ }): Promise<string>;
@@ -0,0 +1,49 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { Writable } from 'node:stream';
3
+ import { StringDecoder } from 'node:string_decoder';
4
+ const fail = (code) => new Error(code);
5
+ /** Values never appear in argv, echoed terminal input, diagnostics or temporary files. */
6
+ export async function readEnvironmentValue({ key, fromStdin = false }) {
7
+ if (fromStdin) {
8
+ const decoder = new StringDecoder('utf8');
9
+ let value = '', bytesRead = 0;
10
+ for await (const chunk of process.stdin) {
11
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
12
+ bytesRead += bytes.length;
13
+ if (bytesRead > 8192)
14
+ throw fail('set_value_too_large');
15
+ value += decoder.write(bytes);
16
+ }
17
+ value += decoder.end();
18
+ if (Buffer.byteLength(value) > 8192)
19
+ throw fail('set_value_too_large');
20
+ return value.replace(/\r?\n$/, '');
21
+ }
22
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
23
+ throw fail('set_interactive_input_required');
24
+ const silent = new Writable({ write(_chunk, _encoding, done) { done(); } });
25
+ const rl = createInterface({ input: process.stdin, output: silent, terminal: true });
26
+ process.stdout.write(`Value for ${key} (input hidden): `);
27
+ try {
28
+ return await new Promise((resolve, reject) => {
29
+ let settled = false;
30
+ const cancel = () => { if (!settled) {
31
+ settled = true;
32
+ reject(fail('set_input_cancelled'));
33
+ } };
34
+ rl.once('SIGINT', () => { cancel(); rl.close(); });
35
+ rl.once('close', cancel);
36
+ rl.question('', value => {
37
+ settled = true;
38
+ if (Buffer.byteLength(value) > 8192)
39
+ reject(fail('set_value_too_large'));
40
+ else
41
+ resolve(value);
42
+ });
43
+ });
44
+ }
45
+ finally {
46
+ rl.close();
47
+ process.stdout.write('\n');
48
+ }
49
+ }
@@ -23,6 +23,7 @@ export declare class Router {
23
23
  ledgerPath: string | undefined;
24
24
  state: LedgerState;
25
25
  active: number;
26
+ private heldLedgerLock;
26
27
  constructor({ policy, provider, ledgerPath }?: RouterOptions);
27
28
  capabilities(): {
28
29
  policy_version: string;
package/dist/src/core.js CHANGED
@@ -1,7 +1,8 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync, lstatSync, chmodSync } from 'node:fs';
3
3
  import { isRecord, errorCode } from './types.js';
4
4
  import { builtInProfiles, resolveCatalog, isSupportedProfile } from './catalog.js';
5
+ import { acquireLedgerLock } from './ledger-lock.js';
5
6
  const defaultCatalog = resolveCatalog({});
6
7
  export const defaultPolicy = Object.freeze({
7
8
  version: 'aptly-v010', catalog_version: defaultCatalog.version, max_summary_bytes: 4000,
@@ -66,6 +67,7 @@ export class Router {
66
67
  ledgerPath;
67
68
  state;
68
69
  active;
70
+ heldLedgerLock;
69
71
  constructor({ policy = defaultPolicy, provider, ledgerPath } = {}) {
70
72
  this.policy = clone(policy);
71
73
  const integerCaps = { max_summary_bytes: 64000, max_concurrency: 16, max_daily_calls: 100000, max_total_calls: 1000000, timeout_ms: 60000 };
@@ -123,6 +125,7 @@ export class Router {
123
125
  load() {
124
126
  if (!this.ledgerPath)
125
127
  return;
128
+ this.heldLedgerLock?.assertHeld();
126
129
  try {
127
130
  const stat = lstatSync(this.ledgerPath);
128
131
  if (!stat.isFile() || stat.isSymbolicLink())
@@ -158,13 +161,15 @@ export class Router {
158
161
  save() {
159
162
  if (!this.ledgerPath)
160
163
  return;
161
- const tmp = `${this.ledgerPath}.write-${process.pid}`;
164
+ this.heldLedgerLock?.assertHeld();
165
+ const tmp = `${this.ledgerPath}.write-${randomUUID()}`;
162
166
  let fd;
163
167
  try {
164
168
  fd = openSync(tmp, 'wx', 0o600);
165
169
  writeFileSync(fd, JSON.stringify(this.state));
166
170
  closeSync(fd);
167
171
  fd = undefined;
172
+ this.heldLedgerLock?.assertHeld();
168
173
  renameSync(tmp, this.ledgerPath);
169
174
  }
170
175
  finally {
@@ -228,12 +233,13 @@ export class Router {
228
233
  let lock;
229
234
  if (this.ledgerPath) {
230
235
  try {
231
- lock = openSync(`${this.ledgerPath}.lock`, 'wx', 0o600);
236
+ lock = acquireLedgerLock(this.ledgerPath);
232
237
  }
233
- catch {
234
- return this.result('unavailable', 'ledger_locked');
238
+ catch (error) {
239
+ return this.result('unavailable', errorCode(error) === 'ledger_locked' ? 'ledger_locked' : 'ledger_error');
235
240
  }
236
241
  }
242
+ this.heldLedgerLock = lock;
237
243
  this.active++;
238
244
  try {
239
245
  this.load();
@@ -258,6 +264,15 @@ export class Router {
258
264
  return complete(this.result('keep_current', 'preserved_constraints'));
259
265
  if (!req.task.context_sufficient)
260
266
  return complete(this.result('needs_context', 'insufficient_context'));
267
+ // Known local failures precede reservation. Do not cache them: correcting
268
+ // configuration must allow the same ID to be tried without ledger edits.
269
+ // Never infer this from a transport exception (including HTTP 401/403).
270
+ try {
271
+ this.provider.preflight?.();
272
+ }
273
+ catch (e) {
274
+ return this.result('unavailable', errorCode(e) === 'unauthorized' ? 'unauthorized' : 'provider_error');
275
+ }
261
276
  const day = new Date().toISOString().slice(0, 10);
262
277
  if (this.state.calls >= this.policy.max_total_calls || (this.state.days[day] ?? 0) >= this.policy.max_daily_calls || this.state.estimated_usd + this.policy.estimated_call_usd > this.policy.max_estimated_usd + 1e-12)
263
278
  return complete(this.result('unavailable', 'budget_exhausted'));
@@ -299,7 +314,7 @@ export class Router {
299
314
  }
300
315
  catch (e) {
301
316
  // A failed transport can have consumed a paid call. Preserve its unknown marker.
302
- const allowed = ['timeout', 'refused', 'truncated', 'invalid_json', 'unauthorized', 'rate_limited', 'network'];
317
+ const allowed = ['timeout', 'refused', 'truncated', 'invalid_json', 'unauthorized', 'rate_limited', 'network', 'ledger_error'];
303
318
  const code = errorCode(e);
304
319
  return this.result('unavailable', code !== undefined && allowed.includes(code) ? code : 'provider_error', { latency_ms: latency() });
305
320
  }
@@ -312,12 +327,12 @@ export class Router {
312
327
  }
313
328
  finally {
314
329
  this.active--;
330
+ this.heldLedgerLock = undefined;
315
331
  if (lock !== undefined) {
316
- closeSync(lock);
317
332
  try {
318
- unlinkSync(`${this.ledgerPath}.lock`);
333
+ lock.release();
319
334
  }
320
- catch { }
335
+ catch { /* Never unlink a persistent OS-owned gate. */ }
321
336
  }
322
337
  }
323
338
  }
@@ -0,0 +1,18 @@
1
+ import type { SetupOptions, SetupResult } from './setup.ts';
2
+ export interface CredentialBackend {
3
+ loadSecret(options: {
4
+ account: string;
5
+ }): string | null;
6
+ storeSecret(value: string, options: {
7
+ account: string;
8
+ }): void;
9
+ deleteSecret(options: {
10
+ account: string;
11
+ }): void;
12
+ }
13
+ /** Key bytes enter the OS store only; host files receive nonsecret references. */
14
+ export declare function prepareApiKey(options?: SetupOptions): {
15
+ result: SetupResult;
16
+ apply(value: string, backend?: CredentialBackend): SetupResult;
17
+ };
18
+ export declare function setApiKey(value: string, options?: SetupOptions, backend?: CredentialBackend): SetupResult;