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.
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/bin/cctra +2 -0
- package/bin/cctra-daemon.exe +0 -0
- package/bin/cctra.js +2 -0
- package/examples/plugins/oauth-internal.js +46 -0
- package/examples/plugins/openai-compatible.js +27 -0
- package/package.json +53 -0
- package/src/canonical/types.ts +132 -0
- package/src/commands/add.ts +159 -0
- package/src/commands/daemon.ts +102 -0
- package/src/commands/ls.ts +49 -0
- package/src/commands/model.ts +95 -0
- package/src/commands/plugin.ts +167 -0
- package/src/commands/rename.ts +33 -0
- package/src/commands/rm.ts +37 -0
- package/src/commands/serve.ts +29 -0
- package/src/commands/shared.ts +14 -0
- package/src/commands/show.ts +46 -0
- package/src/commands/tier.ts +91 -0
- package/src/convert/common/content-blocks.ts +29 -0
- package/src/convert/common/extras.ts +38 -0
- package/src/convert/common/reasoning.ts +19 -0
- package/src/convert/common/system-prompt.ts +15 -0
- package/src/convert/common/tool-calls.ts +29 -0
- package/src/convert/common/usage.ts +19 -0
- package/src/convert/inbound/anthropic-to-canonical.ts +106 -0
- package/src/convert/inbound/chat-to-canonical.ts +132 -0
- package/src/convert/inbound/responses-to-canonical.ts +92 -0
- package/src/convert/outbound/canonical-to-anthropic.ts +62 -0
- package/src/convert/outbound/canonical-to-chat.ts +101 -0
- package/src/convert/outbound/canonical-to-responses.ts +105 -0
- package/src/convert/streaming/inbound/anthropic-stream.ts +14 -0
- package/src/convert/streaming/inbound/chat-stream.ts +219 -0
- package/src/convert/streaming/inbound/pick.ts +21 -0
- package/src/convert/streaming/inbound/responses-stream.ts +276 -0
- package/src/convert/streaming/outbound/format-anthropic.ts +19 -0
- package/src/convert/streaming/outbound/format-chat.ts +133 -0
- package/src/convert/streaming/outbound/format-responses.ts +184 -0
- package/src/convert/upstream/canonical-to-anthropic.ts +111 -0
- package/src/convert/upstream/canonical-to-chat.ts +115 -0
- package/src/convert/upstream/canonical-to-responses.ts +123 -0
- package/src/core/config.ts +156 -0
- package/src/core/model-fetch.ts +124 -0
- package/src/core/resolve.ts +73 -0
- package/src/core/routing.ts +31 -0
- package/src/core/source.ts +28 -0
- package/src/daemon/install.ts +47 -0
- package/src/daemon/platform/linux.ts +65 -0
- package/src/daemon/platform/macos.ts +71 -0
- package/src/daemon/platform/windows.ts +70 -0
- package/src/daemon/start.ts +22 -0
- package/src/daemon/status.ts +19 -0
- package/src/daemon/stop.ts +58 -0
- package/src/index.ts +34 -0
- package/src/plugin/contract.ts +51 -0
- package/src/plugin/host.ts +27 -0
- package/src/plugin/loader.ts +55 -0
- package/src/plugin/sandbox.ts +3 -0
- package/src/providers/presets.ts +167 -0
- package/src/server/anthropic-parser.ts +44 -0
- package/src/server/cancelable-fetch.ts +21 -0
- package/src/server/chat-parser.ts +81 -0
- package/src/server/error-status.ts +18 -0
- package/src/server/error.ts +16 -0
- package/src/server/handlers/chat-completions.ts +94 -0
- package/src/server/handlers/messages.ts +89 -0
- package/src/server/handlers/models.ts +35 -0
- package/src/server/handlers/responses.ts +89 -0
- package/src/server/keepalive.ts +63 -0
- package/src/server/responses-parser.ts +62 -0
- package/src/server/serve.ts +79 -0
- package/src/server/sse.ts +61 -0
- package/src/server/upstream.ts +251 -0
- package/src/tier/builtin.ts +9 -0
- package/src/tier/resolve.ts +33 -0
- package/src/tier/store.ts +3 -0
- package/src/types.ts +94 -0
- package/src/ui/format.ts +44 -0
- package/src/ui/prompts.ts +34 -0
- package/src/utils/fuzzy.ts +48 -0
- package/src/utils/logger.ts +32 -0
- package/src/utils/paths.ts +48 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// 占位:从上游拉模型列表(带 3 层缓存:内存 → 磁盘 → 网络)
|
|
2
|
+
// 完整实现会照搬 ccswi/src/models/api.ts 的 getAllModelNames 逻辑
|
|
3
|
+
// v1 先用空实现,CLI add 时手动填模型 ID 也行
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { modelsCachePath, ensureCctraDir } from "../utils/paths";
|
|
7
|
+
import type { ApiFormat } from "../types";
|
|
8
|
+
|
|
9
|
+
export interface FetchModelsOptions {
|
|
10
|
+
endpoint: string;
|
|
11
|
+
token: string;
|
|
12
|
+
apiFormat: ApiFormat;
|
|
13
|
+
modelsPath?: string; // 默认 "/v1/models"
|
|
14
|
+
ttlMs?: number; // 默认 24h
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ModelCacheEntry {
|
|
18
|
+
models: string[];
|
|
19
|
+
expiresAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const memoryCache = new Map<string, ModelCacheEntry>();
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24h
|
|
25
|
+
const OPENROUTER_FALLBACK = "https://openrouter.ai/api/v1/models";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 从上游拉模型列表(带 3 层缓存 + OpenRouter fallback)
|
|
29
|
+
* 1. 试上游 endpoint 的 /v1/models
|
|
30
|
+
* 2. 失败 → fallback 到 OpenRouter(去 :free 后缀 + 去 provider 前缀)
|
|
31
|
+
* 3. 网络都失败 → 返回空数组(add wizard 会用手动输入 fallback)
|
|
32
|
+
*/
|
|
33
|
+
export async function fetchUpstreamModels(opts: FetchModelsOptions): Promise<string[]> {
|
|
34
|
+
const ttl = opts.ttlMs ?? DEFAULT_TTL;
|
|
35
|
+
const path = opts.modelsPath ?? "/v1/models";
|
|
36
|
+
const key = `${opts.endpoint}|${path}`;
|
|
37
|
+
|
|
38
|
+
// L1: 内存
|
|
39
|
+
const mem = memoryCache.get(key);
|
|
40
|
+
if (mem && mem.expiresAt > Date.now()) return mem.models;
|
|
41
|
+
|
|
42
|
+
// L2: 磁盘
|
|
43
|
+
ensureCctraDir();
|
|
44
|
+
const cachePath = modelsCachePath();
|
|
45
|
+
if (existsSync(cachePath)) {
|
|
46
|
+
try {
|
|
47
|
+
const disk = JSON.parse(readFileSync(cachePath, "utf-8")) as Record<string, ModelCacheEntry>;
|
|
48
|
+
const entry = disk[key];
|
|
49
|
+
if (entry && entry.expiresAt > Date.now()) {
|
|
50
|
+
memoryCache.set(key, entry);
|
|
51
|
+
return entry.models;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore disk cache error
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// L3: 网络 — 先试上游
|
|
59
|
+
const url = joinUrl(opts.endpoint, path);
|
|
60
|
+
const headers: Record<string, string> = {};
|
|
61
|
+
if (opts.token) headers["Authorization"] = `Bearer ${opts.token}`;
|
|
62
|
+
|
|
63
|
+
let models = await tryFetchModels(url, headers);
|
|
64
|
+
|
|
65
|
+
// L4: Fallback 到 OpenRouter
|
|
66
|
+
if (models.length === 0) {
|
|
67
|
+
const fallback = await tryFetchModels(OPENROUTER_FALLBACK, {});
|
|
68
|
+
models = sanitizeOpenRouterModels(fallback);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 回写缓存
|
|
72
|
+
const entry: ModelCacheEntry = { models, expiresAt: Date.now() + ttl };
|
|
73
|
+
memoryCache.set(key, entry);
|
|
74
|
+
try {
|
|
75
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
76
|
+
let disk: Record<string, ModelCacheEntry> = {};
|
|
77
|
+
if (existsSync(cachePath)) {
|
|
78
|
+
disk = JSON.parse(readFileSync(cachePath, "utf-8")) as Record<string, ModelCacheEntry>;
|
|
79
|
+
}
|
|
80
|
+
disk[key] = entry;
|
|
81
|
+
writeFileSync(cachePath, JSON.stringify(disk, null, 2), "utf-8");
|
|
82
|
+
} catch {
|
|
83
|
+
// ignore
|
|
84
|
+
}
|
|
85
|
+
return models;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 拉单个端点的 models(无 auth header 因为 OpenRouter fallback 不带 token)
|
|
90
|
+
*/
|
|
91
|
+
async function tryFetchModels(url: string, headers: Record<string, string>): Promise<string[]> {
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
|
|
94
|
+
if (res.ok) {
|
|
95
|
+
const body = await res.json() as { data?: Array<{ id: string }> };
|
|
96
|
+
return (body.data ?? []).map((m) => m.id);
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// 网络/超时失败
|
|
100
|
+
}
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 清理 OpenRouter 返回的模型名(抄 ccswi 规则)
|
|
106
|
+
* - 去掉 :free 后缀
|
|
107
|
+
* - 去掉 provider 前缀(org/model → model)
|
|
108
|
+
*/
|
|
109
|
+
function sanitizeOpenRouterModels(models: string[]): string[] {
|
|
110
|
+
return models.flatMap((id) => {
|
|
111
|
+
if (id.endsWith(":free")) return [];
|
|
112
|
+
const slashIdx = id.indexOf("/");
|
|
113
|
+
return slashIdx > 0 ? [id.slice(slashIdx + 1)] : [id];
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function joinUrl(base: string, path: string): string {
|
|
118
|
+
const b = base.replace(/\/+$/, "");
|
|
119
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
120
|
+
// 避免 /v1/v1 重复
|
|
121
|
+
if (b.endsWith("/v1") && p.startsWith("/v1/")) return `${b}${p.slice(3)}`;
|
|
122
|
+
if (b.endsWith("/v1beta") && p.startsWith("/v1beta/")) return `${b}${p.slice(7)}`;
|
|
123
|
+
return `${b}${p}`;
|
|
124
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Config, Source, Model } from "../types";
|
|
2
|
+
import { getSource } from "./source";
|
|
3
|
+
import { resolveTier } from "../tier/resolve";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 解析模型引用字符串,返回 (Source, upstreamModelId, apiFormat)
|
|
7
|
+
*
|
|
8
|
+
* 解析优先级:
|
|
9
|
+
* 1. tier 名字(cctra / cctra-pro / cctra-flash / cctra-vision 或用户自建)
|
|
10
|
+
* 2. "sub/model" 或 "plugin/model"(按 / 拆分)
|
|
11
|
+
* 3. 全局 alias(在所有 source 的 model.alias 里找)
|
|
12
|
+
* 4. 都不匹配 → null
|
|
13
|
+
*/
|
|
14
|
+
export function resolveModelRef(
|
|
15
|
+
ref: string,
|
|
16
|
+
config: Config,
|
|
17
|
+
): { source: Source; modelId: string } | null {
|
|
18
|
+
if (!ref) return null;
|
|
19
|
+
|
|
20
|
+
const trimmed = ref.trim();
|
|
21
|
+
|
|
22
|
+
// 1. tier 名字
|
|
23
|
+
const tierResolved = resolveTier(trimmed, config);
|
|
24
|
+
if (tierResolved) return tierResolved;
|
|
25
|
+
|
|
26
|
+
// 2. "sub/model" 格式
|
|
27
|
+
if (trimmed.includes("/")) {
|
|
28
|
+
const [sourceName, modelPart] = trimmed.split("/", 2);
|
|
29
|
+
if (!sourceName || !modelPart) return null;
|
|
30
|
+
const source = getSource(config, sourceName);
|
|
31
|
+
if (!source) return null;
|
|
32
|
+
const model = findModelInSource(source, modelPart);
|
|
33
|
+
if (!model) return null;
|
|
34
|
+
return { source, modelId: model.id };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 3. 全局 alias
|
|
38
|
+
const aliasMatches: Array<{ source: Source; modelId: string }> = [];
|
|
39
|
+
for (const source of Object.values(config.subscriptions)) {
|
|
40
|
+
const m = findModelInSource(source, trimmed);
|
|
41
|
+
if (m) aliasMatches.push({ source, modelId: m.id });
|
|
42
|
+
}
|
|
43
|
+
for (const plugin of Object.values(config.plugins)) {
|
|
44
|
+
if (!plugin.enabled) continue;
|
|
45
|
+
const m = findModelInSource(plugin, trimmed);
|
|
46
|
+
if (m) aliasMatches.push({ source: plugin, modelId: m.id });
|
|
47
|
+
}
|
|
48
|
+
if (aliasMatches.length === 1) return aliasMatches[0]!;
|
|
49
|
+
if (aliasMatches.length > 1) {
|
|
50
|
+
// 多个匹配:抛错(上层包装成 400)
|
|
51
|
+
const names = aliasMatches.map((m) => `${m.source.name}/${m.modelId}`).join(", ");
|
|
52
|
+
throw new ResolveError(`Ambiguous model alias "${trimmed}". Candidates: ${names}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 在 source 的 models 列表里按 id 或 alias 找 */
|
|
59
|
+
function findModelInSource(source: Source, ref: string): Model | null {
|
|
60
|
+
return (
|
|
61
|
+
source.models.find((m) => m.id === ref) ??
|
|
62
|
+
source.models.find((m) => m.alias === ref) ??
|
|
63
|
+
null
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 解析错误(用 throw 表达歧义或不存在) */
|
|
68
|
+
export class ResolveError extends Error {
|
|
69
|
+
constructor(message: string) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "ResolveError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Config, Source, ApiFormat } from "../types";
|
|
2
|
+
import { resolveModelRef, ResolveError } from "./resolve";
|
|
3
|
+
import { getApiFormat } from "./source";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 把客户端 model 字段解析成完整的路由信息
|
|
7
|
+
* 给 HTTP handler 用
|
|
8
|
+
*/
|
|
9
|
+
export interface RouteInfo {
|
|
10
|
+
source: Source;
|
|
11
|
+
upstreamModelId: string;
|
|
12
|
+
apiFormat: ApiFormat;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveRoute(model: string, config: Config): RouteInfo {
|
|
16
|
+
let resolved: { source: Source; modelId: string } | null = null;
|
|
17
|
+
try {
|
|
18
|
+
resolved = resolveModelRef(model, config);
|
|
19
|
+
} catch (e) {
|
|
20
|
+
if (e instanceof ResolveError) throw e;
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
if (!resolved) {
|
|
24
|
+
throw new ResolveError(`Unknown model: "${model}". Use \`cctra tier set\`, \`cctra model\`, or \`sub/model\` format.`);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
source: resolved.source,
|
|
28
|
+
upstreamModelId: resolved.modelId,
|
|
29
|
+
apiFormat: getApiFormat(resolved.source),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Config, Source, Subscription, PluginConfig, ApiFormat } from "../types";
|
|
2
|
+
|
|
3
|
+
/** 把 Subscription 和 PluginConfig 都视为统一的 Source */
|
|
4
|
+
export function getAllSources(config: Config): Source[] {
|
|
5
|
+
const subs = Object.values(config.subscriptions);
|
|
6
|
+
const plugins = Object.values(config.plugins).filter((p) => p.enabled);
|
|
7
|
+
return [...subs, ...plugins];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getSource(config: Config, name: string): Source | null {
|
|
11
|
+
return config.subscriptions[name] ?? config.plugins[name] ?? null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 判断 source 是不是 plugin */
|
|
15
|
+
export function isPlugin(s: Source): s is PluginConfig {
|
|
16
|
+
return s.kind === "plugin";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 判断 source 是不是 subscription */
|
|
20
|
+
export function isSubscription(s: Source): s is Subscription {
|
|
21
|
+
return s.kind === "subscription";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Source 的 API 格式(plugin 的格式由 plugin 实例决定,先用 openai-chat 占位) */
|
|
25
|
+
export function getApiFormat(s: Source): ApiFormat {
|
|
26
|
+
if (isSubscription(s)) return s.apiFormat;
|
|
27
|
+
return "openai-chat"; // 插件默认;具体以插件 getConfig 返回的 UpstreamReady.apiFormat 为准
|
|
28
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// 平台分发:根据 process.platform 调对应的 installer
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { installWindows, isInstalledWindows, uninstallWindows } from "./platform/windows";
|
|
5
|
+
import { installMacOS, isInstalledMacOS, uninstallMacOS } from "./platform/macos";
|
|
6
|
+
import { installLinux, isInstalledLinux, uninstallLinux } from "./platform/linux";
|
|
7
|
+
|
|
8
|
+
export interface InstallOptions {
|
|
9
|
+
/** 启动器 .exe 路径(Windows 专用) */
|
|
10
|
+
bundledLauncherPath?: string;
|
|
11
|
+
/** daemon 入口 .ts 路径(macOS / Linux 用) */
|
|
12
|
+
daemonEntrypoint: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function install(opts: InstallOptions): void {
|
|
16
|
+
const platform = process.platform;
|
|
17
|
+
if (platform === "win32") {
|
|
18
|
+
if (!opts.bundledLauncherPath) {
|
|
19
|
+
throw new Error("Windows install requires bundledLauncherPath");
|
|
20
|
+
}
|
|
21
|
+
installWindows(opts.bundledLauncherPath);
|
|
22
|
+
} else if (platform === "darwin") {
|
|
23
|
+
installMacOS(opts.daemonEntrypoint);
|
|
24
|
+
} else if (platform === "linux") {
|
|
25
|
+
installLinux(opts.daemonEntrypoint);
|
|
26
|
+
} else {
|
|
27
|
+
throw new Error(`Unsupported platform: ${platform}`);
|
|
28
|
+
}
|
|
29
|
+
// 顶层不写 logger.info —— 外层 commands/daemon.ts 已有 success() 友好提示
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function uninstall(): void {
|
|
33
|
+
const platform = process.platform;
|
|
34
|
+
if (platform === "win32") uninstallWindows();
|
|
35
|
+
else if (platform === "darwin") uninstallMacOS();
|
|
36
|
+
else if (platform === "linux") uninstallLinux();
|
|
37
|
+
else throw new Error(`Unsupported platform: ${platform}`);
|
|
38
|
+
// 顶层不写 logger.info —— 外层 commands/daemon.ts 已有 success() 友好提示
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isInstalled(): boolean {
|
|
42
|
+
const platform = process.platform;
|
|
43
|
+
if (platform === "win32") return isInstalledWindows();
|
|
44
|
+
if (platform === "darwin") return isInstalledMacOS();
|
|
45
|
+
if (platform === "linux") return isInstalledLinux();
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Linux:写 ~/.config/systemd/user/cctra.service + systemctl --user enable
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { daemonLogPath } from "../../utils/paths";
|
|
9
|
+
import { info } from "../../ui/format";
|
|
10
|
+
|
|
11
|
+
const UNIT_NAME = "cctra.service";
|
|
12
|
+
const UNIT_PATH = join(homedir(), ".config", "systemd", "user", UNIT_NAME);
|
|
13
|
+
|
|
14
|
+
export function installLinux(daemonEntrypoint: string, bunPath: string = "/usr/bin/env"): void {
|
|
15
|
+
if (process.platform !== "linux") {
|
|
16
|
+
throw new Error("installLinux should only be called on Linux");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
|
|
20
|
+
const unit = generateUnit(daemonEntrypoint, bunPath);
|
|
21
|
+
writeFileSync(UNIT_PATH, unit, "utf-8");
|
|
22
|
+
info(`wrote unit to ${UNIT_PATH}`);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
execSync("systemctl --user daemon-reload", { stdio: "pipe" });
|
|
26
|
+
execSync("systemctl --user enable --now cctra.service", { stdio: "pipe" });
|
|
27
|
+
info(`enabled and started ${UNIT_NAME}`);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
throw new Error(`systemctl failed: ${(e as Error).message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function uninstallLinux(): void {
|
|
34
|
+
if (process.platform !== "linux") return;
|
|
35
|
+
try {
|
|
36
|
+
execSync("systemctl --user disable --now cctra.service", { stdio: "pipe" });
|
|
37
|
+
} catch { /* not enabled */ }
|
|
38
|
+
if (existsSync(UNIT_PATH)) unlinkSync(UNIT_PATH);
|
|
39
|
+
try {
|
|
40
|
+
execSync("systemctl --user daemon-reload", { stdio: "pipe" });
|
|
41
|
+
} catch { /* ignore */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isInstalledLinux(): boolean {
|
|
45
|
+
return process.platform === "linux" && existsSync(UNIT_PATH);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function generateUnit(daemonEntrypoint: string, _bunPath: string): string {
|
|
49
|
+
const log = daemonLogPath();
|
|
50
|
+
return `[Unit]
|
|
51
|
+
Description=cctra daemon
|
|
52
|
+
After=network.target
|
|
53
|
+
|
|
54
|
+
[Service]
|
|
55
|
+
Type=simple
|
|
56
|
+
ExecStart=/usr/bin/env bun run ${daemonEntrypoint}
|
|
57
|
+
Restart=always
|
|
58
|
+
RestartSec=3
|
|
59
|
+
StandardOutput=append:${log}
|
|
60
|
+
StandardError=append:${log}
|
|
61
|
+
|
|
62
|
+
[Install]
|
|
63
|
+
WantedBy=default.target
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// macOS:写 ~/Library/LaunchAgents/com.cctra.daemon.plist + launchctl bootstrap
|
|
3
|
+
// 完全无 UI 痕迹(plain CLI + LaunchAgent = brew services 同款)
|
|
4
|
+
// ============================================================================
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { daemonLogPath } from "../../utils/paths";
|
|
10
|
+
import { info } from "../../ui/format";
|
|
11
|
+
|
|
12
|
+
const PLIST_LABEL = "com.cctra.daemon";
|
|
13
|
+
const PLIST_PATH = join(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
|
|
14
|
+
|
|
15
|
+
export function installMacOS(daemonEntrypoint: string, bunPath: string = "/usr/bin/env"): void {
|
|
16
|
+
if (process.platform !== "darwin") {
|
|
17
|
+
throw new Error("installMacOS should only be called on macOS");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
|
|
21
|
+
const plist = generatePlist(daemonEntrypoint, bunPath);
|
|
22
|
+
writeFileSync(PLIST_PATH, plist, "utf-8");
|
|
23
|
+
info(`wrote plist to ${PLIST_PATH}`);
|
|
24
|
+
|
|
25
|
+
// 用 modern bootstrap 语法(避免 Apple Silicon deprecation warning)
|
|
26
|
+
const uid = process.getuid?.() ?? execSync("id -u").toString().trim();
|
|
27
|
+
try {
|
|
28
|
+
execSync(`launchctl bootstrap gui/${uid} "${PLIST_PATH}"`, { stdio: "pipe" });
|
|
29
|
+
info(`bootstrapped ${PLIST_LABEL}`);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
// 可能已存在,先 bootout 再 bootstrap
|
|
32
|
+
try {
|
|
33
|
+
execSync(`launchctl bootout gui/${uid}/${PLIST_LABEL}`, { stdio: "pipe" });
|
|
34
|
+
} catch { /* ignore */ }
|
|
35
|
+
execSync(`launchctl bootstrap gui/${uid} "${PLIST_PATH}"`, { stdio: "pipe" });
|
|
36
|
+
info(`bootstrapped ${PLIST_LABEL}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function uninstallMacOS(): void {
|
|
41
|
+
if (process.platform !== "darwin") return;
|
|
42
|
+
const uid = process.getuid?.() ?? execSync("id -u").toString().trim();
|
|
43
|
+
try {
|
|
44
|
+
execSync(`launchctl bootout gui/${uid}/${PLIST_LABEL}`, { stdio: "pipe" });
|
|
45
|
+
} catch { /* not loaded */ }
|
|
46
|
+
if (existsSync(PLIST_PATH)) unlinkSync(PLIST_PATH);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isInstalledMacOS(): boolean {
|
|
50
|
+
return process.platform === "darwin" && existsSync(PLIST_PATH);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function generatePlist(daemonEntrypoint: string, bunPath: string): string {
|
|
54
|
+
const log = daemonLogPath();
|
|
55
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
56
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
57
|
+
<plist version="1.0"><dict>
|
|
58
|
+
<key>Label</key><string>${PLIST_LABEL}</string>
|
|
59
|
+
<key>ProgramArguments</key>
|
|
60
|
+
<array>
|
|
61
|
+
<string>${bunPath}</string>
|
|
62
|
+
<string>bun</string>
|
|
63
|
+
<string>run</string>
|
|
64
|
+
<string>${daemonEntrypoint}</string>
|
|
65
|
+
</array>
|
|
66
|
+
<key>RunAtLoad</key><true/>
|
|
67
|
+
<key>KeepAlive</key><true/>
|
|
68
|
+
<key>StandardOutPath</key><string>${log}</string>
|
|
69
|
+
<key>StandardErrorPath</key><string>${log}</string>
|
|
70
|
+
</dict></plist>`;
|
|
71
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Windows:注册表 HKCU\Run(无 UAC)+ 拷贝 Rust 启动器到 ~/.cctra/bin/
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { ensureCctraDir, windowsLauncherPath } from "../../utils/paths";
|
|
8
|
+
import { info } from "../../ui/format";
|
|
9
|
+
|
|
10
|
+
const REG_KEY = `HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run`;
|
|
11
|
+
const REG_NAME = "cctra";
|
|
12
|
+
|
|
13
|
+
/** 把 Rust 启动器从 src-tauri/ 拷贝到 ~/.cctra/bin/,然后注册到 Run key */
|
|
14
|
+
export function installWindows(bundledLauncherPath: string): void {
|
|
15
|
+
if (process.platform !== "win32") {
|
|
16
|
+
throw new Error("installWindows should only be called on Windows");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
ensureCctraDir();
|
|
20
|
+
const dest = windowsLauncherPath();
|
|
21
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
22
|
+
|
|
23
|
+
// 拷贝 .exe
|
|
24
|
+
if (!existsSync(bundledLauncherPath)) {
|
|
25
|
+
throw new Error(`Bundled launcher not found: ${bundledLauncherPath}. Build it first with scripts/build-launcher.ps1`);
|
|
26
|
+
}
|
|
27
|
+
copyFileSync(bundledLauncherPath, dest);
|
|
28
|
+
info(`copied launcher to ${dest}`);
|
|
29
|
+
|
|
30
|
+
// 写注册表(HKCU 不需要管理员)
|
|
31
|
+
const cmd = `reg add "${REG_KEY}" /v ${REG_NAME} /t REG_SZ /d "\"${dest}\"" /f`;
|
|
32
|
+
try {
|
|
33
|
+
execSync(cmd, { stdio: "pipe" });
|
|
34
|
+
info(`registered ${REG_NAME} in ${REG_KEY}`);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
throw new Error(`Failed to register Run key: ${(e as Error).message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function uninstallWindows(): void {
|
|
41
|
+
if (process.platform !== "win32") return;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
execSync(`reg delete "${REG_KEY}" /v ${REG_NAME} /f`, { stdio: "pipe" });
|
|
45
|
+
info(`removed ${REG_NAME} from ${REG_KEY}`);
|
|
46
|
+
} catch {
|
|
47
|
+
// 没注册过,跳过
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const dest = windowsLauncherPath();
|
|
51
|
+
if (existsSync(dest)) {
|
|
52
|
+
try {
|
|
53
|
+
execSync(`del /f "${dest}"`, { stdio: "pipe" });
|
|
54
|
+
info(`removed launcher ${dest}`);
|
|
55
|
+
} catch {
|
|
56
|
+
// 删不掉(文件被占用等)不阻塞
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 检测是否已注册 */
|
|
62
|
+
export function isInstalledWindows(): boolean {
|
|
63
|
+
if (process.platform !== "win32") return false;
|
|
64
|
+
try {
|
|
65
|
+
const out = execSync(`reg query "${REG_KEY}" /v ${REG_NAME}`, { stdio: "pipe" }).toString();
|
|
66
|
+
return out.includes(REG_NAME);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// 启动 daemon(后台拉起)
|
|
3
|
+
// v1: 调用 OS 的 service manager(Windows 触发 HKCU Run / macOS launchctl / Linux systemctl)
|
|
4
|
+
// v1 简化:直接 spawn 一个新的 bun 进程跑 serve,等价于不带 daemon 安装的启动方式
|
|
5
|
+
// ============================================================================
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { info } from "../ui/format";
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
export async function startDaemon(): Promise<void> {
|
|
14
|
+
const indexPath = join(__dirname, "..", "index.ts");
|
|
15
|
+
const child = spawn("bun", ["run", indexPath, "serve"], {
|
|
16
|
+
detached: true,
|
|
17
|
+
stdio: "ignore",
|
|
18
|
+
windowsHide: true,
|
|
19
|
+
});
|
|
20
|
+
child.unref();
|
|
21
|
+
info(`started (pid=${child.pid})`);
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// 状态查询:探测端口 /healthz
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { loadConfigFile } from "../core/config";
|
|
5
|
+
|
|
6
|
+
export async function checkDaemonStatus(): Promise<{ running: boolean; port: number }> {
|
|
7
|
+
const config = loadConfigFile();
|
|
8
|
+
const port = config.port;
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(2000) });
|
|
11
|
+
if (res.ok) {
|
|
12
|
+
const data = await res.json() as { ok: boolean };
|
|
13
|
+
return { running: data.ok === true, port };
|
|
14
|
+
}
|
|
15
|
+
return { running: false, port };
|
|
16
|
+
} catch {
|
|
17
|
+
return { running: false, port };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// 停止 daemon:探测端口占用进程并 kill
|
|
3
|
+
// 端口从 config 读,与 serve 时绑定的端口保持一致
|
|
4
|
+
// ============================================================================
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
import { loadConfigFile } from "../core/config";
|
|
7
|
+
import { info } from "../ui/format";
|
|
8
|
+
|
|
9
|
+
export async function stopDaemon(): Promise<void> {
|
|
10
|
+
const port = loadConfigFile().port;
|
|
11
|
+
|
|
12
|
+
if (process.platform === "win32") {
|
|
13
|
+
// PowerShell 找占用 config.port 端口的进程
|
|
14
|
+
// -ErrorAction SilentlyContinue + 0 长度 = 找不到监听时 stdout 为空,exit 0
|
|
15
|
+
const out = execSync(
|
|
16
|
+
`powershell -NoProfile -Command "(Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue).OwningProcess"`,
|
|
17
|
+
{ stdio: "pipe" },
|
|
18
|
+
).toString();
|
|
19
|
+
const pids = Array.from(new Set(
|
|
20
|
+
out.split("\n").map((s) => s.trim()).filter((s) => /^\d+$/.test(s)),
|
|
21
|
+
));
|
|
22
|
+
if (pids.length === 0) {
|
|
23
|
+
throw new Error(`No process is listening on port ${port} (daemon not running?)`);
|
|
24
|
+
}
|
|
25
|
+
for (const pid of pids) {
|
|
26
|
+
try {
|
|
27
|
+
execSync(`taskkill /F /PID ${pid}`, { stdio: "pipe" });
|
|
28
|
+
info(`killed pid ${pid}`);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
throw new Error(`taskkill /F /PID ${pid} failed: ${(e as Error).message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// macOS / Linux:lsof 找 pid
|
|
37
|
+
let out: string;
|
|
38
|
+
try {
|
|
39
|
+
out = execSync(`lsof -ti :${port}`, { stdio: "pipe" }).toString();
|
|
40
|
+
} catch {
|
|
41
|
+
// lsof 找不到监听时 exit 1、stdout 为空
|
|
42
|
+
throw new Error(`No process is listening on port ${port} (daemon not running?)`);
|
|
43
|
+
}
|
|
44
|
+
const pids = Array.from(new Set(
|
|
45
|
+
out.split("\n").map((s) => s.trim()).filter((s) => /^\d+$/.test(s)),
|
|
46
|
+
));
|
|
47
|
+
if (pids.length === 0) {
|
|
48
|
+
throw new Error(`No process is listening on port ${port} (daemon not running?)`);
|
|
49
|
+
}
|
|
50
|
+
for (const pid of pids) {
|
|
51
|
+
try {
|
|
52
|
+
execSync(`kill -TERM ${pid}`, { stdio: "pipe" });
|
|
53
|
+
info(`killed pid ${pid}`);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
throw new Error(`kill -TERM ${pid} failed: ${(e as Error).message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// CLI 入口:commander 注册所有子命令
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { registerAdd } from "./commands/add";
|
|
6
|
+
import { registerLs } from "./commands/ls";
|
|
7
|
+
import { registerShow } from "./commands/show";
|
|
8
|
+
import { registerRm } from "./commands/rm";
|
|
9
|
+
import { registerRename } from "./commands/rename";
|
|
10
|
+
import { registerModel } from "./commands/model";
|
|
11
|
+
import { registerPlugin } from "./commands/plugin";
|
|
12
|
+
import { registerTier } from "./commands/tier";
|
|
13
|
+
import { registerDaemon } from "./commands/daemon";
|
|
14
|
+
import { registerServe } from "./commands/serve";
|
|
15
|
+
import pkg from "../package.json";
|
|
16
|
+
|
|
17
|
+
const program = new Command();
|
|
18
|
+
program
|
|
19
|
+
.name("cctra")
|
|
20
|
+
.description("Local LLM subscription protocol converter + plugin host")
|
|
21
|
+
.version(pkg.version, "-v, --version");
|
|
22
|
+
|
|
23
|
+
registerAdd(program);
|
|
24
|
+
registerLs(program);
|
|
25
|
+
registerShow(program);
|
|
26
|
+
registerRm(program);
|
|
27
|
+
registerRename(program);
|
|
28
|
+
registerModel(program);
|
|
29
|
+
registerPlugin(program);
|
|
30
|
+
registerTier(program);
|
|
31
|
+
registerDaemon(program);
|
|
32
|
+
registerServe(program);
|
|
33
|
+
|
|
34
|
+
program.parse(process.argv);
|