zerocut-cli 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.
- package/.eslintrc.js +11 -0
- package/.prettierignore +3 -0
- package/.prettierrc.json +6 -0
- package/README.md +205 -0
- package/dist/bin/zerocut.d.ts +2 -0
- package/dist/bin/zerocut.js +5 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +24 -0
- package/dist/commands/config.d.ts +4 -0
- package/dist/commands/config.js +129 -0
- package/dist/commands/ffmpeg.d.ts +4 -0
- package/dist/commands/ffmpeg.js +32 -0
- package/dist/commands/foo.d.ts +4 -0
- package/dist/commands/foo.js +14 -0
- package/dist/commands/help.d.ts +4 -0
- package/dist/commands/help.js +14 -0
- package/dist/commands/image.d.ts +4 -0
- package/dist/commands/image.js +149 -0
- package/dist/commands/music.d.ts +4 -0
- package/dist/commands/music.js +74 -0
- package/dist/commands/pandoc.d.ts +4 -0
- package/dist/commands/pandoc.js +32 -0
- package/dist/commands/skill.d.ts +4 -0
- package/dist/commands/skill.js +24 -0
- package/dist/commands/tts.d.ts +4 -0
- package/dist/commands/tts.js +74 -0
- package/dist/commands/video.d.ts +4 -0
- package/dist/commands/video.js +166 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -0
- package/dist/services/cerevox.d.ts +34 -0
- package/dist/services/cerevox.js +256 -0
- package/dist/services/commandLoader.d.ts +3 -0
- package/dist/services/commandLoader.js +80 -0
- package/dist/services/config.d.ts +15 -0
- package/dist/services/config.js +235 -0
- package/dist/skill/SKILL.md +133 -0
- package/dist/types/command.d.ts +6 -0
- package/dist/types/command.js +2 -0
- package/dist/utils/progress.d.ts +1 -0
- package/dist/utils/progress.js +13 -0
- package/eslint.config.js +30 -0
- package/package.json +52 -0
- package/scripts/copy-skill-md.cjs +8 -0
- package/src/bin/zerocut.ts +3 -0
- package/src/cli.ts +25 -0
- package/src/commands/config.ts +130 -0
- package/src/commands/ffmpeg.ts +37 -0
- package/src/commands/help.ts +13 -0
- package/src/commands/image.ts +194 -0
- package/src/commands/music.ts +78 -0
- package/src/commands/pandoc.ts +37 -0
- package/src/commands/skill.ts +20 -0
- package/src/commands/tts.ts +80 -0
- package/src/commands/video.ts +202 -0
- package/src/index.ts +1 -0
- package/src/services/cerevox.ts +296 -0
- package/src/services/commandLoader.ts +42 -0
- package/src/services/config.ts +230 -0
- package/src/skill/SKILL.md +209 -0
- package/src/types/command.ts +7 -0
- package/src/utils/progress.ts +10 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { Cerevox, type Session } from "cerevox";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { getConfigValueSync, setConfigValueSync } from "./config";
|
|
4
|
+
import { createReadStream } from "node:fs";
|
|
5
|
+
import { resolve, basename } from "node:path";
|
|
6
|
+
import { stat } from "node:fs/promises";
|
|
7
|
+
|
|
8
|
+
export const SESSION_SYMBOL = Symbol("zerocut.session");
|
|
9
|
+
|
|
10
|
+
export function attachSessionToCommand(cmd: { [k: symbol]: unknown }, session: Session): void {
|
|
11
|
+
cmd[SESSION_SYMBOL] = session as unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getSessionFromCommand(cmd: { [k: symbol]: unknown }): Session | undefined {
|
|
15
|
+
return cmd[SESSION_SYMBOL] as Session | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getApiKey(): string {
|
|
19
|
+
const v = getConfigValueSync("apiKey");
|
|
20
|
+
return typeof v === "string" ? v : "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getRegion(): "cn" | "us" {
|
|
24
|
+
const v = getConfigValueSync("region") as "cn" | "us";
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function openSession(): Promise<Session> {
|
|
29
|
+
let apiKey = getApiKey();
|
|
30
|
+
if (!apiKey) {
|
|
31
|
+
throw new Error("apiKey is not set");
|
|
32
|
+
}
|
|
33
|
+
const region = getRegion();
|
|
34
|
+
apiKey = region + apiKey.slice(2);
|
|
35
|
+
const cerevox = new Cerevox({
|
|
36
|
+
apiKey,
|
|
37
|
+
});
|
|
38
|
+
let sandboxId = getConfigValueSync("sandboxId") as string | undefined;
|
|
39
|
+
if (sandboxId) {
|
|
40
|
+
try {
|
|
41
|
+
return await cerevox.connect(sandboxId, 300_000);
|
|
42
|
+
} catch {
|
|
43
|
+
sandboxId = undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const session = await cerevox.launch({ timeout: 60, region });
|
|
47
|
+
sandboxId = session.sandbox.sandboxId!;
|
|
48
|
+
setConfigValueSync("sandboxId", sandboxId);
|
|
49
|
+
return session;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function closeSession(session: Session) {
|
|
53
|
+
await session.close();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function computeSha256(filePath: string): Promise<string> {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const hash = createHash("sha256");
|
|
59
|
+
const stream = createReadStream(filePath);
|
|
60
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
61
|
+
stream.on("error", reject);
|
|
62
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface GetMaterialUriOptions {
|
|
67
|
+
fileSizeLimit?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function getMaterialUri(
|
|
71
|
+
session: Session,
|
|
72
|
+
fileName: string,
|
|
73
|
+
options: GetMaterialUriOptions = {}
|
|
74
|
+
): Promise<string> {
|
|
75
|
+
const resolvedOptions: Required<GetMaterialUriOptions> = {
|
|
76
|
+
fileSizeLimit: options.fileSizeLimit ?? -1,
|
|
77
|
+
};
|
|
78
|
+
const localPath = resolve(fileName);
|
|
79
|
+
const hash = await computeSha256(localPath);
|
|
80
|
+
const url = session.sandbox.getUrl(
|
|
81
|
+
`/zerocut/${session.terminal.id}/materials/${basename(fileName)}`
|
|
82
|
+
);
|
|
83
|
+
// check url avaliable,用 HEAD 请求检查是否存在
|
|
84
|
+
const res = await fetch(url, {
|
|
85
|
+
method: "HEAD",
|
|
86
|
+
});
|
|
87
|
+
if (res.status === 404 || hash !== res.headers.get("X-Content-Hash")) {
|
|
88
|
+
if (resolvedOptions.fileSizeLimit > 0) {
|
|
89
|
+
const stats = await stat(localPath);
|
|
90
|
+
const fileSizeMb = stats.size / (1024 * 1024);
|
|
91
|
+
if (fileSizeMb > resolvedOptions.fileSizeLimit) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`文件太大:${fileName} (${fileSizeMb.toFixed(2)}MB),限制为 ${resolvedOptions.fileSizeLimit}MB`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const saveToPath = `/home/user/cerevox-zerocut/projects/${session.terminal.id}/materials/${fileName}`;
|
|
98
|
+
const files = session.files;
|
|
99
|
+
await files.upload(localPath, saveToPath);
|
|
100
|
+
} else if (res.status > 299) {
|
|
101
|
+
throw new Error(`Failed to get material from ${url}. Details: ${res.statusText}`);
|
|
102
|
+
}
|
|
103
|
+
return url;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
import { promises as fs } from "node:fs";
|
|
107
|
+
import { tmpdir } from "node:os";
|
|
108
|
+
import { join } from "node:path";
|
|
109
|
+
import { pipeline } from "node:stream/promises";
|
|
110
|
+
import { createWriteStream } from "node:fs";
|
|
111
|
+
|
|
112
|
+
export async function syncToTOS(url: string): Promise<string> {
|
|
113
|
+
const api = "https://api.zerocut.cn/api/v1/upload/material";
|
|
114
|
+
const apiKey = getApiKey();
|
|
115
|
+
|
|
116
|
+
const fileRes = await fetch(url);
|
|
117
|
+
if (!fileRes.ok || !fileRes.body) {
|
|
118
|
+
throw new Error("Failed to fetch source file");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const contentType = fileRes.headers.get("content-type") || "application/octet-stream";
|
|
122
|
+
const map: Record<string, string> = {
|
|
123
|
+
"audio/mpeg": "mp3",
|
|
124
|
+
"audio/mpga": "mp3",
|
|
125
|
+
"audio/wav": "wav",
|
|
126
|
+
"audio/x-wav": "wav",
|
|
127
|
+
"audio/flac": "flac",
|
|
128
|
+
"audio/ogg": "ogg",
|
|
129
|
+
"audio/webm": "webm",
|
|
130
|
+
"video/mp4": "mp4",
|
|
131
|
+
"video/mpeg": "mp4",
|
|
132
|
+
"video/quicktime": "mov",
|
|
133
|
+
"video/webm": "webm",
|
|
134
|
+
"image/png": "png",
|
|
135
|
+
"image/jpeg": "jpg",
|
|
136
|
+
"image/jpg": "jpg",
|
|
137
|
+
"image/webp": "webp",
|
|
138
|
+
"image/gif": "gif",
|
|
139
|
+
"application/pdf": "pdf",
|
|
140
|
+
};
|
|
141
|
+
let ext = map[contentType] || "";
|
|
142
|
+
if (!ext) {
|
|
143
|
+
try {
|
|
144
|
+
const u = new URL(url);
|
|
145
|
+
const p = u.pathname;
|
|
146
|
+
const i = p.lastIndexOf(".");
|
|
147
|
+
if (i !== -1 && i < p.length - 1) {
|
|
148
|
+
const e = p.substring(i + 1).toLowerCase();
|
|
149
|
+
if (/^[a-z0-9]{2,5}$/.test(e)) ext = e;
|
|
150
|
+
}
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
if (!ext) ext = "bin";
|
|
154
|
+
|
|
155
|
+
const tempPath = join(tmpdir(), `upload-${Date.now()}.${ext}`);
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await pipeline(fileRes.body as any, createWriteStream(tempPath));
|
|
159
|
+
|
|
160
|
+
const fileBuffer = await fs.readFile(tempPath);
|
|
161
|
+
const fileName = `file.${ext}`;
|
|
162
|
+
const file = new File([fileBuffer], fileName, { type: contentType });
|
|
163
|
+
|
|
164
|
+
const formData = new FormData();
|
|
165
|
+
formData.append("file", file);
|
|
166
|
+
|
|
167
|
+
const res = await fetch(api, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: {
|
|
170
|
+
Authorization: `Bearer ${apiKey}`,
|
|
171
|
+
},
|
|
172
|
+
body: formData,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
throw new Error(`Failed to upload to TOS: ${res.statusText}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const result = await res.json();
|
|
180
|
+
return result?.data?.url;
|
|
181
|
+
} finally {
|
|
182
|
+
await fs.unlink(tempPath).catch(() => {});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function runFFMpegCommand(
|
|
187
|
+
session: Session,
|
|
188
|
+
command: string,
|
|
189
|
+
resources: string[] = []
|
|
190
|
+
) {
|
|
191
|
+
// 验证命令只能是 ffmpeg 或 ffprobe
|
|
192
|
+
const trimmedCommand = command.trim();
|
|
193
|
+
if (!trimmedCommand.startsWith("ffmpeg") && !trimmedCommand.startsWith("ffprobe")) {
|
|
194
|
+
throw new Error("Only ffmpeg and ffprobe commands are allowed");
|
|
195
|
+
}
|
|
196
|
+
const terminal = session.terminal;
|
|
197
|
+
// 自动添加 -y 参数以避免交互式确认导致命令卡住
|
|
198
|
+
let finalCommand = trimmedCommand;
|
|
199
|
+
if (trimmedCommand.startsWith("ffmpeg") && !trimmedCommand.includes(" -y")) {
|
|
200
|
+
// 在 ffmpeg 后面插入 -y 参数
|
|
201
|
+
finalCommand = trimmedCommand.replace(/^ffmpeg/, "ffmpeg -y");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 将 resources 中的文件同步到沙箱 materials 目录
|
|
205
|
+
await Promise.all(
|
|
206
|
+
resources.map((resource) => {
|
|
207
|
+
return getMaterialUri(session, resource);
|
|
208
|
+
})
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// 构建工作目录路径 - materials 目录
|
|
212
|
+
const workDir = `/home/user/cerevox-zerocut/projects/${terminal.id}/materials`;
|
|
213
|
+
// 执行命令,用一个独立的命令行以免影响当前会话的cwd
|
|
214
|
+
const response = await terminal.create().run(finalCommand, {
|
|
215
|
+
cwd: workDir,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const outputFilePath = trimmedCommand.startsWith("ffmpeg")
|
|
219
|
+
? finalCommand.split(" ").pop() || ""
|
|
220
|
+
: "";
|
|
221
|
+
const sandboxFilePath = join(workDir, outputFilePath);
|
|
222
|
+
|
|
223
|
+
// 等待命令完成
|
|
224
|
+
const result = await response.json();
|
|
225
|
+
if (result.exitCode === 0 && outputFilePath) {
|
|
226
|
+
const savePath = join(process.cwd(), basename(outputFilePath));
|
|
227
|
+
|
|
228
|
+
console.log(sandboxFilePath, savePath);
|
|
229
|
+
|
|
230
|
+
const files = session.files;
|
|
231
|
+
await files.download(sandboxFilePath, savePath);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
exitCode: result.exitCode,
|
|
236
|
+
outputFilePath,
|
|
237
|
+
data: {
|
|
238
|
+
stdout: result.stdout || (!result.exitCode && result.stderr) || "",
|
|
239
|
+
stderr: result.exitCode ? result.stderr : undefined,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getPandocOutputFilePath(command: string): string {
|
|
245
|
+
const inlineMatch = command.match(/(?:^|\s)--output=("[^"]+"|'[^']+'|[^\s]+)/);
|
|
246
|
+
if (inlineMatch?.[1]) {
|
|
247
|
+
return inlineMatch[1].replace(/^["']|["']$/g, "");
|
|
248
|
+
}
|
|
249
|
+
const outputMatch = command.match(/(?:^|\s)(?:-o|--output)\s+("[^"]+"|'[^']+'|[^\s]+)/);
|
|
250
|
+
if (outputMatch?.[1]) {
|
|
251
|
+
return outputMatch[1].replace(/^["']|["']$/g, "");
|
|
252
|
+
}
|
|
253
|
+
return "";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function runPandocCommand(
|
|
257
|
+
session: Session,
|
|
258
|
+
command: string,
|
|
259
|
+
resources: string[] = []
|
|
260
|
+
) {
|
|
261
|
+
const trimmedCommand = command.trim();
|
|
262
|
+
if (!trimmedCommand.startsWith("pandoc")) {
|
|
263
|
+
throw new Error("Only pandoc command is allowed");
|
|
264
|
+
}
|
|
265
|
+
const terminal = session.terminal;
|
|
266
|
+
|
|
267
|
+
await Promise.all(
|
|
268
|
+
resources.map((resource) => {
|
|
269
|
+
return getMaterialUri(session, resource);
|
|
270
|
+
})
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const workDir = `/home/user/cerevox-zerocut/projects/${terminal.id}/materials`;
|
|
274
|
+
const response = await terminal.create().run(trimmedCommand, {
|
|
275
|
+
cwd: workDir,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const outputFilePath = getPandocOutputFilePath(trimmedCommand);
|
|
279
|
+
const sandboxFilePath = outputFilePath ? join(workDir, outputFilePath) : "";
|
|
280
|
+
|
|
281
|
+
const result = await response.json();
|
|
282
|
+
if (result.exitCode === 0 && outputFilePath) {
|
|
283
|
+
const savePath = join(process.cwd(), basename(outputFilePath));
|
|
284
|
+
const files = session.files;
|
|
285
|
+
await files.download(sandboxFilePath, savePath);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
exitCode: result.exitCode,
|
|
290
|
+
outputFilePath,
|
|
291
|
+
data: {
|
|
292
|
+
stdout: result.stdout || (!result.exitCode && result.stderr) || "",
|
|
293
|
+
stderr: result.exitCode ? result.stderr : undefined,
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { register as registerHelp } from "../commands/help";
|
|
3
|
+
import { register as registerImage } from "../commands/image";
|
|
4
|
+
import { register as registerConfig } from "../commands/config";
|
|
5
|
+
import { register as registerVideo } from "../commands/video";
|
|
6
|
+
import { register as registerMusic } from "../commands/music";
|
|
7
|
+
import { register as registerTts } from "../commands/tts";
|
|
8
|
+
import { register as registerFfmpeg } from "../commands/ffmpeg";
|
|
9
|
+
import { register as registerPandoc } from "../commands/pandoc";
|
|
10
|
+
import { register as registerSkill } from "../commands/skill";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
export function loadBuiltInCommands(program: Command): void {
|
|
15
|
+
registerHelp(program);
|
|
16
|
+
registerConfig(program);
|
|
17
|
+
registerImage(program);
|
|
18
|
+
registerVideo(program);
|
|
19
|
+
registerMusic(program);
|
|
20
|
+
registerTts(program);
|
|
21
|
+
registerFfmpeg(program);
|
|
22
|
+
registerPandoc(program);
|
|
23
|
+
registerSkill(program);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function loadExternalCommandsAsync(program: Command, dir?: string): Promise<void> {
|
|
27
|
+
const d = dir ?? process.env.ZEROCUT_COMMANDS_DIR;
|
|
28
|
+
if (!d) return;
|
|
29
|
+
if (!fs.existsSync(d)) return;
|
|
30
|
+
const files = fs.readdirSync(d).filter((f) => f.endsWith(".js") || f.endsWith(".cjs"));
|
|
31
|
+
for (const f of files) {
|
|
32
|
+
const full = path.join(d, f);
|
|
33
|
+
try {
|
|
34
|
+
const mod = (await import(full)) as {
|
|
35
|
+
register?: (p: Command) => void;
|
|
36
|
+
default?: (p: Command) => void;
|
|
37
|
+
};
|
|
38
|
+
const fn = mod.register ?? mod.default;
|
|
39
|
+
if (typeof fn === "function") fn(program);
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import fsp from "node:fs/promises";
|
|
5
|
+
import type { Command } from "commander";
|
|
6
|
+
import { openSession, closeSession, attachSessionToCommand, SESSION_SYMBOL } from "./cerevox";
|
|
7
|
+
|
|
8
|
+
export interface ZerocutConfig {
|
|
9
|
+
apiKey: string;
|
|
10
|
+
region?: "us" | "cn";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function configPath(): string {
|
|
14
|
+
return path.join(os.homedir(), ".zerocut", "config.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function configFallbackPath(): string {
|
|
18
|
+
return path.join(process.cwd(), ".zerocut", "config.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readJson(file: string): Promise<Record<string, unknown>> {
|
|
22
|
+
try {
|
|
23
|
+
const buf = await fsp.readFile(file, "utf8");
|
|
24
|
+
return JSON.parse(buf);
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readJsonSync(file: string): Record<string, unknown> {
|
|
31
|
+
try {
|
|
32
|
+
const buf = fs.readFileSync(file, "utf8");
|
|
33
|
+
return JSON.parse(buf);
|
|
34
|
+
} catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function writeJson(file: string, data: unknown): Promise<void> {
|
|
40
|
+
const dir = path.dirname(file);
|
|
41
|
+
try {
|
|
42
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
43
|
+
await fsp.writeFile(file, JSON.stringify(data, null, 2), "utf8");
|
|
44
|
+
return;
|
|
45
|
+
} catch (e: unknown) {
|
|
46
|
+
const code = (e as { code?: string }).code;
|
|
47
|
+
if (code !== "EPERM" && code !== "EACCES") throw e;
|
|
48
|
+
}
|
|
49
|
+
const fb = configFallbackPath();
|
|
50
|
+
const fdir = path.dirname(fb);
|
|
51
|
+
await fsp.mkdir(fdir, { recursive: true });
|
|
52
|
+
await fsp.writeFile(fb, JSON.stringify(data, null, 2), "utf8");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function readConfig(): Promise<Partial<ZerocutConfig>> {
|
|
56
|
+
const primary = configPath();
|
|
57
|
+
try {
|
|
58
|
+
await fsp.access(primary);
|
|
59
|
+
return (await readJson(primary)) as Partial<ZerocutConfig>;
|
|
60
|
+
} catch {
|
|
61
|
+
const fb = configFallbackPath();
|
|
62
|
+
try {
|
|
63
|
+
await fsp.access(fb);
|
|
64
|
+
return (await readJson(fb)) as Partial<ZerocutConfig>;
|
|
65
|
+
} catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readConfigSync(): Partial<ZerocutConfig> {
|
|
72
|
+
const primary = configPath();
|
|
73
|
+
if (fs.existsSync(primary)) return readJsonSync(primary) as Partial<ZerocutConfig>;
|
|
74
|
+
const fb = configFallbackPath();
|
|
75
|
+
if (fs.existsSync(fb)) return readJsonSync(fb) as Partial<ZerocutConfig>;
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function writeConfig(update: Partial<ZerocutConfig>): Promise<Partial<ZerocutConfig>> {
|
|
80
|
+
const file = configPath();
|
|
81
|
+
const current = (await readJson(file)) as Partial<ZerocutConfig>;
|
|
82
|
+
const next = { ...current, ...update } as Partial<ZerocutConfig>;
|
|
83
|
+
await writeJson(file, next);
|
|
84
|
+
return next;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function splitKeyPath(key: string): string[] {
|
|
88
|
+
return key
|
|
89
|
+
.split(".")
|
|
90
|
+
.map((s) => s.trim())
|
|
91
|
+
.filter((s) => s.length > 0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function deepGet(obj: Record<string, unknown>, segments: string[]): unknown {
|
|
95
|
+
let cur: unknown = obj;
|
|
96
|
+
for (const seg of segments) {
|
|
97
|
+
if (typeof cur !== "object" || cur === null) return undefined;
|
|
98
|
+
const rec = cur as Record<string, unknown>;
|
|
99
|
+
cur = rec[seg];
|
|
100
|
+
}
|
|
101
|
+
return cur;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function deepSet(obj: Record<string, unknown>, segments: string[], value: unknown): void {
|
|
105
|
+
let cur: Record<string, unknown> = obj;
|
|
106
|
+
for (let i = 0; i < segments.length; i++) {
|
|
107
|
+
const seg = segments[i];
|
|
108
|
+
const isLast = i === segments.length - 1;
|
|
109
|
+
if (isLast) {
|
|
110
|
+
cur[seg] = value as unknown;
|
|
111
|
+
} else {
|
|
112
|
+
const next = cur[seg];
|
|
113
|
+
if (typeof next !== "object" || next === null) {
|
|
114
|
+
const created: Record<string, unknown> = {};
|
|
115
|
+
cur[seg] = created;
|
|
116
|
+
cur = created;
|
|
117
|
+
} else {
|
|
118
|
+
cur = next as Record<string, unknown>;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getConfigValueSync(key: string): unknown {
|
|
125
|
+
const primary = configPath();
|
|
126
|
+
const fb = configFallbackPath();
|
|
127
|
+
const data = fs.existsSync(primary)
|
|
128
|
+
? (readJsonSync(primary) as Record<string, unknown>)
|
|
129
|
+
: fs.existsSync(fb)
|
|
130
|
+
? (readJsonSync(fb) as Record<string, unknown>)
|
|
131
|
+
: ({} as Record<string, unknown>);
|
|
132
|
+
return deepGet(data, splitKeyPath(key));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function getConfigValue(key: string): Promise<unknown> {
|
|
136
|
+
const primary = configPath();
|
|
137
|
+
try {
|
|
138
|
+
await fsp.access(primary);
|
|
139
|
+
const data = (await readJson(primary)) as Record<string, unknown>;
|
|
140
|
+
return deepGet(data, splitKeyPath(key));
|
|
141
|
+
} catch {
|
|
142
|
+
const fb = configFallbackPath();
|
|
143
|
+
try {
|
|
144
|
+
await fsp.access(fb);
|
|
145
|
+
const data = (await readJson(fb)) as Record<string, unknown>;
|
|
146
|
+
return deepGet(data, splitKeyPath(key));
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function writeJsonSync(file: string, data: unknown): void {
|
|
154
|
+
const dir = path.dirname(file);
|
|
155
|
+
try {
|
|
156
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
157
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2), "utf8");
|
|
158
|
+
return;
|
|
159
|
+
} catch (e: unknown) {
|
|
160
|
+
const code = (e as { code?: string }).code;
|
|
161
|
+
if (code !== "EPERM" && code !== "EACCES") throw e;
|
|
162
|
+
}
|
|
163
|
+
const fb = configFallbackPath();
|
|
164
|
+
const fdir = path.dirname(fb);
|
|
165
|
+
fs.mkdirSync(fdir, { recursive: true });
|
|
166
|
+
fs.writeFileSync(fb, JSON.stringify(data, null, 2), "utf8");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function setConfigValueSync(key: string, value: unknown): void {
|
|
170
|
+
const file = configPath();
|
|
171
|
+
const data = readJsonSync(file) as Record<string, unknown>;
|
|
172
|
+
deepSet(data, splitKeyPath(key), value);
|
|
173
|
+
writeJsonSync(file, data);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function setConfigValue(key: string, value: unknown): Promise<void> {
|
|
177
|
+
const file = configPath();
|
|
178
|
+
const data = (await readJson(file)) as Record<string, unknown>;
|
|
179
|
+
deepSet(data, splitKeyPath(key), value);
|
|
180
|
+
await writeJson(file, data);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function ensureConfig(): Promise<boolean> {
|
|
184
|
+
const apiKey = getConfigValueSync("apiKey");
|
|
185
|
+
const region = getConfigValueSync("region");
|
|
186
|
+
const missing: string[] = [];
|
|
187
|
+
if (typeof apiKey !== "string" || apiKey.trim().length === 0) missing.push("apiKey");
|
|
188
|
+
if (missing.length > 0) {
|
|
189
|
+
process.stderr.write(
|
|
190
|
+
`Missing required configuration: ${missing.join(
|
|
191
|
+
", "
|
|
192
|
+
)}\nConfigure using:\n zerocut config key <key>\n or:\n zerocut config --ott <token> --region <cn|us>\n`
|
|
193
|
+
);
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
if (region !== "us" && region !== "cn") {
|
|
197
|
+
setConfigValueSync("region", "us");
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function applyConfigInterceptor(program: Command): void {
|
|
203
|
+
program.hook("preAction", async (thisCommand, actionCommand) => {
|
|
204
|
+
const current = (actionCommand ?? thisCommand) as Command;
|
|
205
|
+
const name = current?.name?.();
|
|
206
|
+
const parentName = current?.parent?.name?.();
|
|
207
|
+
if (name === "help" || name === "skill" || name === "config" || parentName === "config") return;
|
|
208
|
+
const ok = await ensureConfig();
|
|
209
|
+
if (!ok) {
|
|
210
|
+
process.exit(1);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const session = await openSession();
|
|
214
|
+
if (actionCommand) {
|
|
215
|
+
attachSessionToCommand(actionCommand as unknown as Record<symbol, unknown>, session);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
program.hook("postAction", async (thisCommand, actionCommand) => {
|
|
219
|
+
const name = actionCommand?.name?.() ?? thisCommand?.name?.();
|
|
220
|
+
if (name === "help" || name === "skill") return;
|
|
221
|
+
try {
|
|
222
|
+
const cmd = (actionCommand ?? thisCommand) as Command & {
|
|
223
|
+
[SESSION_SYMBOL]?: import("cerevox").Session;
|
|
224
|
+
};
|
|
225
|
+
const session = cmd?.[SESSION_SYMBOL];
|
|
226
|
+
if (session) await closeSession(session);
|
|
227
|
+
process.exit(0);
|
|
228
|
+
} catch {}
|
|
229
|
+
});
|
|
230
|
+
}
|