aptly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +45 -0
  3. package/README.ja.md +45 -0
  4. package/README.md +45 -0
  5. package/README.zh-CN.md +45 -0
  6. package/dist/bin/aptly.d.ts +2 -0
  7. package/dist/bin/aptly.js +122 -0
  8. package/dist/src/automatic-hook.d.ts +42 -0
  9. package/dist/src/automatic-hook.js +232 -0
  10. package/dist/src/catalog.d.ts +19 -0
  11. package/dist/src/catalog.js +135 -0
  12. package/dist/src/config.d.ts +24 -0
  13. package/dist/src/config.js +75 -0
  14. package/dist/src/core.d.ts +50 -0
  15. package/dist/src/core.js +324 -0
  16. package/dist/src/diagnostics.d.ts +11 -0
  17. package/dist/src/diagnostics.js +68 -0
  18. package/dist/src/files.d.ts +10 -0
  19. package/dist/src/files.js +70 -0
  20. package/dist/src/hook.d.ts +20 -0
  21. package/dist/src/hook.js +102 -0
  22. package/dist/src/inspect.d.ts +31 -0
  23. package/dist/src/inspect.js +219 -0
  24. package/dist/src/model-info.d.ts +32 -0
  25. package/dist/src/model-info.js +223 -0
  26. package/dist/src/provenance.d.ts +62 -0
  27. package/dist/src/provenance.js +266 -0
  28. package/dist/src/provider.d.ts +9 -0
  29. package/dist/src/provider.js +80 -0
  30. package/dist/src/report.d.ts +51 -0
  31. package/dist/src/report.js +177 -0
  32. package/dist/src/runtime.d.ts +8 -0
  33. package/dist/src/runtime.js +22 -0
  34. package/dist/src/server.d.ts +7 -0
  35. package/dist/src/server.js +80 -0
  36. package/dist/src/setup.d.ts +22 -0
  37. package/dist/src/setup.js +408 -0
  38. package/dist/src/types.d.ts +76 -0
  39. package/dist/src/types.js +2 -0
  40. package/dist/src/version.d.ts +1 -0
  41. package/dist/src/version.js +1 -0
  42. package/docs/ADVISORY_USAGE.md +196 -0
  43. package/docs/DRAFT_0_1_0.md +55 -0
  44. package/docs/guide/en.md +190 -0
  45. package/docs/guide/ja.md +190 -0
  46. package/docs/guide/ko.md +190 -0
  47. package/docs/guide/zh-CN.md +190 -0
  48. package/examples/claude-mcp.json +15 -0
  49. package/examples/codex-mcp.toml +9 -0
  50. package/examples/recommend.claude.json +9 -0
  51. package/examples/recommend.codex.json +9 -0
  52. package/examples/router.shadow.json +5 -0
  53. package/examples/settings-inventory.json +5 -0
  54. package/package.json +75 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aptly contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,45 @@
