cctra 0.3.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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +135 -0
  3. package/bin/cctra +2 -0
  4. package/bin/cctra-daemon.exe +0 -0
  5. package/bin/cctra.js +2 -0
  6. package/examples/plugins/oauth-internal.js +46 -0
  7. package/examples/plugins/openai-compatible.js +27 -0
  8. package/package.json +53 -0
  9. package/src/canonical/types.ts +132 -0
  10. package/src/commands/add.ts +159 -0
  11. package/src/commands/daemon.ts +102 -0
  12. package/src/commands/ls.ts +49 -0
  13. package/src/commands/model.ts +95 -0
  14. package/src/commands/plugin.ts +167 -0
  15. package/src/commands/rename.ts +33 -0
  16. package/src/commands/rm.ts +37 -0
  17. package/src/commands/serve.ts +29 -0
  18. package/src/commands/shared.ts +14 -0
  19. package/src/commands/show.ts +46 -0
  20. package/src/commands/tier.ts +91 -0
  21. package/src/convert/common/content-blocks.ts +29 -0
  22. package/src/convert/common/extras.ts +38 -0
  23. package/src/convert/common/reasoning.ts +19 -0
  24. package/src/convert/common/system-prompt.ts +15 -0
  25. package/src/convert/common/tool-calls.ts +29 -0
  26. package/src/convert/common/usage.ts +19 -0
  27. package/src/convert/inbound/anthropic-to-canonical.ts +106 -0
  28. package/src/convert/inbound/chat-to-canonical.ts +132 -0
  29. package/src/convert/inbound/responses-to-canonical.ts +92 -0
  30. package/src/convert/outbound/canonical-to-anthropic.ts +62 -0
  31. package/src/convert/outbound/canonical-to-chat.ts +101 -0
  32. package/src/convert/outbound/canonical-to-responses.ts +105 -0
  33. package/src/convert/streaming/inbound/anthropic-stream.ts +14 -0
  34. package/src/convert/streaming/inbound/chat-stream.ts +219 -0
  35. package/src/convert/streaming/inbound/pick.ts +21 -0
  36. package/src/convert/streaming/inbound/responses-stream.ts +276 -0
  37. package/src/convert/streaming/outbound/format-anthropic.ts +19 -0
  38. package/src/convert/streaming/outbound/format-chat.ts +133 -0
  39. package/src/convert/streaming/outbound/format-responses.ts +184 -0
  40. package/src/convert/upstream/canonical-to-anthropic.ts +111 -0
  41. package/src/convert/upstream/canonical-to-chat.ts +115 -0
  42. package/src/convert/upstream/canonical-to-responses.ts +123 -0
  43. package/src/core/config.ts +156 -0
  44. package/src/core/model-fetch.ts +124 -0
  45. package/src/core/resolve.ts +73 -0
  46. package/src/core/routing.ts +31 -0
  47. package/src/core/source.ts +28 -0
  48. package/src/daemon/install.ts +47 -0
  49. package/src/daemon/platform/linux.ts +65 -0
  50. package/src/daemon/platform/macos.ts +71 -0
  51. package/src/daemon/platform/windows.ts +70 -0
  52. package/src/daemon/start.ts +22 -0
  53. package/src/daemon/status.ts +19 -0
  54. package/src/daemon/stop.ts +58 -0
  55. package/src/index.ts +34 -0
  56. package/src/plugin/contract.ts +51 -0
  57. package/src/plugin/host.ts +27 -0
  58. package/src/plugin/loader.ts +55 -0
  59. package/src/plugin/sandbox.ts +3 -0
  60. package/src/providers/presets.ts +167 -0
  61. package/src/server/anthropic-parser.ts +44 -0
  62. package/src/server/cancelable-fetch.ts +21 -0
  63. package/src/server/chat-parser.ts +81 -0
  64. package/src/server/error-status.ts +18 -0
  65. package/src/server/error.ts +16 -0
  66. package/src/server/handlers/chat-completions.ts +94 -0
  67. package/src/server/handlers/messages.ts +89 -0
  68. package/src/server/handlers/models.ts +35 -0
  69. package/src/server/handlers/responses.ts +89 -0
  70. package/src/server/keepalive.ts +63 -0
  71. package/src/server/responses-parser.ts +62 -0
  72. package/src/server/serve.ts +79 -0
  73. package/src/server/sse.ts +61 -0
  74. package/src/server/upstream.ts +251 -0
  75. package/src/tier/builtin.ts +9 -0
  76. package/src/tier/resolve.ts +33 -0
  77. package/src/tier/store.ts +3 -0
  78. package/src/types.ts +94 -0
  79. package/src/ui/format.ts +44 -0
  80. package/src/ui/prompts.ts +34 -0
  81. package/src/utils/fuzzy.ts +48 -0
  82. package/src/utils/logger.ts +32 -0
  83. package/src/utils/paths.ts +48 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cha133
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.md ADDED
@@ -0,0 +1,135 @@
1
+ # cctra
2
+
3
+ > Local LLM subscription protocol converter + plugin host
4
+
5
+ `cctra` runs a daemon on `127.0.0.1:3133` that translates between **OpenAI Chat Completions / OpenAI Responses / Anthropic Messages**, with a **tier-based model aliasing** system and **local-path plugin** support for non-standard upstream auth (OAuth, mTLS, etc.).
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ # install (once)
11
+ bun add -g cctra
12
+ # or npm i -g cctra
13
+
14
+ # add a subscription (interactive)
15
+ cctra add
16
+
17
+ # start the daemon (foreground)
18
+ cctra serve
19
+
20
+ # or install as system startup item (Windows / macOS / Linux)
21
+ cctra daemon install
22
+ cctra daemon start
23
+ ```
24
+
25
+ ## Endpoints
26
+
27
+ cctra exposes exactly **3 protocol endpoints** on `127.0.0.1:3133`:
28
+
29
+ | Protocol | Path |
30
+ |---|---|
31
+ | Anthropic Messages | `POST /anthropic/v1/messages` |
32
+ | OpenAI Chat Completions | `POST /v1/chat/completions` |
33
+ | OpenAI Responses | `POST /v1/responses` |
34
+ | OpenAI Models | `GET /v1/models` |
35
+ | Health | `GET /healthz` |
36
+
37
+ ### Client configuration
38
+
39
+ ⚠️ **baseURL must include the protocol namespace prefix**:
40
+
41
+ | Client | baseURL |
42
+ |---|---|
43
+ | Claude Code | `http://127.0.0.1:3133/anthropic` |
44
+ | OpenAI SDK / Codex / Cursor | `http://127.0.0.1:3133/v1` |
45
+
46
+ ## Tier aliases
47
+
48
+ cctra ships 4 built-in semantic tier names that you map to concrete `(subscription, model)` pairs:
49
+
50
+ | Tier | Purpose |
51
+ |---|---|
52
+ | `cctra` | Default (medium quality, cheap) |
53
+ | `cctra-pro` | Deep reasoning (slow but strong) |
54
+ | `cctra-flash` | High speed (small & fast) |
55
+ | `cctra-vision` | Multimodal |
56
+
57
+ ```bash
58
+ # map cctra-pro to your actual deepseek model
59
+ cctra tier set cctra-pro ark-agent-plan/deepseek-v4-pro
60
+
61
+ # now configure Claude Code to use it (won't change when you switch models)
62
+ # ANTHROPIC_MODEL=cctra-pro
63
+ ```
64
+
65
+ ## Plugin system
66
+
67
+ Add custom JS plugins for non-standard upstream auth:
68
+
69
+ ```bash
70
+ cctra plugin add my-internal /path/to/my-internal.js
71
+ cctra plugin ls
72
+ cctra plugin enable my-internal
73
+ cctra plugin disable my-internal
74
+ cctra plugin rm my-internal
75
+ ```
76
+
77
+ A plugin exports:
78
+
79
+ ```js
80
+ export default {
81
+ name: "my-internal-llm",
82
+ displayName: "My Company LLM",
83
+ async getConfig(ctx) {
84
+ // OAuth / mTLS / custom header logic
85
+ return { baseUrl: "...", path: "/v1/chat/completions", apiFormat: "openai-chat", authHeader: { /* ... */ }, modelId: "..." };
86
+ },
87
+ async listModels(ctx) { return [{ id: "..." }, { id: "..." }]; },
88
+ };
89
+ ```
90
+
91
+ See `examples/plugins/` for working examples.
92
+
93
+ ## CLI
94
+
95
+ ```
96
+ cctra add # interactive subscription wizard
97
+ cctra ls # list all sources
98
+ cctra show <name> # show details
99
+ cctra rm <name> # remove
100
+ cctra rename <old> <new> # rename
101
+ cctra model add <sub> # add model to a subscription
102
+ cctra model ls <sub> # list models
103
+ cctra model rm <sub> <m> # remove model
104
+ cctra model rename <sub> <m> <alias>
105
+ cctra plugin add <name> <path>
106
+ cctra plugin ls / show / enable / disable / rm
107
+ cctra tier set <name> <target>
108
+ cctra tier ls / show / rm
109
+ cctra daemon install / uninstall / start / stop / status
110
+ cctra serve [--port N] # foreground HTTP server
111
+ ```
112
+
113
+ ## Configuration
114
+
115
+ Persisted at `~/.cctra/config.toml` (TOML format, edited via CLI).
116
+
117
+ Plugin configs go in `~/.cctra/plugins/<name>/config.json`.
118
+
119
+ ## Architecture
120
+
121
+ - `src/canonical/` — protocol-agnostic internal types
122
+ - `src/convert/` — bidirectional protocol conversions
123
+ - `src/server/` — Bun.serve() routes, upstream forwarding
124
+ - `src/plugin/` — local-path plugin loader + author contract
125
+ - `src/tier/` — 4 builtin tier system
126
+ - `src/daemon/` — cross-platform install (Windows registry / macOS LaunchAgent / Linux systemd)
127
+ - `launcher/` — tiny Rust .exe for Windows startup (hides console, registers in Task Manager)
128
+
129
+ ## Credits
130
+
131
+ The vendor preset list (`src/providers/presets.ts`) — provider names and endpoint URLs — is derived from [cc-switch](https://github.com/farion1231/cc-switch) (MIT, Copyright (c) 2025 Jason Young). Thanks to Jason and the cc-switch contributors for maintaining this comprehensive registry.
132
+
133
+ ## License
134
+
135
+ MIT — see [LICENSE](LICENSE).
package/bin/cctra ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import("../src/index.ts");
Binary file
package/bin/cctra.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import("../src/index.ts");
@@ -0,0 +1,46 @@
1
+ // ============================================================================
2
+ // 示例插件:内部公司服务,OAuth 鉴权
3
+ // 演示 token 缓存、refresh、ctx.fetch 用法
4
+ // ============================================================================
5
+
6
+ export default {
7
+ name: "oauth-internal",
8
+ displayName: "Internal OAuth Service",
9
+
10
+ async getConfig(ctx) {
11
+ const { clientId, clientSecret, baseUrl, modelIds, workspaceId } = ctx.config;
12
+
13
+ // 缓存 token(避免每次请求都重新拿)
14
+ let tokenData = await ctx.cacheGet("oauth-token");
15
+ if (!tokenData) {
16
+ tokenData = await refreshToken(clientId, clientSecret);
17
+ // token 提前 60s 过期,留缓冲
18
+ await ctx.cacheSet("oauth-token", tokenData, (tokenData.expires_in - 60) * 1000);
19
+ ctx.logger(`refreshed OAuth token, expires in ${tokenData.expires_in}s`);
20
+ }
21
+
22
+ return modelIds.map((id) => ({
23
+ baseUrl: baseUrl.replace(/\/+$/, ""),
24
+ path: "/v1/chat/completions",
25
+ apiFormat: "openai-chat",
26
+ authHeader: {
27
+ Authorization: `Bearer ${tokenData.access_token}`,
28
+ "X-Workspace-Id": workspaceId,
29
+ },
30
+ modelId: id,
31
+ }));
32
+ },
33
+
34
+ async listModels(ctx) {
35
+ return ctx.config.modelIds.map((id) => ({ id }));
36
+ },
37
+ };
38
+
39
+ async function refreshToken(_clientId, _clientSecret) {
40
+ // v1 示例:实际应调用 OAuth 端点
41
+ // 这里模拟一个 token
42
+ return {
43
+ access_token: "simulated-token-" + Date.now(),
44
+ expires_in: 3600,
45
+ };
46
+ }
@@ -0,0 +1,27 @@
1
+ // ============================================================================
2
+ // 示例插件:标准 OpenAI 兼容端点(无鉴权)
3
+ // 复制到 ~/.cctra/plugins/openai-compatible.js 后用 `cctra plugin add` 注册
4
+ // ============================================================================
5
+
6
+ export default {
7
+ name: "openai-compatible",
8
+ displayName: "OpenAI-Compatible Endpoint",
9
+
10
+ async getConfig(ctx) {
11
+ // 用户填的 config(来自 cctra plugin add 时输入)
12
+ const { baseUrl, token, modelIds } = ctx.config;
13
+
14
+ return modelIds.map((id) => ({
15
+ baseUrl: baseUrl.replace(/\/+$/, ""),
16
+ path: "/v1/chat/completions",
17
+ apiFormat: "openai-chat",
18
+ authHeader: token ? { Authorization: `Bearer ${token}` } : {},
19
+ modelId: id,
20
+ }));
21
+ },
22
+
23
+ async listModels(ctx) {
24
+ const { modelIds } = ctx.config;
25
+ return modelIds.map((id) => ({ id, alias: id }));
26
+ },
27
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "cctra",
3
+ "version": "0.3.0",
4
+ "description": "Local LLM subscription protocol converter + plugin host. Run a daemon on 127.0.0.1:3133 that translates between OpenAI Chat / OpenAI Responses / Anthropic Messages, with a tier-based model aliasing system and local-path plugin support.",
5
+ "type": "module",
6
+ "bin": {
7
+ "cctra": "bin/cctra.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "examples/",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "dev": "bun run src/index.ts",
18
+ "serve": "bun run src/index.ts serve",
19
+ "test": "bun test",
20
+ "typecheck": "bunx tsc --noEmit",
21
+ "verify": "bunx tsc --noEmit && bun test",
22
+ "publish": "pwsh -ExecutionPolicy Bypass -File scripts/publish.ps1"
23
+ },
24
+ "engines": {
25
+ "bun": ">=1.0.0"
26
+ },
27
+ "keywords": [
28
+ "llm",
29
+ "proxy",
30
+ "openai",
31
+ "anthropic",
32
+ "claude-code",
33
+ "codex",
34
+ "translation",
35
+ "plugin"
36
+ ],
37
+ "license": "MIT",
38
+ "author": "cha133",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/cha133/cctra.git"
42
+ },
43
+ "dependencies": {
44
+ "@clack/prompts": "^1.5.0",
45
+ "commander": "^15.0.0",
46
+ "confbox": "^0.2.4",
47
+ "picocolors": "^1.1.1"
48
+ },
49
+ "devDependencies": {
50
+ "@types/bun": "latest",
51
+ "typescript": "^5"
52
+ }
53
+ }
@@ -0,0 +1,132 @@
1
+ // ============================================================================
2
+ // Canonical 内部表示:与具体协议解耦的统一数据结构
3
+ // shape 接近 Anthropic Messages(因为它表达能力最丰富)
4
+ // ============================================================================
5
+
6
+ export type ApiFormat = "openai-chat" | "openai-responses" | "anthropic-messages";
7
+
8
+ export type StopReason = "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | "error";
9
+
10
+ // ---------- Per-protocol extras(透传未识别字段) ----------
11
+
12
+ /**
13
+ * 按协议分类的「未识别字段」桶。
14
+ * - inbound 时把协议原请求中未识别的字段塞进对应协议桶
15
+ * - outbound 时按目标协议把对应桶 spread 进结果对象
16
+ * 例:anthropic → canonical → anthropic 链路里 cache_control 字段不会丢失
17
+ */
18
+ export interface ProtocolExtras {
19
+ anthropic?: Record<string, unknown>;
20
+ openaiChat?: Record<string, unknown>;
21
+ openaiResponses?: Record<string, unknown>;
22
+ }
23
+
24
+ // ---------- Request ----------
25
+
26
+ export interface CanonicalRequest {
27
+ model: string;
28
+ messages: CanonicalMessage[];
29
+ system?: string | CanonicalContentBlock[];
30
+ tools?: CanonicalTool[];
31
+ maxTokens?: number;
32
+ temperature?: number;
33
+ topP?: number;
34
+ stopSequences?: string[];
35
+ stream: boolean;
36
+ metadata?: Record<string, unknown>;
37
+ // OpenAI Responses 多轮链路 ID(cctra 仅做透传,不维护链路状态)
38
+ previousResponseId?: string;
39
+ // 思考强度(OpenAI Responses `reasoning.effort` / Anthropic `thinking.budget_tokens` 的统一抽象)
40
+ reasoning?: { effort?: "low" | "medium" | "high" };
41
+ // 透传未识别字段(按协议分类)
42
+ extras?: ProtocolExtras;
43
+ }
44
+
45
+ export interface CanonicalMessage {
46
+ role: "user" | "assistant";
47
+ content: CanonicalContentBlock[];
48
+ extras?: ProtocolExtras;
49
+ }
50
+
51
+ export type CanonicalContentBlock = (
52
+ | { type: "text"; text: string }
53
+ | { type: "image"; source: ImageSource }
54
+ | { type: "document"; source: DocumentSource }
55
+ | { type: "tool_use"; id: string; name: string; input: unknown }
56
+ | { type: "tool_result"; toolUseId: string; content: string | CanonicalContentBlock[]; isError?: boolean }
57
+ | { type: "thinking"; thinking: string; signature?: string }
58
+ | { type: "refusal"; refusal: string }
59
+ ) & { extras?: ProtocolExtras };
60
+
61
+ export interface ImageSource {
62
+ kind: "url" | "base64";
63
+ mediaType: string; // e.g. "image/png"
64
+ data: string; // URL string or base64-encoded data
65
+ }
66
+
67
+ export interface DocumentSource {
68
+ kind: "url" | "base64";
69
+ mediaType: string;
70
+ data: string;
71
+ }
72
+
73
+ export interface CanonicalTool {
74
+ name: string;
75
+ description?: string;
76
+ inputSchema: Record<string, unknown>;
77
+ }
78
+
79
+ // ---------- Response ----------
80
+
81
+ /**
82
+ * 错误响应的结构化字段。
83
+ * - `message`:人类可读错误消息
84
+ * - `status`:上游 HTTP status code(4xx/5xx 透传用;plugin/network/parse 错无此字段)
85
+ * - `type`:错误分类标签
86
+ */
87
+ export interface CanonicalResponseError {
88
+ message: string;
89
+ status?: number;
90
+ type?: "upstream_error" | "network_error" | "plugin_error" | "parse_error";
91
+ }
92
+
93
+ export interface CanonicalResponse {
94
+ id: string;
95
+ model: string;
96
+ content: CanonicalContentBlock[];
97
+ stopReason: StopReason;
98
+ usage: CanonicalUsage;
99
+ /** 错误响应的结构化字段(替代过去用 `content[0].text` + `stopReason: "error"` 表达错误) */
100
+ error?: CanonicalResponseError;
101
+ }
102
+
103
+ export interface CanonicalUsage {
104
+ inputTokens: number;
105
+ outputTokens: number;
106
+ cacheReadTokens?: number;
107
+ cacheWriteTokens?: number;
108
+ }
109
+
110
+ // ---------- Streaming Chunks ----------
111
+ // 字段跟 Anthropic SSE 几乎一致,方便直接复用 streaming 状态机
112
+
113
+ export type CanonicalChunk =
114
+ | { type: "message_start"; message: CanonicalResponse }
115
+ | { type: "content_block_start"; index: number; content_block: CanonicalContentBlock }
116
+ | { type: "content_block_delta"; index: number; delta: ContentBlockDelta }
117
+ | { type: "content_block_stop"; index: number }
118
+ | { type: "message_delta"; delta: MessageDelta; usage?: Partial<CanonicalUsage> }
119
+ | { type: "message_stop" }
120
+ | { type: "ping" }
121
+ | { type: "error"; error: string };
122
+
123
+ export type ContentBlockDelta =
124
+ | { type: "text_delta"; text: string }
125
+ | { type: "input_json_delta"; partial_json: string }
126
+ | { type: "thinking_delta"; thinking: string }
127
+ | { type: "signature_delta"; signature: string };
128
+
129
+ export interface MessageDelta {
130
+ stop_reason?: StopReason;
131
+ stop_sequence?: string;
132
+ }
@@ -0,0 +1,159 @@
1
+ // ============================================================================
2
+ // cctra add:交互式添加订阅
3
+ // ============================================================================
4
+ import * as p from "@clack/prompts";
5
+ import { Command } from "commander";
6
+ import { checkCancel } from "../ui/prompts";
7
+ import { success, error as errorOut, info } from "../ui/format";
8
+ import { withConfig } from "./shared";
9
+ import { addSubscription } from "../core/config";
10
+ import { fetchUpstreamModels } from "../core/model-fetch";
11
+ import {
12
+ API_FORMAT_LABELS,
13
+ getEndpointForFormat,
14
+ getPresetHint,
15
+ getSupportedApiFormats,
16
+ getVendorChoices,
17
+ generateProfileName,
18
+ NO_VENDOR,
19
+ type ProviderPreset,
20
+ } from "../providers/presets";
21
+ import type { Subscription, ApiFormat } from "../types";
22
+
23
+ export function registerAdd(program: Command): void {
24
+ program
25
+ .command("add")
26
+ .description("Interactively add a subscription")
27
+ .action(async () => {
28
+ try {
29
+ const sub = await promptNewSubscription();
30
+ withConfig((config) => addSubscription(config, sub));
31
+ success(`Added subscription "${sub.name}" with ${sub.models.length} model(s).`);
32
+ info(`Run \`cctra serve\` to start the daemon.`);
33
+ } catch (e) {
34
+ if ((e as Error).message.includes("cancelled")) return;
35
+ errorOut((e as Error).message);
36
+ process.exit(1);
37
+ }
38
+ });
39
+ }
40
+
41
+ async function promptNewSubscription(): Promise<Subscription> {
42
+ // 1. Vendor(可跳过 → 走纯手输)
43
+ const vendor = checkCancel(
44
+ await p.autocomplete<ProviderPreset>({
45
+ message: "Select a vendor (type to search, or pick '(不使用供应商)' for custom):",
46
+ options: getVendorChoices().map((v) => ({
47
+ value: v,
48
+ label: v.name,
49
+ hint: getPresetHint(v),
50
+ })),
51
+ placeholder: "Type to filter vendors...",
52
+ }),
53
+ );
54
+ const isCustom = vendor.name === NO_VENDOR.name;
55
+
56
+ // 2. 名称(vendor 选中时自动从 vendor.name 生成)
57
+ const defaultName = isCustom ? "" : generateProfileName(vendor.name);
58
+ const name = checkCancel(
59
+ await p.text({
60
+ message: "Subscription name:",
61
+ initialValue: defaultName,
62
+ placeholder: "e.g. ark-agent-plan, deepseek",
63
+ validate: (v) => {
64
+ if (!v?.trim()) return "Name is required.";
65
+ const n = v.trim().toLowerCase();
66
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(n)) return 'Use kebab-case: lowercase letters, digits, hyphens.';
67
+ return undefined;
68
+ },
69
+ }),
70
+ );
71
+
72
+ // 3. 协议(vendor 选中时只显示该 preset 支持的协议)
73
+ const supportedFormats = getSupportedApiFormats(vendor);
74
+ const apiFormat = checkCancel(
75
+ await p.select<ApiFormat>({
76
+ message: "Upstream API format:",
77
+ initialValue: supportedFormats[0],
78
+ options: supportedFormats.map((format) => ({
79
+ value: format,
80
+ label: API_FORMAT_LABELS[format],
81
+ })),
82
+ }),
83
+ );
84
+
85
+ // 4. Endpoint(vendor 选中时按协议预填)
86
+ const endpoint = checkCancel(
87
+ await p.text({
88
+ message: "Endpoint URL (root, no /v1 suffix):",
89
+ initialValue: getEndpointForFormat(vendor, apiFormat),
90
+ placeholder: "e.g. https://ark.cn-beijing.volces.com/api/plan",
91
+ validate: (v) => (!v?.trim() ? "Endpoint is required." : undefined),
92
+ }),
93
+ );
94
+
95
+ // 4.5 提示 vendor 备注(如有)
96
+ if (vendor.notes && !isCustom) {
97
+ info(`Note: ${vendor.notes}`);
98
+ }
99
+
100
+ // 5. Token
101
+ const token = checkCancel(
102
+ await p.password({
103
+ message: "API key / token:",
104
+ validate: (v) => (!v?.trim() ? "Token is required." : undefined),
105
+ }),
106
+ );
107
+
108
+ // 6. 拉模型列表
109
+ const s = p.spinner();
110
+ s.start("Fetching model list from upstream...");
111
+ let modelNames: string[] = [];
112
+ try {
113
+ modelNames = await fetchUpstreamModels({
114
+ endpoint: endpoint.trim(),
115
+ token: token.trim(),
116
+ apiFormat,
117
+ });
118
+ s.stop(`Found ${modelNames.length} model(s).`);
119
+ } catch {
120
+ s.stop("Failed to fetch models, will add manually.");
121
+ }
122
+
123
+ // 7. 选模型
124
+ let selected: string[] = [];
125
+ if (modelNames.length > 0) {
126
+ const result = checkCancel(
127
+ await p.multiselect({
128
+ message: "Select models to add:",
129
+ options: modelNames.map((m) => ({ value: m, label: m })),
130
+ required: false,
131
+ }),
132
+ );
133
+ selected = result as string[];
134
+ }
135
+
136
+ if (selected.length === 0) {
137
+ // 手动输入
138
+ const manual = checkCancel(
139
+ await p.text({
140
+ message: "Enter model IDs (comma-separated):",
141
+ placeholder: "e.g. deepseek-v4-pro, claude-sonnet-4-6",
142
+ }),
143
+ );
144
+ selected = manual.split(",").map((s) => s.trim()).filter(Boolean);
145
+ }
146
+
147
+ return {
148
+ kind: "subscription",
149
+ vendor: isCustom ? undefined : vendor.name,
150
+ name: name.trim().toLowerCase(),
151
+ endpoint: endpoint.trim(),
152
+ token: token.trim(),
153
+ apiFormat,
154
+ ...(apiFormat === "openai-responses" ? { responsesPath: "/v1/responses" } : {}),
155
+ models: selected.map((id) => ({ id })),
156
+ createdAt: Date.now(),
157
+ updatedAt: Date.now(),
158
+ };
159
+ }
@@ -0,0 +1,102 @@
1
+ // ============================================================================
2
+ // cctra daemon <subcommand>:守护进程管理
3
+ // install / uninstall / start / stop / status
4
+ // ============================================================================
5
+ import { Command } from "commander";
6
+ import { install, uninstall, isInstalled } from "../daemon/install";
7
+ import { checkDaemonStatus } from "../daemon/status";
8
+ import { startDaemon } from "../daemon/start";
9
+ import { stopDaemon } from "../daemon/stop";
10
+ import { success, error as errorOut, info, dim, green, red } from "../ui/format";
11
+ import { fileURLToPath } from "node:url";
12
+ import { dirname, join } from "node:path";
13
+ import { existsSync } from "node:fs";
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const DAEMON_ENTRYPOINT = join(__dirname, "..", "index.ts");
17
+
18
+ export function registerDaemon(program: Command): void {
19
+ const daemon = program.command("daemon").description("Manage the cctra daemon");
20
+
21
+ // cctra daemon install
22
+ daemon
23
+ .command("install")
24
+ .description("Install daemon as a system startup item")
25
+ .action(() => {
26
+ try {
27
+ const bundledLauncher = process.platform === "win32"
28
+ ? join(__dirname, "..", "..", "bin", "cctra-daemon.exe")
29
+ : undefined;
30
+ if (process.platform === "win32" && (!bundledLauncher || !existsSync(bundledLauncher))) {
31
+ errorOut(`Bundled launcher not found: ${bundledLauncher}`);
32
+ errorOut("Build it first with scripts/build-launcher.ps1");
33
+ return;
34
+ }
35
+ install({ bundledLauncherPath: bundledLauncher, daemonEntrypoint: DAEMON_ENTRYPOINT });
36
+ success(`Installed cctra daemon for ${process.platform}.`);
37
+ } catch (e) {
38
+ errorOut((e as Error).message);
39
+ process.exit(1);
40
+ }
41
+ });
42
+
43
+ // cctra daemon uninstall
44
+ daemon
45
+ .command("uninstall")
46
+ .description("Remove daemon from system startup")
47
+ .action(() => {
48
+ try {
49
+ uninstall();
50
+ success(`Uninstalled cctra daemon.`);
51
+ } catch (e) {
52
+ errorOut((e as Error).message);
53
+ process.exit(1);
54
+ }
55
+ });
56
+
57
+ // cctra daemon start
58
+ daemon
59
+ .command("start")
60
+ .description("Start the daemon (background)")
61
+ .action(async () => {
62
+ try {
63
+ await startDaemon();
64
+ success(`Started.`);
65
+ } catch (e) {
66
+ errorOut((e as Error).message);
67
+ process.exit(1);
68
+ }
69
+ });
70
+
71
+ // cctra daemon stop
72
+ daemon
73
+ .command("stop")
74
+ .description("Stop the daemon")
75
+ .action(async () => {
76
+ try {
77
+ await stopDaemon();
78
+ success(`Stopped.`);
79
+ } catch (e) {
80
+ errorOut((e as Error).message);
81
+ process.exit(1);
82
+ }
83
+ });
84
+
85
+ // cctra daemon status
86
+ daemon
87
+ .command("status")
88
+ .description("Check daemon status")
89
+ .action(async () => {
90
+ const status = await checkDaemonStatus();
91
+ const installed = isInstalled();
92
+ const runningIcon = status.running ? green("✓ running") : red("✗ not running");
93
+ const installedIcon = installed ? green("✓ installed") : red("✗ not installed");
94
+ console.log(`Daemon: ${runningIcon}`);
95
+ console.log(`Startup: ${installedIcon}`);
96
+ console.log(`Port: ${status.port}`);
97
+ console.log(dim(`Health: http://127.0.0.1:${status.port}/healthz`));
98
+ if (!status.running && !installed) {
99
+ info(`Run \`cctra daemon install\` to register as a startup item, or \`cctra serve\` to run in foreground.`);
100
+ }
101
+ });
102
+ }