@4yi-dev/cli 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/codex-migrate.mjs +111 -6
- package/src/connect.mjs +68 -28
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ On macOS, `4yi connect claude` also configures an installed Claude Desktop throu
|
|
|
39
39
|
|
|
40
40
|
`--scope project` changes only the project's Claude Code settings and never changes the global Claude Desktop profile.
|
|
41
41
|
|
|
42
|
-
Codex CLI and Codex App share the same Codex home on native Windows (`%USERPROFILE%\\.codex`). After `4yi connect codex`, finish active App tasks, fully quit and reopen the App, and start a new chat so its embedded Codex process loads the new provider. Microsoft Store/AppX installations may not expose a conventional executable path, but that detection does not gate the shared configuration update.
|
|
42
|
+
Codex CLI and Codex App share the same Codex home on native Windows (`%USERPROFILE%\\.codex`). After `4yi connect codex`, finish active App tasks, fully quit and reopen the App, and start a new chat so its embedded Codex process loads the new provider. Microsoft Store/AppX installations may not expose a conventional executable path, but that detection does not gate the shared configuration update. While the external 4YI provider supplies authentication, Codex App may hide its `Log out` action because there is no App-managed provider credential to clear. To switch back to OpenAI, run `4yi restore codex`, fully quit and reopen the App, and start a new chat.
|
|
43
43
|
|
|
44
44
|
Existing Codex tasks keep the provider they were created with. Run `4yi migrate codex`, choose a recent task by number, and 4YI creates a new task with the same history and project using your Plan's default model. The original task is not modified. Scripts can use `--thread <id>` and optionally `--model <id>`.
|
|
45
45
|
|
package/package.json
CHANGED
package/src/codex-migrate.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
1
3
|
import path from "node:path";
|
|
2
4
|
import process from "node:process";
|
|
3
5
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
@@ -6,6 +8,8 @@ import { requestJson } from "./http.mjs";
|
|
|
6
8
|
|
|
7
9
|
const DEFAULT_LIMIT = 10;
|
|
8
10
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
11
|
+
const SESSION_SCAN_LIMIT = 500;
|
|
12
|
+
const SESSION_PREFIX_BYTES = 256 * 1024;
|
|
9
13
|
|
|
10
14
|
function executableName(command, platform = process.platform) {
|
|
11
15
|
return platform === "win32" ? `${command}.cmd` : command;
|
|
@@ -34,6 +38,104 @@ function relativeTime(timestamp, now = Date.now()) {
|
|
|
34
38
|
return new Date(Number(timestamp) * 1000).toLocaleDateString();
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
function codexHomeForEnv(env = process.env) {
|
|
42
|
+
if (env.CODEX_HOME) return env.CODEX_HOME;
|
|
43
|
+
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
44
|
+
return path.join(home, ".codex");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function collectSessionFiles(root) {
|
|
48
|
+
const files = [];
|
|
49
|
+
const pending = [root];
|
|
50
|
+
while (pending.length > 0) {
|
|
51
|
+
const directory = pending.pop();
|
|
52
|
+
let entries;
|
|
53
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { continue; }
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const file = path.join(directory, entry.name);
|
|
56
|
+
if (entry.isDirectory()) pending.push(file);
|
|
57
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
58
|
+
try { files.push({ file, mtimeMs: fs.statSync(file).mtimeMs }); } catch { /* Ignore a file removed during scanning. */ }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return files.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, SESSION_SCAN_LIMIT);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readFilePrefix(file) {
|
|
66
|
+
const descriptor = fs.openSync(file, "r");
|
|
67
|
+
try {
|
|
68
|
+
const buffer = Buffer.alloc(SESSION_PREFIX_BYTES);
|
|
69
|
+
const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
70
|
+
return buffer.toString("utf8", 0, bytes);
|
|
71
|
+
} finally {
|
|
72
|
+
fs.closeSync(descriptor);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function messageText(payload) {
|
|
77
|
+
if (payload?.type === "message" && payload.role === "user") {
|
|
78
|
+
if (typeof payload.content === "string") return payload.content;
|
|
79
|
+
if (Array.isArray(payload.content)) {
|
|
80
|
+
return payload.content
|
|
81
|
+
.map((item) => item?.text || item?.input_text || "")
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.join(" ");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (payload?.type === "user_message") return payload.message || payload.text || "";
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sessionThreadFromFile({ file, mtimeMs }) {
|
|
91
|
+
let text;
|
|
92
|
+
try { text = readFilePrefix(file); } catch { return null; }
|
|
93
|
+
let metadata;
|
|
94
|
+
let preview = "";
|
|
95
|
+
for (const line of text.split(/\r?\n/)) {
|
|
96
|
+
if (!line.trim()) continue;
|
|
97
|
+
let record;
|
|
98
|
+
try { record = JSON.parse(line); } catch { continue; }
|
|
99
|
+
if (record.type === "session_meta") metadata = record.payload;
|
|
100
|
+
if (!preview && (record.type === "response_item" || record.type === "event_msg")) {
|
|
101
|
+
preview = messageText(record.payload);
|
|
102
|
+
}
|
|
103
|
+
if (metadata && preview) break;
|
|
104
|
+
}
|
|
105
|
+
const id = metadata?.id || metadata?.session_id;
|
|
106
|
+
if (!id) return null;
|
|
107
|
+
const created = Date.parse(metadata.timestamp || "");
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
modelProvider: metadata.model_provider || metadata.modelProvider || "openai",
|
|
111
|
+
cwd: metadata.cwd || "",
|
|
112
|
+
preview,
|
|
113
|
+
createdAt: Number.isFinite(created) ? Math.floor(created / 1000) : Math.floor(mtimeMs / 1000),
|
|
114
|
+
updatedAt: Math.floor(mtimeMs / 1000),
|
|
115
|
+
recencyAt: Math.floor(mtimeMs / 1000),
|
|
116
|
+
ephemeral: false,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function scanCodexSessionThreads({ codexHome = codexHomeForEnv() } = {}) {
|
|
121
|
+
const sessions = path.join(codexHome, "sessions");
|
|
122
|
+
return collectSessionFiles(sessions).map(sessionThreadFromFile).filter(Boolean);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function mergeCandidateThreads(serverThreads, sessionThreads, limit) {
|
|
126
|
+
const threads = new Map();
|
|
127
|
+
for (const thread of sessionThreads) if (thread?.id) threads.set(thread.id, thread);
|
|
128
|
+
for (const thread of serverThreads) if (thread?.id) threads.set(thread.id, { ...threads.get(thread.id), ...thread });
|
|
129
|
+
return [...threads.values()]
|
|
130
|
+
.filter((thread) => thread.modelProvider !== "4yi" && !thread.ephemeral)
|
|
131
|
+
.sort((left, right) => {
|
|
132
|
+
const leftTime = left.recencyAt || left.updatedAt || left.createdAt || 0;
|
|
133
|
+
const rightTime = right.recencyAt || right.updatedAt || right.createdAt || 0;
|
|
134
|
+
return rightTime - leftTime;
|
|
135
|
+
})
|
|
136
|
+
.slice(0, limit);
|
|
137
|
+
}
|
|
138
|
+
|
|
37
139
|
export function describeCodexThread(thread, { now = Date.now() } = {}) {
|
|
38
140
|
const title = oneLine(thread.name || thread.preview, "未命名任务");
|
|
39
141
|
const project = path.basename(String(thread.cwd || "")) || String(thread.cwd || "未知项目");
|
|
@@ -201,6 +303,7 @@ export async function migrateCodexTask({
|
|
|
201
303
|
client,
|
|
202
304
|
clientOptions = {},
|
|
203
305
|
selectThread = selectCodexThread,
|
|
306
|
+
scanThreads = scanCodexSessionThreads,
|
|
204
307
|
} = {}) {
|
|
205
308
|
if (!session?.token) throw new Error("Not signed in. Run `4yi login`.");
|
|
206
309
|
const selectedModel = model || await loadDefaultCodexModel(session);
|
|
@@ -215,13 +318,11 @@ export async function migrateCodexTask({
|
|
|
215
318
|
limit: Math.max(limit * 3, 30),
|
|
216
319
|
sortKey: "updated_at",
|
|
217
320
|
sortDirection: "desc",
|
|
218
|
-
useStateDbOnly: true,
|
|
219
321
|
});
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
.slice(0, limit);
|
|
322
|
+
const sessionThreads = scanThreads({ codexHome: codexHomeForEnv(clientOptions.env || process.env) });
|
|
323
|
+
const candidates = mergeCandidateThreads(response?.data || [], sessionThreads, limit);
|
|
223
324
|
if (candidates.length === 0) {
|
|
224
|
-
throw new Error("
|
|
325
|
+
throw new Error("没有自动找到可迁移的旧 Codex 任务。新任务已经会默认使用 4YI;如已知任务 ID,可运行 `4yi migrate codex --thread <任务ID>`。");
|
|
225
326
|
}
|
|
226
327
|
selected = await selectThread(candidates, { input, output });
|
|
227
328
|
if (!selected) {
|
|
@@ -230,7 +331,7 @@ export async function migrateCodexTask({
|
|
|
230
331
|
}
|
|
231
332
|
}
|
|
232
333
|
|
|
233
|
-
stdout(
|
|
334
|
+
stdout(`正在创建新的 4YI 任务(${selectedModel})…`);
|
|
234
335
|
const result = await appServer.request("thread/fork", {
|
|
235
336
|
threadId: selected.id,
|
|
236
337
|
model: selectedModel,
|
|
@@ -251,6 +352,10 @@ export async function migrateCodexTask({
|
|
|
251
352
|
|
|
252
353
|
export const __testing = {
|
|
253
354
|
appServerLaunch,
|
|
355
|
+
codexHomeForEnv,
|
|
254
356
|
loadDefaultCodexModel,
|
|
357
|
+
mergeCandidateThreads,
|
|
255
358
|
relativeTime,
|
|
359
|
+
scanCodexSessionThreads,
|
|
360
|
+
sessionThreadFromFile,
|
|
256
361
|
};
|
package/src/connect.mjs
CHANGED
|
@@ -97,10 +97,21 @@ function executableName(command, platform = process.platform) {
|
|
|
97
97
|
return platform === "win32" ? `${command}.cmd` : command;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
function
|
|
101
|
-
|
|
100
|
+
function commandLaunch(command, args, { platform = process.platform, env = process.env } = {}) {
|
|
101
|
+
if (platform !== "win32") return { command, args, shell: false };
|
|
102
|
+
// Avoid Node 24 DEP0190 by invoking cmd.exe explicitly. Every token passed
|
|
103
|
+
// here is owned by the CLI (tool names, fixed flags, and npm package names),
|
|
104
|
+
// never user input.
|
|
105
|
+
const shell = env.ComSpec || env.COMSPEC || "cmd.exe";
|
|
106
|
+
return { command: shell, args: ["/d", "/s", "/c", `${command} ${args.join(" ")}`], shell: false };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function commandAvailable(command, { platform = process.platform, env = process.env, spawn = spawnSync } = {}) {
|
|
110
|
+
const launch = commandLaunch(executableName(command, platform), ["--version"], { platform, env });
|
|
111
|
+
const result = spawn(launch.command, launch.args, {
|
|
102
112
|
encoding: "utf8",
|
|
103
|
-
|
|
113
|
+
env,
|
|
114
|
+
shell: launch.shell,
|
|
104
115
|
stdio: "ignore",
|
|
105
116
|
});
|
|
106
117
|
return !result.error && result.status === 0;
|
|
@@ -123,29 +134,32 @@ async function ensureToolCli(target, {
|
|
|
123
134
|
input = process.stdin,
|
|
124
135
|
output = process.stdout,
|
|
125
136
|
platform = process.platform,
|
|
137
|
+
env = process.env,
|
|
126
138
|
spawn = spawnSync,
|
|
127
139
|
stdout = console.log,
|
|
128
140
|
} = {}) {
|
|
129
141
|
const meta = TOOL_METADATA[target];
|
|
130
|
-
if (commandAvailable(meta.command, { platform, spawn })) return { installed: false };
|
|
142
|
+
if (commandAvailable(meta.command, { platform, env, spawn })) return { installed: false };
|
|
131
143
|
|
|
132
144
|
const approved = autoInstall || await confirmInstall(meta, { input, output });
|
|
133
145
|
if (!approved) {
|
|
134
146
|
throw new Error(`${meta.label} CLI is required. Install it with \`npm install -g ${meta.npmPackage}\`, then run this command again.`);
|
|
135
147
|
}
|
|
136
|
-
if (!commandAvailable("npm", { platform, spawn })) {
|
|
148
|
+
if (!commandAvailable("npm", { platform, env, spawn })) {
|
|
137
149
|
throw new Error(`npm is required to install ${meta.label} CLI automatically. Install Node.js/npm, then run this command again.`);
|
|
138
150
|
}
|
|
139
151
|
|
|
140
152
|
stdout(`Installing ${meta.label} CLI (${meta.npmPackage})...`);
|
|
141
|
-
const
|
|
142
|
-
|
|
153
|
+
const launch = commandLaunch(executableName("npm", platform), ["install", "-g", meta.npmPackage], { platform, env });
|
|
154
|
+
const result = spawn(launch.command, launch.args, {
|
|
155
|
+
env,
|
|
156
|
+
shell: launch.shell,
|
|
143
157
|
stdio: "inherit",
|
|
144
158
|
});
|
|
145
159
|
if (result.error || result.status !== 0) {
|
|
146
160
|
throw new Error(`Could not install ${meta.label} CLI automatically. Run \`npm install -g ${meta.npmPackage}\` and try again.`);
|
|
147
161
|
}
|
|
148
|
-
if (!commandAvailable(meta.command, { platform, spawn })) {
|
|
162
|
+
if (!commandAvailable(meta.command, { platform, env, spawn })) {
|
|
149
163
|
throw new Error(`${meta.label} CLI was installed, but \`${meta.command}\` is not available in this terminal. Open a new terminal and run this command again.`);
|
|
150
164
|
}
|
|
151
165
|
stdout(`${meta.label} CLI installed.`);
|
|
@@ -202,9 +216,9 @@ function reportDesktopApp(target, {
|
|
|
202
216
|
const detected = candidates.some((candidate) => exists(candidate));
|
|
203
217
|
if (target === "codex" && platform === "win32") {
|
|
204
218
|
if (detected) {
|
|
205
|
-
stdout("Codex App detected. 4YI updated the shared Windows Codex home. Finish active tasks, fully quit and reopen the App, then start a new chat to load the new provider.");
|
|
219
|
+
stdout("Codex App detected. 4YI updated the shared Windows Codex home. Finish active tasks, fully quit and reopen the App, then start a new chat to load the new provider. The App may hide Log out while this external provider supplies authentication; use `4yi restore codex`, then reopen the App, to return to OpenAI.");
|
|
206
220
|
} else {
|
|
207
|
-
stdout("4YI updated the shared Windows Codex home. A Microsoft Store/AppX install may not expose a standard executable path; finish active tasks, fully quit and reopen any running Codex App, then start a new chat.");
|
|
221
|
+
stdout("4YI updated the shared Windows Codex home. A Microsoft Store/AppX install may not expose a standard executable path; finish active tasks, fully quit and reopen any running Codex App, then start a new chat. The App may hide Log out while this external provider supplies authentication; use `4yi restore codex`, then reopen the App, to return to OpenAI.");
|
|
208
222
|
}
|
|
209
223
|
return detected;
|
|
210
224
|
}
|
|
@@ -235,21 +249,37 @@ function backupFile(target, file, home, groupId = timestamp()) {
|
|
|
235
249
|
return backup;
|
|
236
250
|
}
|
|
237
251
|
|
|
238
|
-
function
|
|
252
|
+
function backupGroups(target, home) {
|
|
239
253
|
const dir = path.join(pathsForHome(home).backupsDir, target);
|
|
240
254
|
if (!fs.existsSync(dir)) return [];
|
|
241
255
|
const entries = fs.readdirSync(dir)
|
|
242
256
|
.filter((entry) => entry.endsWith(".json"))
|
|
243
257
|
.sort()
|
|
244
258
|
.reverse();
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
.
|
|
252
|
-
|
|
259
|
+
const groups = new Map();
|
|
260
|
+
for (const entry of entries) {
|
|
261
|
+
const file = path.join(dir, entry);
|
|
262
|
+
const record = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
263
|
+
const groupId = record.group_id || entry;
|
|
264
|
+
if (!groups.has(groupId)) groups.set(groupId, []);
|
|
265
|
+
groups.get(groupId).push({ file, record });
|
|
266
|
+
}
|
|
267
|
+
return [...groups.values()];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function latestBackups(target, home) {
|
|
271
|
+
const groups = backupGroups(target, home);
|
|
272
|
+
if (target !== "codex") return groups[0] || [];
|
|
273
|
+
|
|
274
|
+
// Older CLI releases created a fresh backup every time `connect codex` ran,
|
|
275
|
+
// even when Codex was already connected. Such a group only restores the 4YI
|
|
276
|
+
// provider and credential helper. Walk back to the newest group whose config
|
|
277
|
+
// predates 4YI management so existing installations can still return to the
|
|
278
|
+
// official provider.
|
|
279
|
+
return groups.find((group) => !group.some(({ record }) => (
|
|
280
|
+
typeof record.content === "string"
|
|
281
|
+
&& (record.content.includes(CODEX_ROOT_START) || record.content.includes(CODEX_PROVIDER_START))
|
|
282
|
+
))) || [];
|
|
253
283
|
}
|
|
254
284
|
|
|
255
285
|
function mask(value) {
|
|
@@ -448,9 +478,10 @@ function escapeRegExp(value) {
|
|
|
448
478
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
449
479
|
}
|
|
450
480
|
|
|
451
|
-
function parseBundledCatalog({ platform = process.platform, spawn = spawnSync } = {}) {
|
|
481
|
+
function parseBundledCatalog({ platform = process.platform, env = process.env, spawn = spawnSync } = {}) {
|
|
452
482
|
const command = executableName("codex", platform);
|
|
453
|
-
const
|
|
483
|
+
const launch = commandLaunch(command, ["debug", "models", "--bundled"], { platform, env });
|
|
484
|
+
const result = spawn(launch.command, launch.args, { encoding: "utf8", env, shell: launch.shell });
|
|
454
485
|
if (result.status !== 0 || !result.stdout) throw new Error("Codex CLI is required to build its 4YI model catalog. Install Codex, then run this command again.");
|
|
455
486
|
const catalog = JSON.parse(result.stdout);
|
|
456
487
|
const template = catalog.models?.find((model) => model.slug === "gpt-5.5") || catalog.models?.[0];
|
|
@@ -551,10 +582,15 @@ async function connectCodex({ session, home, codexHome, codexBaseUrl, stdout, sk
|
|
|
551
582
|
const catalog = buildCodexCatalog(models, template);
|
|
552
583
|
atomicWrite(paths.codexCatalog, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
553
584
|
|
|
554
|
-
const backupGroup = timestamp();
|
|
555
|
-
const backup = backupFile("codex", paths.codexConfig, home, backupGroup);
|
|
556
|
-
const helperBackup = backupFile("codex", paths.codexCredentialHelper, home, backupGroup);
|
|
557
585
|
let existing = fs.existsSync(paths.codexConfig) ? fs.readFileSync(paths.codexConfig, "utf8") : "";
|
|
586
|
+
const alreadyManaged = existing.includes(CODEX_ROOT_START) || existing.includes(CODEX_PROVIDER_START);
|
|
587
|
+
let backup;
|
|
588
|
+
let helperBackup;
|
|
589
|
+
if (!alreadyManaged) {
|
|
590
|
+
const backupGroup = timestamp();
|
|
591
|
+
backup = backupFile("codex", paths.codexConfig, home, backupGroup);
|
|
592
|
+
helperBackup = backupFile("codex", paths.codexCredentialHelper, home, backupGroup);
|
|
593
|
+
}
|
|
558
594
|
existing = removeManagedBlock(existing, CODEX_ROOT_START, CODEX_ROOT_END);
|
|
559
595
|
existing = removeManagedBlock(existing, CODEX_PROVIDER_START, CODEX_PROVIDER_END);
|
|
560
596
|
existing = removeCodexRootAssignments(existing);
|
|
@@ -569,8 +605,12 @@ async function connectCodex({ session, home, codexHome, codexBaseUrl, stdout, sk
|
|
|
569
605
|
stdout(`Connected Codex: ${paths.codexConfig}`);
|
|
570
606
|
stdout(`Codex credential helper: ${paths.codexCredentialHelper}`);
|
|
571
607
|
stdout(`Available Codex models: ${models.map((model) => model.id).join(", ")}`);
|
|
572
|
-
|
|
573
|
-
|
|
608
|
+
if (backup && helperBackup) {
|
|
609
|
+
stdout(`Backup: ${backup}`);
|
|
610
|
+
stdout(`Backup: ${helperBackup}`);
|
|
611
|
+
} else {
|
|
612
|
+
stdout("Preserved the existing Codex restore point.");
|
|
613
|
+
}
|
|
574
614
|
}
|
|
575
615
|
|
|
576
616
|
function resolveUrls(session, options) {
|
|
@@ -669,8 +709,7 @@ function restoreOne(target, home, stdout, { required = true } = {}) {
|
|
|
669
709
|
if (required) throw new Error(`No ${target} backup found.`);
|
|
670
710
|
return false;
|
|
671
711
|
}
|
|
672
|
-
for (const backup of backups) {
|
|
673
|
-
const record = JSON.parse(fs.readFileSync(backup, "utf8"));
|
|
712
|
+
for (const { file: backup, record } of backups) {
|
|
674
713
|
if (record.existed) atomicWrite(record.source, record.content || "");
|
|
675
714
|
else if (fs.existsSync(record.source)) fs.unlinkSync(record.source);
|
|
676
715
|
stdout(`Restored ${target}: ${record.source}`);
|
|
@@ -693,6 +732,7 @@ export const __testing = {
|
|
|
693
732
|
buildCodexCatalog,
|
|
694
733
|
checkCodex,
|
|
695
734
|
commandAvailable,
|
|
735
|
+
commandLaunch,
|
|
696
736
|
desktopAppCandidates,
|
|
697
737
|
ensureToolCli,
|
|
698
738
|
executableName,
|