1
+ # Aptly
2
+
3
+ [한국어](README.md) · **English** · [简体中文](README.zh-CN.md) · [日本語](README.ja.md)
4
+
5
+ **A local MCP server that selects Claude Code and Codex subagent models and reasoning effort to suit the task.**
6
+
7
+ 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
+
9
+ ## Quick start
10
+
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.
12
+
13
+ ```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
16
+ ```
17
+
18
+ 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
+
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).
21
+
22
+ ## Usage modes
23
+
24
+ | Mode | Behavior |
25
+ |---|---|
26
+ | Automatic Hook | Selects defaults for fresh, general-purpose calls without an explicit model, effort, fixed role, inherited history, or resume target |
27
+ | Direct recommendation | Returns a profile through `recommend_agent` or `aptly recommend`; review it and decide separately whether to run it in the host |
28
+ | fixture | Tests recommendations generated by local rules without a key or network access; these are not actual Decisions results |
29
+
30
+ Automatic selection targets Codex calls with `fork_turns: "none"` and Claude Code calls with `subagent_type: "general-purpose"`. Explicit choices, insufficient context, and authentication or connection errors leave the original call unchanged. A recommendation does not verify model access or the host's complete configuration.
31
+
32
+ ## Documentation
33
+
34
+ - [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
36
+ - [Models and environment variables](docs/guide/en.md#models-and-environment-variables): six default profiles and candidate configuration
37
+ - [Providing context](docs/guide/en.md#providing-context): encrypted messages and repository URL limitations
38
+ - [Cost benchmark](docs/guide/en.md#cost-benchmark): tokens, converted costs, and measurement scope for a README translation review
39
+ - [Diagnostics and development](docs/guide/en.md#diagnostics-and-development): local reports and checks without paid API calls
40
+
41
+ Korean is the canonical documentation. The English, Simplified Chinese, and Japanese documents cover the same usage scope. Choosing a documentation language does not change the language of CLI or MCP output.
42
+
43
+ ## License
44
+
45
+ [MIT](LICENSE).
package/README.ja.md ADDED
@@ -0,0 +1,45 @@
1
+ # Aptly
2
+
3
+ [한국어](README.md) · [English](README.en.md) · [简体中文](README.zh-CN.md) · **日本語**
4
+
5
+ **タスクに適した Claude Code・Codex のサブエージェント用モデルと effort を選ぶローカル MCP です。**
6
+
7
+ Aptly は必要最小限のタスク要約を OpenAI Decisions API で分類し、あらかじめ許可された実行プロファイルを推薦します。インストールした Hook は、明示的な指定のない新しい汎用サブエージェントのデフォルト値を自動で選択できます。サブエージェントの実行と権限に関する判断は、既存のホストが行います。
8
+
9
+ ## クイックスタート
10
+
11
+ Node.js **24 以上**と Claude Code または Codex が必要です。現在は **MIT ライセンスの 0.1.0 ローカルパッケージ案**で、npm registry にはまだ公開していません。確認済みのアーカイブの実際のパスに置き換えてインストールしてください。
12
+
13
+ ```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
16
+ ```
17
+
18
+ インストール時の `postinstall` は、既存のホスト設定ディレクトリに MCP と `PreToolUse` Hook を登録します。自動登録をスキップした場合や、ホストに設定ディレクトリがまだない場合も、`setup` を明示的に実行して登録できます。他の既存設定・Hook と、既存の Aptly の実行コマンド・環境は保持します。
19
+
20
+ 分類用の `OPENAI_API_KEY` は、安全な実行環境から MCP プロセスに渡してください。キーをコマンドライン・リクエスト・設定 JSON・リポジトリに記載しないでください。ホストまたは MCP を再起動した後、**Codex では `/hooks` で新しい Hook を確認し、信頼を承認**してください。[インストール・キー・登録の詳細](docs/guide/ja.md#インストール)を確認してください。
21
+
22
+ ## 使用方法
23
+
24
+ | 方法 | 動作 |
25
+ |---|---|
26
+ | 自動 Hook | モデル・effort・固定ロール・履歴継承・resume の指定がない、新しい汎用呼び出しのデフォルト値を選びます |
27
+ | 直接推薦 | `recommend_agent` または `aptly recommend` でプロファイルを受け取り、実行するかどうかをホストで明示的に判断します |
28
+ | fixture | キーやネットワークを使わず、ローカルで生成した試験用の推薦を確認できます。実際の Decisions の結果ではありません |
29
+
30
+ 自動選択の対象は、Codex の `fork_turns: "none"`、Claude Code の `subagent_type: "general-purpose"` の呼び出しです。明示的な指定や、コンテキスト不足・認証・接続エラーがある場合は、元の呼び出しの引数を変更しません。推薦されたというだけでは、モデルへのアクセス権限やホストの設定全体が確認されたことにはなりません。
31
+
32
+ ## ドキュメント
33
+
34
+ - [使用ガイド](docs/guide/ja.md): インストール・削除、キー、Hook、直接推薦、エラー復旧
35
+ - [インストール・削除](docs/guide/ja.md#インストール): ローカルアーカイブ、ホスト登録、再起動と信頼の承認
36
+ - [モデルと環境変数](docs/guide/ja.md#モデルと環境変数): 6 つのデフォルトプロファイルと候補の設定
37
+ - [コンテキストの提供](docs/guide/ja.md#コンテキストの提供): 暗号化されたメッセージとリポジトリ URL の制約
38
+ - [費用実測](docs/guide/ja.md#費用実測): README 翻訳レビューのトークン・換算費用と測定範囲
39
+ - [診断・開発](docs/guide/ja.md#診断と開発): ローカル report と無料の検証コマンド
40
+
41
+ 韓国語版が正本です。英語・簡体字中国語・日本語のドキュメントは同じ使用範囲を説明しており、ドキュメントの言語を選んでも CLI・MCP の出力言語は変わりません。
42
+
43
+ ## ライセンス
44
+
45
+ [MIT](LICENSE)。
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # Aptly
2
+
3
+ **한국어** · [English](README.en.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md)
4
+
5
+ **작업에 맞는 Claude Code·Codex 서브에이전트 모델과 effort를 선택하는 로컬 MCP입니다.**
6
+
7
+ Aptly는 최소한의 작업 요약을 OpenAI Decisions API로 분류하고 허용된 실행 프로필을 추천합니다. 설치한 Hook은 명시 선택이 없는 새 일반 서브에이전트의 기본값을 자동으로 선택할 수 있습니다. 실제 실행과 권한 결정은 기존 호스트가 담당합니다.
8
+
9
+ ## 빠른 시작
10
+
11
+ Node.js **24 이상**과 Claude Code 또는 Codex가 필요합니다. 현재는 **MIT 라이선스의 0.1.0 로컬 패키지 초안**이며 npm 레지스트리에 게시하지 않았습니다. 검토한 아카이브의 실제 경로로 바꿔 설치하십시오.
12
+
13
+ ```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
16
+ ```
17
+
18
+ 설치 시 `postinstall`은 기존 호스트 설정 디렉터리에 MCP와 `PreToolUse` Hook을 등록합니다. 명시적인 `setup`은 자동 등록을 건너뛰었거나 아직 설정 디렉터리가 없는 호스트에도 사용할 수 있습니다. 기존 다른 설정·Hook과 기존 Aptly 실행 명령·환경은 보존합니다.
19
+
20
+ 분류용 `OPENAI_API_KEY`는 안전한 실행 환경에서 MCP 프로세스에 전달하십시오. 키를 명령행·요청·설정 JSON·저장소에 기록하지 마십시오. 호스트 또는 MCP를 재시작한 뒤, **Codex에서는 `/hooks`에서 새 Hook을 검토하고 신뢰를 승인하십시오.** [설치·키·등록 상세](docs/guide/ko.md#설치)를 참고하십시오.
21
+
22
+ ## 사용 방식
23
+
24
+ | 방식 | 동작 |
25
+ |---|---|
26
+ | 자동 Hook | 모델·effort·고정 역할·이력 상속·resume을 지정하지 않은 새 일반 호출의 기본값을 선택합니다 |
27
+ | 직접 추천 | `recommend_agent` 또는 `aptly recommend`로 프로필을 받고 호스트에서 명시적으로 실행합니다 |
28
+ | fixture | 키·네트워크 없이 시험용 추천을 확인합니다. 실제 Decisions 결과가 아닙니다 |
29
+
30
+ 자동 선택 대상은 Codex의 `fork_turns: "none"`, Claude Code의 `subagent_type: "general-purpose"` 호출입니다. 명시 선택이나 맥락 부족·인증·연결 오류가 있으면 원래 호출을 유지합니다. 추천은 모델 접근 권한이나 호스트 전체 설정의 확인을 뜻하지 않습니다.
31
+
32
+ ## 문서
33
+
34
+ - [사용 가이드](docs/guide/ko.md): 설치·제거, 키, Hook, 직접 추천, 오류 복구
35
+ - [설치·제거](docs/guide/ko.md#설치): 로컬 아카이브, 호스트 등록, 재시작과 신뢰 승인
36
+ - [모델과 환경변수](docs/guide/ko.md#모델과-환경변수): 여섯 기본 프로필과 후보 설정
37
+ - [맥락 전달](docs/guide/ko.md#맥락-전달): 암호화된 메시지와 저장소 URL의 한계
38
+ - [비용 실측](docs/guide/ko.md#비용-실측): README 번역 검토의 토큰·환산 비용과 측정 범위
39
+ - [진단·개발](docs/guide/ko.md#진단과-개발): 로컬 진단 보고서와 무료 검증 명령
40
+
41
+ 한국어가 정본입니다. 영어·중국어 간체·일본어 문서는 같은 사용 범위를 설명하며, 문서 언어가 CLI·MCP 출력 언어를 바꾸지는 않습니다.
42
+
43
+ ## 라이선스
44
+
45
+ [MIT](LICENSE).
@@ -0,0 +1,45 @@
1
+ # Aptly
2
+
3
+ [한국어](README.md) · [English](README.en.md) · **简体中文** · [日本語](README.ja.md)
4
+
5
+ **用于为 Claude Code 和 Codex 子代理选择合适模型与推理强度(effort)的本地 MCP 服务器。**
6
+
7
+ Aptly 使用 OpenAI Decisions API 对简要任务摘要进行分类,并推荐允许使用的执行配置。安装 Hook 后,可为未明确指定配置的新建通用子代理自动选择默认值。实际执行与权限决策仍由宿主(Claude Code 或 Codex)负责。
8
+
9
+ ## 快速开始
10
+
11
+ 需要 Node.js **24 或更高版本**,以及 Claude Code 或 Codex。目前是**采用 MIT 许可证的 0.1.0 本地软件包草案**,尚未发布到 npm registry。请将示例路径替换为已审核归档文件的实际路径。
12
+
13
+ ```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
16
+ ```
17
+
18
+ 安装时的 `postinstall` 会在现有宿主配置目录中注册 MCP 和 `PreToolUse` Hook。如果跳过了自动注册,或宿主尚无配置目录,也可以显式运行 `setup`。其他现有配置与 Hook,以及 Aptly 原有的启动命令和环境均会保留。
19
+
20
+ 请通过安全的运行环境,将分类用的 `OPENAI_API_KEY` 传递给 MCP 进程。不要将密钥写入命令行、请求、配置 JSON 或代码仓库。重启宿主或 MCP 后,**请在 Codex 的 `/hooks` 中审核新 Hook 并批准信任**。详见[安装、密钥与注册](docs/guide/zh-CN.md#安装)。
21
+
22
+ ## 使用方式
23
+
24
+ | 方式 | 行为 |
25
+ |---|---|
26
+ | 自动 Hook | 为未指定模型、effort、固定角色、历史继承或 resume 的新建通用调用选择默认值 |
27
+ | 直接推荐 | 通过 `recommend_agent` 或 `aptly recommend` 获取执行配置,再在宿主中明确选择并执行 |
28
+ | fixture | 无需密钥或网络即可测试本地模拟推荐;结果并非实际 Decisions 判断 |
29
+
30
+ 自动选择适用于 Codex 的 `fork_turns: "none"` 调用和 Claude Code 的 `subagent_type: "general-purpose"` 调用。如果已有明确选择,或发生上下文不足、认证或连接错误,则保留原始调用。推荐结果并不表示已确认账户的模型访问权限,也不表示已核实宿主的全部配置。
31
+
32
+ ## 文档
33
+
34
+ - [使用指南](docs/guide/zh-CN.md):安装与移除、密钥、Hook、直接推荐、错误恢复
35
+ - [安装与移除](docs/guide/zh-CN.md#安装):本地归档、宿主注册、重启与信任批准
36
+ - [模型与环境变量](docs/guide/zh-CN.md#模型与环境变量):六个默认配置与候选设置
37
+ - [提供上下文](docs/guide/zh-CN.md#提供上下文):加密消息与仓库 URL 的限制
38
+ - [成本实测](docs/guide/zh-CN.md#成本实测):README 翻译审阅的 token、换算成本与测量范围
39
+ - [诊断与开发](docs/guide/zh-CN.md#诊断与开发):本地诊断报告与免费验证命令
40
+
41
+ 韩语文档为正本,英语、简体中文和日语文档与其保持相同的使用范围。选择文档语言不会改变 CLI 或 MCP 的输出语言。
42
+
43
+ ## 许可证
44
+
45
+ [MIT](LICENSE)。
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { serve } from '../src/server.js';
5
+ import { runtime } from '../src/runtime.js';
6
+ import { explainSettings } from '../src/inspect.js';
7
+ import { VERSION } from '../src/version.js';
8
+ import { collectDiagnostics } from '../src/diagnostics.js';
9
+ import { exportDiagnosticReport } from '../src/report.js';
10
+ import { fetchModelInfo, saveModelInfo } from '../src/model-info.js';
11
+ import { errorCode } from '../src/types.js';
12
+ import { setupHosts, removeSetup } from '../src/setup.js';
13
+ const [command = 'serve', ...args] = process.argv.slice(2);
14
+ const output = (value) => process.stdout.write(JSON.stringify(value, null, 2) + '\n');
15
+ try {
16
+ 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');
18
+ }
19
+ else if (command === '--version')
20
+ process.stdout.write(VERSION + '\n');
21
+ else if (command === 'report' && args.length === 1 && args[0] === '--help') {
22
+ process.stdout.write('Usage: aptly report [--format md|json|both] [--output-dir DIR]\nDefault: both formats in a private reports directory. No uploads or API probes.\n');
23
+ }
24
+ else if (command === 'report') {
25
+ const options = { format: 'both', outputDir: 'reports' };
26
+ const seen = new Set();
27
+ for (let i = 0; i < args.length; i++) {
28
+ const flag = args[i];
29
+ if (!['--format', '--output-dir'].includes(flag) || seen.has(flag) || !args[i + 1] || args[i + 1].startsWith('--'))
30
+ throw Object.assign(new Error('report_invalid_options'), { code: 'report_invalid_options' });
31
+ seen.add(flag);
32
+ options[flag === '--format' ? 'format' : 'outputDir'] = args[++i];
33
+ }
34
+ if (!['md', 'json', 'both'].includes(options.format))
35
+ throw Object.assign(new Error('report_invalid_options'), { code: 'report_invalid_options' });
36
+ output(exportDiagnosticReport({ diagnostic: collectDiagnostics(), ...options }));
37
+ }
38
+ else if (['setup', 'remove'].includes(command)) {
39
+ 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');
41
+ else {
42
+ let clients = ['codex', 'claude'];
43
+ let mode = 'defaults';
44
+ let automatic = false;
45
+ const seen = new Set();
46
+ for (let i = 0; i < args.length; i++) {
47
+ const flag = args[i];
48
+ if (seen.has(flag))
49
+ throw new Error('setup_invalid_options');
50
+ seen.add(flag);
51
+ if (flag === '--automatic' && command === 'setup') {
52
+ automatic = true;
53
+ continue;
54
+ }
55
+ const value = args[++i];
56
+ if (flag === '--client' && ['codex', 'claude', 'all'].includes(value))
57
+ clients = value === 'all' ? ['codex', 'claude'] : [value];
58
+ else if (flag === '--mode' && command === 'setup' && ['defaults', 'advisory'].includes(value))
59
+ mode = value;
60
+ else
61
+ throw new Error('setup_invalid_options');
62
+ }
63
+ 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 });
65
+ output(result);
66
+ if (result.status === 'partial' && !automatic)
67
+ process.exitCode = 1;
68
+ }
69
+ }
70
+ else if (command === 'serve' && args.length === 0)
71
+ await serve();
72
+ 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 });
75
+ }
76
+ else if (command === 'catalog' && args.length === 3 && args[0] === 'refresh' && args[1] === '--output') {
77
+ const snapshot = await fetchModelInfo();
78
+ saveModelInfo(args[2], snapshot);
79
+ output({ status: 'refreshed', facts: snapshot.facts.length, sources: snapshot.sources, account_access_verified: false });
80
+ }
81
+ else if (command === 'catalog' && args.length === 1 && args[0] === '--help') {
82
+ process.stdout.write('Usage: aptly catalog\n aptly catalog refresh --output /absolute/private/model-info.json\nSet APTLY_MODEL_INFO_PATH to load the cache at server startup. No account access is certified.\n');
83
+ }
84
+ else if (command === 'recommend' && args.length === 1 && args[0] === '--help') {
85
+ process.stdout.write('Usage: aptly recommend < request.json\nRequired: request_id, client (codex|claude), task {summary, operation, context_sufficient}.\nOptional: eligible_profile_ids (defaults to client catalog), constraints (declared pins only).\nRecommendations are advisory_only; no host settings are inspected or changed.\nSee examples/recommend.codex.json and examples/recommend.claude.json.\n');
86
+ }
87
+ else if (command === 'recommend' && args.length === 0) {
88
+ const raw = readFileSync(0, 'utf8');
89
+ if (Buffer.byteLength(raw) > 16384)
90
+ throw new Error('input_too_large');
91
+ const { router, mode } = runtime();
92
+ output({ ...await router.advise(JSON.parse(raw)), provider: mode });
93
+ }
94
+ else if (command === 'inspect' && args.length === 2 && ['codex', 'claude'].includes(args[0])) {
95
+ const raw = readFileSync(args[1], 'utf8');
96
+ if (Buffer.byteLength(raw) > 65536)
97
+ throw new Error('input_too_large');
98
+ const inventory = JSON.parse(raw);
99
+ output(explainSettings({ ...inventory, client: args[0] }));
100
+ }
101
+ else if (command === 'config' && args.length === 1 && ['codex', 'claude'].includes(args[0])) {
102
+ const node = process.execPath, server = fileURLToPath(new URL(`../src/server${import.meta.url.endsWith('.ts') ? '.ts' : '.js'}`, import.meta.url));
103
+ if (args[0] === 'claude')
104
+ output({ mcpServers: { 'aptly': { command: node, args: [server], env: { ROUTER_PROVIDER: 'fixture' } } } });
105
+ else
106
+ process.stdout.write('[mcp_servers.aptly]\ncommand = ' + JSON.stringify(node) + '\nargs = [' + JSON.stringify(server) + ']\nenv_vars = ["OPENAI_API_KEY", "ROUTER_PROVIDER", "ROUTER_LEDGER_PATH", "ROUTER_CONFIG_PATH", "APTLY_CLAUDE_MODELS", "APTLY_CODEX_MODELS", "CLAUDE_MODELS", "CODEX_MODELS", "APTLY_MODEL_INFO_PATH"]\n');
107
+ }
108
+ else {
109
+ process.stderr.write('aptly: invalid_command (use --help)\n');
110
+ process.exitCode = 2;
111
+ }
112
+ }
113
+ catch (error) {
114
+ const codes = ['report_invalid_options', 'report_invalid_diagnostic', 'report_invalid_format', 'report_already_exists', 'report_output_unavailable'];
115
+ const caughtCode = errorCode(error);
116
+ 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
+ const message = error instanceof Error ? error.message : undefined;
118
+ 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';
120
+ process.stderr.write('aptly: ' + code + '\n');
121
+ process.exitCode = 1;
122
+ }
@@ -0,0 +1,42 @@
1
+ import { Router } from './core.ts';
2
+ import type { HookOutput } from './hook.ts';
3
+ export interface AutoPolicy {
4
+ schema_version: 1;
5
+ kind: 'aptly-auto-hook';
6
+ mode: 'defaults' | 'advisory';
7
+ issuer: 'operator_installation';
8
+ enabled: boolean;
9
+ state_directory: string;
10
+ }
11
+ export interface AutomaticHookObservation {
12
+ status: 'applied' | 'advisory' | 'preserved';
13
+ reason: string;
14
+ profile_id?: string;
15
+ request_id?: string;
16
+ }
17
+ export interface AutomaticHookResult {
18
+ output: HookOutput | {
19
+ hookSpecificOutput: {
20
+ hookEventName: 'PreToolUse';
21
+ additionalContext: string;
22
+ };
23
+ };
24
+ observation: AutomaticHookObservation;
25
+ }
26
+ export interface AutomaticHookOptions {
27
+ client: unknown;
28
+ event: unknown;
29
+ router: Router;
30
+ /** Internal configuration only: callers read APTLY_HOOK_POLICY_PATH, never MCP arguments. */
31
+ policyPath?: unknown;
32
+ signal?: AbortSignal;
33
+ /** A caller may revoke the pending event; absence does not certify native dispatch. */
34
+ stillCurrent?: () => boolean | Promise<boolean>;
35
+ }
36
+ export declare function loadAutomaticHookPolicy(path: unknown): {
37
+ policy: AutoPolicy | null;
38
+ reason: string;
39
+ fingerprint?: string;
40
+ };
41
+ /** Operator default selection only. No provenance_verified/evidence_verified host claim. */
42
+ export declare function automaticHook({ client, event, router, policyPath, signal, stillCurrent }: AutomaticHookOptions): Promise<AutomaticHookResult>;
@@ -0,0 +1,232 @@
1
+ /** Explicit operator-installed Hook policy. This does not certify host settings. */
2
+ import { createHash } from 'node:crypto';
3
+ import { closeSync, openSync, writeFileSync } from 'node:fs';
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { Router } from './core.js';
6
+ import { assertPrivateStatePath, readRegularFile } from './files.js';
7
+ import { hookInput, hookOutput } from './hook.js';
8
+ import { digestToolInput } from './provenance.js';
9
+ const own = (object, key) => Object.hasOwn(object, key);
10
+ const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) &&
11
+ [Object.prototype, null].includes(Object.getPrototypeOf(value));
12
+ const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
13
+ const safeText = (value, max) => typeof value === 'string' && !!value.trim() && Buffer.byteLength(value) <= max;
14
+ const sensitive = (value) => /\bsk-[A-Za-z0-9_-]{12,}|\bBearer\s+[A-Za-z0-9._~+\/-]{8,}={0,2}/i.test(value);
15
+ const policyKeys = ['schema_version', 'kind', 'mode', 'issuer', 'enabled', 'state_directory'];
16
+ export function loadAutomaticHookPolicy(path) {
17
+ if (path === undefined)
18
+ return { policy: null, reason: 'automatic_policy_missing' };
19
+ try {
20
+ assertPrivateStatePath(path);
21
+ const value = JSON.parse(readRegularFile(path, { privateFile: true, maxBytes: 4096 }).toString('utf8'));
22
+ if (!plain(value) || Object.keys(value).length !== policyKeys.length || Object.keys(value).some(key => !policyKeys.includes(key)) ||
23
+ value.schema_version !== 1 || value.kind !== 'aptly-auto-hook' || (typeof value.mode !== 'string' || !['defaults', 'advisory'].includes(value.mode)) ||
24
+ value.issuer !== 'operator_installation' || typeof value.enabled !== 'boolean' ||
25
+ typeof value.state_directory !== 'string' || !isAbsolute(value.state_directory))
26
+ throw new Error('invalid_policy');
27
+ // Validate an existing private state directory, without creating a path from untrusted event data.
28
+ assertPrivateStatePath(join(value.state_directory, '.aptly-state-check'));
29
+ const policy = Object.fromEntries(policyKeys.map(key => [key, value[key]]));
30
+ return { policy, fingerprint: hash(policy), reason: policy.enabled ? 'automatic_policy_enabled' : 'automatic_policy_disabled' };
31
+ }
32
+ catch {
33
+ return { policy: null, reason: 'automatic_policy_invalid' };
34
+ }
35
+ }
36
+ function readCall(client, event) {
37
+ try {
38
+ if ((client !== 'codex' && client !== 'claude') || !plain(event) || event.hook_event_name !== 'PreToolUse' ||
39
+ !safeText(event.session_id, 256) || !safeText(event.tool_use_id ?? event.tool_call_id, 512) ||
40
+ !(client === 'codex' ? ['spawn_agent', 'collaborationspawn_agent', 'Agent'] : ['Agent']).includes(String(event.tool_name)) ||
41
+ Buffer.byteLength(JSON.stringify(event)) > 65536)
42
+ return null;
43
+ const digest = digestToolInput({ client, event });
44
+ if (!digest)
45
+ return null;
46
+ let input = event.tool_input;
47
+ if (typeof input === 'string') {
48
+ if (client !== 'claude' || Buffer.byteLength(input) > 16384)
49
+ return null;
50
+ input = JSON.parse(input);
51
+ }
52
+ if (!plain(input))
53
+ return null;
54
+ return { client, event, input, digest,
55
+ payloadDigest: hash([digest, event.tool_name, event.cwd ?? null, event.turn_id ?? null, event.turnId ?? null]),
56
+ callKey: hash(['aptly-auto-hook-call-v1', client, event.session_id, event.tool_use_id ?? event.tool_call_id]) };
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ function preservation(call) {
63
+ const input = call.input;
64
+ if (own(input, 'model'))
65
+ return 'explicit_model';
66
+ if (['effort', 'reasoning_effort', 'model_reasoning_effort'].some(key => own(input, key)))
67
+ return 'explicit_effort';
68
+ if (['resume', 'resume_agent', 'agent_id'].some(key => own(input, key)))
69
+ return 'resumed_agent';
70
+ if (['role', 'agent_type'].some(key => own(input, key)) ||
71
+ (call.client === 'claude' && input.subagent_type !== 'general-purpose'))
72
+ return 'fixed_role';
73
+ if (['fork_context', 'fork', 'fork_session', 'inherit_history'].some(key => own(input, key)) ||
74
+ (call.client === 'codex' ? input.fork_turns !== 'none' : own(input, 'fork_turns')))
75
+ return 'inherited_fork';
76
+ return null;
77
+ }
78
+ function catalogFingerprint(policy) {
79
+ return hash([policy.version, policy.catalog_version, policy.provider_id ?? null, policy.profiles, policy.profile_descriptions ?? {}]);
80
+ }
81
+ function boundedSummary(call, maxBytes) {
82
+ const label = call.input[call.client === 'codex' ? 'task_name' : 'description'];
83
+ const body = call.input[call.client === 'codex' ? 'message' : 'prompt'];
84
+ if (typeof body !== 'string' || !body.trim())
85
+ return { reason: 'insufficient_context' };
86
+ // Check the entire bounded native body before clipping, so truncation cannot hide a key.
87
+ if (sensitive(body) || (typeof label === 'string' && sensitive(label)))
88
+ return { reason: 'sensitive_input' };
89
+ // Some Codex transports expose an opaque message to PreToolUse. Never decode
90
+ // it or forward it to the provider: a sufficiently descriptive visible name
91
+ // is a separate, explicitly labelled source of task advice only.
92
+ if (call.client === 'codex' && /^gAAAA[A-Za-z0-9_-]{75,}={0,2}$/.test(body)) {
93
+ if (!safeText(label, 512) || Buffer.byteLength(label) < 24 || !/^[a-z0-9]+(?:_[a-z0-9]+)*$/.test(label))
94
+ return { reason: 'insufficient_context' };
95
+ const words = label.split('_');
96
+ if (words.length < 6 || words.filter(word => /^[a-z]{2,}$/.test(word)).length < 3)
97
+ return { reason: 'insufficient_context' };
98
+ const summary = `Task summary from visible task_name only; opaque message not inspected: ${words.join(' ')}`;
99
+ if (Buffer.byteLength(summary) > Math.min(4000, maxBytes))
100
+ return { reason: 'insufficient_context' };
101
+ return { summary };
102
+ }
103
+ const first = body.trim().split(/\r?\n\s*\r?\n/, 1)[0];
104
+ if (first.length < 12 || first.trim().split(/\s+/u).length < 3 || (typeof label === 'string' && first.trim() === label.trim()))
105
+ return { reason: 'insufficient_context' };
106
+ const prefix = safeText(label, 512) ? `Task: ${label.trim()}\n` : '';
107
+ const budget = Math.min(4000, maxBytes) - Buffer.byteLength(prefix);
108
+ if (budget < 64)
109
+ return { reason: 'insufficient_context' };
110
+ // Clip by Unicode code point; never emit invalid UTF-8 at a byte boundary.
111
+ let clipped = '', bytes = 0;
112
+ for (const point of first) {
113
+ const size = Buffer.byteLength(point);
114
+ if (bytes + size > budget)
115
+ break;
116
+ clipped += point;
117
+ bytes += size;
118
+ }
119
+ return { summary: prefix + clipped };
120
+ }
121
+ function bindCall(call, policy, policyFingerprint, catalog) {
122
+ const binding = { schema_version: 1, input_digest: call.digest, payload_digest: call.payloadDigest, policy_fingerprint: policyFingerprint, catalog_fingerprint: catalog,
123
+ request_id: hash(['aptly-auto-hook-v1', call.callKey, call.payloadDigest, policyFingerprint, catalog]) };
124
+ const path = join(policy.state_directory, `hook-call-${call.callKey}.json`);
125
+ let fd;
126
+ try {
127
+ assertPrivateStatePath(path);
128
+ try {
129
+ fd = openSync(path, 'wx', 0o600);
130
+ }
131
+ catch (error) {
132
+ if (!plain(error) && !(error instanceof Error))
133
+ throw error;
134
+ if (error.code !== 'EEXIST')
135
+ throw error;
136
+ const previous = JSON.parse(readRegularFile(path, { privateFile: true, maxBytes: 2048 }).toString('utf8'));
137
+ if (!plain(previous) || Object.keys(previous).length !== Object.keys(binding).length ||
138
+ Object.entries(binding).some(([key, value]) => previous[key] !== value))
139
+ return { reason: 'call_conflict' };
140
+ return { request_id: binding.request_id };
141
+ }
142
+ // Immutable metadata is persisted before a potentially paid classification. A partial
143
+ // write fails closed on the next invocation rather than issuing a replacement ID.
144
+ writeFileSync(fd, JSON.stringify(binding));
145
+ return { request_id: binding.request_id };
146
+ }
147
+ catch {
148
+ return { reason: 'automatic_state_unavailable' };
149
+ }
150
+ finally {
151
+ if (fd !== undefined)
152
+ closeSync(fd);
153
+ }
154
+ }
155
+ /** Operator default selection only. No provenance_verified/evidence_verified host claim. */
156
+ export async function automaticHook({ client, event, router, policyPath, signal, stillCurrent }) {
157
+ const preserve = (reason, request_id) => ({ output: {}, observation: { status: 'preserved', reason, ...(request_id ? { request_id } : {}) } });
158
+ try {
159
+ const loaded = loadAutomaticHookPolicy(policyPath);
160
+ if (!loaded.policy || !loaded.policy.enabled || !loaded.fingerprint)
161
+ return preserve(loaded.reason);
162
+ const call = readCall(client, event);
163
+ if (!call)
164
+ return preserve('invalid_event');
165
+ const pinned = preservation(call);
166
+ if (pinned)
167
+ return preserve(pinned);
168
+ // Synthetic fixture output must never select a native child's defaults.
169
+ if (loaded.policy.mode === 'defaults' && router.policy.provider_id !== 'decisions')
170
+ return preserve('synthetic_provider');
171
+ const summary = boundedSummary(call, router.policy.max_summary_bytes);
172
+ if (!summary.summary)
173
+ return preserve(summary.reason ?? 'insufficient_context');
174
+ if (signal?.aborted || (stillCurrent && await stillCurrent() !== true))
175
+ return preserve('call_cancelled');
176
+ // Installing an operator policy must not bypass a historical strict-path
177
+ // unknown/complete record for this same native call with a new paid ID.
178
+ const beforePaid = readCall(client, event), policyBeforePaid = loadAutomaticHookPolicy(policyPath);
179
+ if (!beforePaid || beforePaid.callKey !== call.callKey || beforePaid.payloadDigest !== call.payloadDigest)
180
+ return preserve('call_changed');
181
+ if (!policyBeforePaid.policy?.enabled || policyBeforePaid.fingerprint !== loaded.fingerprint)
182
+ return preserve('automatic_policy_changed');
183
+ const legacyId = hookInput({ client, event, policy: router.policy })?.request_id ??
184
+ hash([call.client, call.event.session_id, call.event.tool_use_id ?? call.event.tool_call_id]);
185
+ try {
186
+ router.load();
187
+ }
188
+ catch {
189
+ return preserve('ledger_error');
190
+ }
191
+ if (own(router.state.requests, legacyId))
192
+ return preserve('legacy_request_exists', legacyId);
193
+ const catalog = catalogFingerprint(router.policy);
194
+ const bound = bindCall(call, loaded.policy, loaded.fingerprint, catalog);
195
+ if (!bound.request_id)
196
+ return preserve(bound.reason ?? 'automatic_state_unavailable');
197
+ const request_id = bound.request_id;
198
+ const snapshot = structuredClone(call.event);
199
+ const recommendation = await router.advise({ request_id, client: call.client,
200
+ task: { summary: summary.summary, operation: 'analysis', context_sufficient: true },
201
+ eligible_profile_ids: router.policy.profiles.filter(profile => profile.client === call.client).map(profile => profile.id),
202
+ constraints: { model_pinned: false, effort_pinned: false, role_pinned: false, fork_inherits: false },
203
+ });
204
+ if (signal?.aborted || (stillCurrent && await stillCurrent() !== true))
205
+ return preserve('call_cancelled', request_id);
206
+ const current = readCall(client, event), policyNow = loadAutomaticHookPolicy(policyPath);
207
+ if (!current || current.callKey !== call.callKey || current.digest !== call.digest || current.payloadDigest !== call.payloadDigest)
208
+ return preserve('call_changed', request_id);
209
+ if (!policyNow.policy?.enabled || policyNow.fingerprint !== loaded.fingerprint)
210
+ return preserve('automatic_policy_changed', request_id);
211
+ if (catalogFingerprint(router.policy) !== catalog)
212
+ return preserve('catalog_changed', request_id);
213
+ // The existing narrow catalog/tuple validator is reused. This local flag is an
214
+ // operator-policy decision, never a claim about complete host-setting evidence.
215
+ const candidate = hookOutput({ client, event: snapshot, recommendation, mode: 'apply', compatibilityVerified: true, policy: router.policy });
216
+ if (!candidate.hookSpecificOutput || recommendation.status !== 'recommended') {
217
+ const allowed = ['preserved_constraints', 'unsupported_profile', 'invalid_response', 'invalid_choice', 'provider_abstained', 'no_candidates', 'insufficient_context',
218
+ 'provider_needs_context', 'budget_exhausted', 'concurrency_limit', 'ledger_locked', 'request_conflict', 'outcome_unknown', 'ledger_error', 'timeout', 'refused',
219
+ 'truncated', 'invalid_json', 'unauthorized', 'rate_limited', 'network', 'provider_error', 'sensitive_input', 'invalid_input'];
220
+ return preserve(allowed.includes(recommendation.reason) ? recommendation.reason : 'recommendation_unavailable', request_id);
221
+ }
222
+ const profile = router.policy.profiles.find(profile => profile.id === recommendation.profile_id && profile.client === call.client);
223
+ if (loaded.policy.mode === 'advisory')
224
+ return { output: { hookSpecificOutput: { hookEventName: 'PreToolUse',
225
+ additionalContext: `Aptly suggests ${profile.model}${profile.reasoning_effort ? ` with ${profile.reasoning_effort} reasoning effort` : ''} for this task (${profile.id}). This is task advice; the host retains execution and permission decisions.` } },
226
+ observation: { status: 'advisory', reason: 'operator_policy_advice', profile_id: profile.id, request_id } };
227
+ return { output: candidate, observation: { status: 'applied', reason: 'operator_policy_defaults', profile_id: profile.id, request_id } };
228
+ }
229
+ catch {
230
+ return preserve('automatic_hook_unavailable');
231
+ }
232
+ }
@@ -0,0 +1,19 @@
1
+ import type { Client, Profile } from './types.ts';
2
+ /** Bounded official capability text only; this is not host availability evidence. */
3
+ export interface ModelFact {
4
+ client: Client;
5
+ model: string;
6
+ description: string;
7
+ }
8
+ export interface ResolvedCatalog {
9
+ profiles: Profile[];
10
+ descriptions: Record<string, string>;
11
+ version: string;
12
+ disabled_clients: Client[];
13
+ }
14
+ /** Fresh copies prevent caller mutation from changing the trusted registry. */
15
+ export declare function builtInProfiles(): Profile[];
16
+ /** Exact finite profile tuples, including key coverage, not arbitrary model strings. */
17
+ export declare function isSupportedProfile(value: unknown): value is Profile;
18
+ /** Operator candidate filters and model facts never grant execution authority. */
19
+ export declare function resolveCatalog(env?: NodeJS.ProcessEnv, facts?: ModelFact[]): ResolvedCatalog;