codexapp 0.1.32 → 0.1.33

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/dist-cli/index.js CHANGED
@@ -1,34 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
+ import "dotenv/config";
4
5
  import { createServer as createServer2 } from "http";
5
- import { chmodSync, createWriteStream, existsSync as existsSync2, mkdirSync } from "fs";
6
- import { readFile as readFile3 } from "fs/promises";
7
- import { homedir as homedir2, networkInterfaces } from "os";
8
- import { join as join4 } from "path";
6
+ import { existsSync as existsSync3 } from "fs";
7
+ import { readFile as readFile2 } from "fs/promises";
8
+ import { homedir as homedir2 } from "os";
9
+ import { join as join3 } from "path";
9
10
  import { spawn as spawn2, spawnSync } from "child_process";
10
- import { createInterface } from "readline/promises";
11
11
  import { fileURLToPath as fileURLToPath2 } from "url";
12
- import { dirname as dirname3 } from "path";
13
- import { get as httpsGet } from "https";
12
+ import { dirname as dirname2 } from "path";
14
13
  import { Command } from "commander";
15
14
  import qrcode from "qrcode-terminal";
16
15
 
17
16
  // src/server/httpServer.ts
18
17
  import { fileURLToPath } from "url";
19
- import { dirname as dirname2, extname as extname2, isAbsolute as isAbsolute2, join as join3 } from "path";
20
- import { existsSync } from "fs";
21
- import { writeFile as writeFile2, stat as stat3 } from "fs/promises";
18
+ import { dirname, extname, isAbsolute as isAbsolute2, join as join2 } from "path";
19
+ import { existsSync as existsSync2 } from "fs";
22
20
  import express from "express";
23
21
 
24
22
  // src/server/codexAppServerBridge.ts
23
+ import "dotenv/config";
25
24
  import { spawn } from "child_process";
26
- import { randomBytes } from "crypto";
27
- import { mkdtemp, readFile, readdir, rm, mkdir, stat } from "fs/promises";
25
+ import { mkdtemp, readFile, readdir, rm, mkdir, stat, lstat, readlink, symlink } from "fs/promises";
26
+ import { existsSync } from "fs";
28
27
  import { request as httpsRequest } from "https";
29
28
  import { homedir } from "os";
30
29
  import { tmpdir } from "os";
31
- import { basename, isAbsolute, join, resolve } from "path";
30
+ import { isAbsolute, join, resolve } from "path";
32
31
  import { writeFile } from "fs/promises";
33
32
  function asRecord(value) {
34
33
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
@@ -52,46 +51,6 @@ function setJson(res, statusCode, payload) {
52
51
  res.setHeader("Content-Type", "application/json; charset=utf-8");
53
52
  res.end(JSON.stringify(payload));
54
53
  }
55
- function extractThreadMessageText(threadReadPayload) {
56
- const payload = asRecord(threadReadPayload);
57
- const thread = asRecord(payload?.thread);
58
- const turns = Array.isArray(thread?.turns) ? thread.turns : [];
59
- const parts = [];
60
- for (const turn of turns) {
61
- const turnRecord = asRecord(turn);
62
- const items = Array.isArray(turnRecord?.items) ? turnRecord.items : [];
63
- for (const item of items) {
64
- const itemRecord = asRecord(item);
65
- const type = typeof itemRecord?.type === "string" ? itemRecord.type : "";
66
- if (type === "agentMessage" && typeof itemRecord?.text === "string" && itemRecord.text.trim().length > 0) {
67
- parts.push(itemRecord.text.trim());
68
- continue;
69
- }
70
- if (type === "userMessage") {
71
- const content = Array.isArray(itemRecord?.content) ? itemRecord.content : [];
72
- for (const block of content) {
73
- const blockRecord = asRecord(block);
74
- if (blockRecord?.type === "text" && typeof blockRecord.text === "string" && blockRecord.text.trim().length > 0) {
75
- parts.push(blockRecord.text.trim());
76
- }
77
- }
78
- continue;
79
- }
80
- if (type === "commandExecution") {
81
- const command = typeof itemRecord?.command === "string" ? itemRecord.command.trim() : "";
82
- const output = typeof itemRecord?.aggregatedOutput === "string" ? itemRecord.aggregatedOutput.trim() : "";
83
- if (command) parts.push(command);
84
- if (output) parts.push(output);
85
- }
86
- }
87
- }
88
- return parts.join("\n").trim();
89
- }
90
- function isExactPhraseMatch(query, doc) {
91
- const q = query.trim().toLowerCase();
92
- if (!q) return false;
93
- return doc.title.toLowerCase().includes(q) || doc.preview.toLowerCase().includes(q) || doc.messageText.toLowerCase().includes(q);
94
- }
95
54
  function scoreFileCandidate(path, query) {
96
55
  if (!query) return 0;
97
56
  const lowerPath = path.toLowerCase();
@@ -165,30 +124,8 @@ async function runCommand(command, args, options = {}) {
165
124
  });
166
125
  });
167
126
  }
168
- function isMissingHeadError(error) {
169
- const message = getErrorMessage(error, "").toLowerCase();
170
- return message.includes("not a valid object name: 'head'") || message.includes("not a valid object name: head") || message.includes("invalid reference: head");
171
- }
172
- function isNotGitRepositoryError(error) {
173
- const message = getErrorMessage(error, "").toLowerCase();
174
- return message.includes("not a git repository") || message.includes("fatal: not a git repository");
175
- }
176
- async function ensureRepoHasInitialCommit(repoRoot) {
177
- const agentsPath = join(repoRoot, "AGENTS.md");
178
- try {
179
- await stat(agentsPath);
180
- } catch {
181
- await writeFile(agentsPath, "", "utf8");
182
- }
183
- await runCommand("git", ["add", "AGENTS.md"], { cwd: repoRoot });
184
- await runCommand(
185
- "git",
186
- ["-c", "user.name=Codex", "-c", "user.email=codex@local", "commit", "-m", "Initialize repository for worktree support"],
187
- { cwd: repoRoot }
188
- );
189
- }
190
- async function runCommandCapture(command, args, options = {}) {
191
- return await new Promise((resolveOutput, reject) => {
127
+ async function runCommandWithOutput(command, args, options = {}) {
128
+ return await new Promise((resolve2, reject) => {
192
129
  const proc = spawn(command, args, {
193
130
  cwd: options.cwd,
194
131
  env: process.env,
@@ -205,7 +142,7 @@ async function runCommandCapture(command, args, options = {}) {
205
142
  proc.on("error", reject);
206
143
  proc.on("close", (code) => {
207
144
  if (code === 0) {
208
- resolveOutput(stdout.trim());
145
+ resolve2(stdout);
209
146
  return;
210
147
  }
211
148
  const details = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
@@ -271,7 +208,7 @@ async function fetchSkillsTree() {
271
208
  if (skillsTreeCache && Date.now() - skillsTreeCache.fetchedAt < TREE_CACHE_TTL_MS) {
272
209
  return skillsTreeCache.entries;
273
210
  }
274
- const resp = await ghFetch("https://api.github.com/repos/openclaw/skills/git/trees/main?recursive=1");
211
+ const resp = await ghFetch(`https://api.github.com/repos/${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}/git/trees/main?recursive=1`);
275
212
  if (!resp.ok) throw new Error(`GitHub tree API returned ${resp.status}`);
276
213
  const data = await resp.json();
277
214
  const metaPattern = /^skills\/([^/]+)\/([^/]+)\/_meta\.json$/;
@@ -287,7 +224,7 @@ async function fetchSkillsTree() {
287
224
  entries.push({
288
225
  name: skillName,
289
226
  owner,
290
- url: `https://github.com/openclaw/skills/tree/main/skills/${owner}/${skillName}`
227
+ url: `https://github.com/${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}/tree/main/skills/${owner}/${skillName}`
291
228
  });
292
229
  }
293
230
  skillsTreeCache = { entries, fetchedAt: Date.now() };
@@ -299,7 +236,7 @@ async function fetchMetaBatch(entries) {
299
236
  const batch = toFetch.slice(0, 50);
300
237
  const results = await Promise.allSettled(
301
238
  batch.map(async (e) => {
302
- const rawUrl = `https://raw.githubusercontent.com/openclaw/skills/main/skills/${e.owner}/${e.name}/_meta.json`;
239
+ const rawUrl = `https://raw.githubusercontent.com/${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}/main/skills/${e.owner}/${e.name}/_meta.json`;
303
240
  const resp = await fetch(rawUrl);
304
241
  if (!resp.ok) return;
305
242
  const meta = await resp.json();
@@ -325,6 +262,23 @@ function buildHubEntry(e) {
325
262
  installed: false
326
263
  };
327
264
  }
265
+ var GITHUB_DEVICE_CLIENT_ID = "Iv1.b507a08c87ecfe98";
266
+ var DEFAULT_SKILLS_SYNC_REPO_NAME = "codexskills";
267
+ var SKILLS_SYNC_MANIFEST_PATH = "installed-skills.json";
268
+ var SYNC_UPSTREAM_SKILLS_OWNER = "OpenClawAndroid";
269
+ var SYNC_UPSTREAM_SKILLS_REPO = "skills";
270
+ var HUB_SKILLS_OWNER = "openclaw";
271
+ var HUB_SKILLS_REPO = "skills";
272
+ var startupSkillsSyncInitialized = false;
273
+ var startupSyncStatus = {
274
+ inProgress: false,
275
+ mode: "idle",
276
+ branch: getPreferredSyncBranch(),
277
+ lastAction: "not-started",
278
+ lastRunAtIso: "",
279
+ lastSuccessAtIso: "",
280
+ lastError: ""
281
+ };
328
282
  async function scanInstalledSkillsFromDisk() {
329
283
  const map = /* @__PURE__ */ new Map();
330
284
  const skillsDir = getSkillsInstallDir();
@@ -343,6 +297,520 @@ async function scanInstalledSkillsFromDisk() {
343
297
  }
344
298
  return map;
345
299
  }
300
+ function getSkillsSyncStatePath() {
301
+ return join(getCodexHomeDir(), "skills-sync.json");
302
+ }
303
+ async function readSkillsSyncState() {
304
+ try {
305
+ const raw = await readFile(getSkillsSyncStatePath(), "utf8");
306
+ const parsed = JSON.parse(raw);
307
+ return parsed && typeof parsed === "object" ? parsed : {};
308
+ } catch {
309
+ return {};
310
+ }
311
+ }
312
+ async function writeSkillsSyncState(state) {
313
+ await writeFile(getSkillsSyncStatePath(), JSON.stringify(state), "utf8");
314
+ }
315
+ async function getGithubJson(url, token, method = "GET", body) {
316
+ const resp = await fetch(url, {
317
+ method,
318
+ headers: {
319
+ Accept: "application/vnd.github+json",
320
+ "Content-Type": "application/json",
321
+ Authorization: `Bearer ${token}`,
322
+ "X-GitHub-Api-Version": "2022-11-28",
323
+ "User-Agent": "codex-web-local"
324
+ },
325
+ body: body ? JSON.stringify(body) : void 0
326
+ });
327
+ if (!resp.ok) {
328
+ const text = await resp.text();
329
+ throw new Error(`GitHub API ${method} ${url} failed (${resp.status}): ${text}`);
330
+ }
331
+ return await resp.json();
332
+ }
333
+ async function startGithubDeviceLogin() {
334
+ const resp = await fetch("https://github.com/login/device/code", {
335
+ method: "POST",
336
+ headers: {
337
+ Accept: "application/json",
338
+ "Content-Type": "application/x-www-form-urlencoded",
339
+ "User-Agent": "codex-web-local"
340
+ },
341
+ body: new URLSearchParams({
342
+ client_id: GITHUB_DEVICE_CLIENT_ID,
343
+ scope: "repo read:user"
344
+ })
345
+ });
346
+ if (!resp.ok) {
347
+ throw new Error(`GitHub device flow init failed (${resp.status})`);
348
+ }
349
+ return await resp.json();
350
+ }
351
+ async function completeGithubDeviceLogin(deviceCode) {
352
+ const resp = await fetch("https://github.com/login/oauth/access_token", {
353
+ method: "POST",
354
+ headers: {
355
+ Accept: "application/json",
356
+ "Content-Type": "application/x-www-form-urlencoded",
357
+ "User-Agent": "codex-web-local"
358
+ },
359
+ body: new URLSearchParams({
360
+ client_id: GITHUB_DEVICE_CLIENT_ID,
361
+ device_code: deviceCode,
362
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
363
+ })
364
+ });
365
+ if (!resp.ok) {
366
+ throw new Error(`GitHub token exchange failed (${resp.status})`);
367
+ }
368
+ const payload = await resp.json();
369
+ if (!payload.access_token) return { token: null, error: payload.error || "unknown_error" };
370
+ return { token: payload.access_token, error: null };
371
+ }
372
+ function isAndroidLikeRuntime() {
373
+ if (process.platform === "android") return true;
374
+ if (existsSync("/data/data/com.termux")) return true;
375
+ if (process.env.TERMUX_VERSION) return true;
376
+ const prefix = process.env.PREFIX?.toLowerCase() ?? "";
377
+ if (prefix.includes("/com.termux/")) return true;
378
+ const proot = process.env.PROOT_TMP_DIR?.toLowerCase() ?? "";
379
+ if (proot.length > 0) return true;
380
+ return false;
381
+ }
382
+ function getPreferredSyncBranch() {
383
+ return isAndroidLikeRuntime() ? "android" : "main";
384
+ }
385
+ function isUpstreamSkillsRepo(repoOwner, repoName) {
386
+ return repoOwner.toLowerCase() === SYNC_UPSTREAM_SKILLS_OWNER.toLowerCase() && repoName.toLowerCase() === SYNC_UPSTREAM_SKILLS_REPO.toLowerCase();
387
+ }
388
+ async function resolveGithubUsername(token) {
389
+ const user = await getGithubJson("https://api.github.com/user", token);
390
+ return user.login;
391
+ }
392
+ async function ensurePrivateForkFromUpstream(token, username, repoName) {
393
+ const repoUrl = `https://api.github.com/repos/${username}/${repoName}`;
394
+ let created = false;
395
+ const existing = await fetch(repoUrl, {
396
+ headers: {
397
+ Accept: "application/vnd.github+json",
398
+ Authorization: `Bearer ${token}`,
399
+ "X-GitHub-Api-Version": "2022-11-28",
400
+ "User-Agent": "codex-web-local"
401
+ }
402
+ });
403
+ if (existing.ok) {
404
+ const details = await existing.json();
405
+ if (details.private === true) return;
406
+ await getGithubJson(repoUrl, token, "PATCH", { private: true });
407
+ return;
408
+ }
409
+ if (existing.status !== 404) {
410
+ throw new Error(`Failed to check personal repo existence (${existing.status})`);
411
+ }
412
+ await getGithubJson(
413
+ "https://api.github.com/user/repos",
414
+ token,
415
+ "POST",
416
+ { name: repoName, private: true, auto_init: false, description: "Codex skills private mirror sync" }
417
+ );
418
+ created = true;
419
+ let ready = false;
420
+ for (let i = 0; i < 20; i++) {
421
+ const check = await fetch(repoUrl, {
422
+ headers: {
423
+ Accept: "application/vnd.github+json",
424
+ Authorization: `Bearer ${token}`,
425
+ "X-GitHub-Api-Version": "2022-11-28",
426
+ "User-Agent": "codex-web-local"
427
+ }
428
+ });
429
+ if (check.ok) {
430
+ ready = true;
431
+ break;
432
+ }
433
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
434
+ }
435
+ if (!ready) throw new Error("Private mirror repo was created but is not available yet");
436
+ if (!created) return;
437
+ const tmp = await mkdtemp(join(tmpdir(), "codex-skills-seed-"));
438
+ try {
439
+ const upstreamUrl = `https://github.com/${SYNC_UPSTREAM_SKILLS_OWNER}/${SYNC_UPSTREAM_SKILLS_REPO}.git`;
440
+ const branch = getPreferredSyncBranch();
441
+ try {
442
+ await runCommand("git", ["clone", "--depth", "1", "--single-branch", "--branch", branch, upstreamUrl, tmp]);
443
+ } catch {
444
+ await runCommand("git", ["clone", "--depth", "1", upstreamUrl, tmp]);
445
+ }
446
+ const privateRemote = toGitHubTokenRemote(username, repoName, token);
447
+ await runCommand("git", ["remote", "set-url", "origin", privateRemote], { cwd: tmp });
448
+ try {
449
+ await runCommand("git", ["checkout", "-B", branch], { cwd: tmp });
450
+ } catch {
451
+ }
452
+ await runCommand("git", ["push", "-u", "origin", `HEAD:${branch}`], { cwd: tmp });
453
+ } finally {
454
+ await rm(tmp, { recursive: true, force: true });
455
+ }
456
+ }
457
+ async function readRemoteSkillsManifest(token, repoOwner, repoName) {
458
+ const url = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/${SKILLS_SYNC_MANIFEST_PATH}`;
459
+ const resp = await fetch(url, {
460
+ headers: {
461
+ Accept: "application/vnd.github+json",
462
+ Authorization: `Bearer ${token}`,
463
+ "X-GitHub-Api-Version": "2022-11-28",
464
+ "User-Agent": "codex-web-local"
465
+ }
466
+ });
467
+ if (resp.status === 404) return [];
468
+ if (!resp.ok) throw new Error(`Failed to read remote manifest (${resp.status})`);
469
+ const payload = await resp.json();
470
+ const content = payload.content ? Buffer.from(payload.content.replace(/\n/g, ""), "base64").toString("utf8") : "[]";
471
+ const parsed = JSON.parse(content);
472
+ if (!Array.isArray(parsed)) return [];
473
+ const skills = [];
474
+ for (const row of parsed) {
475
+ const item = asRecord(row);
476
+ const owner = typeof item?.owner === "string" ? item.owner : "";
477
+ const name = typeof item?.name === "string" ? item.name : "";
478
+ if (!name) continue;
479
+ skills.push({ ...owner ? { owner } : {}, name, enabled: item?.enabled !== false });
480
+ }
481
+ return skills;
482
+ }
483
+ async function writeRemoteSkillsManifest(token, repoOwner, repoName, skills) {
484
+ const url = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/${SKILLS_SYNC_MANIFEST_PATH}`;
485
+ let sha = "";
486
+ const existing = await fetch(url, {
487
+ headers: {
488
+ Accept: "application/vnd.github+json",
489
+ Authorization: `Bearer ${token}`,
490
+ "X-GitHub-Api-Version": "2022-11-28",
491
+ "User-Agent": "codex-web-local"
492
+ }
493
+ });
494
+ if (existing.ok) {
495
+ const payload = await existing.json();
496
+ sha = payload.sha ?? "";
497
+ }
498
+ const content = Buffer.from(JSON.stringify(skills, null, 2), "utf8").toString("base64");
499
+ await getGithubJson(url, token, "PUT", {
500
+ message: "Update synced skills manifest",
501
+ content,
502
+ ...sha ? { sha } : {}
503
+ });
504
+ }
505
+ function toGitHubTokenRemote(repoOwner, repoName, token) {
506
+ return `https://x-access-token:${encodeURIComponent(token)}@github.com/${repoOwner}/${repoName}.git`;
507
+ }
508
+ async function ensureSkillsWorkingTreeRepo(repoUrl, branch) {
509
+ const localDir = getSkillsInstallDir();
510
+ await mkdir(localDir, { recursive: true });
511
+ const gitDir = join(localDir, ".git");
512
+ let hasGitDir = false;
513
+ try {
514
+ hasGitDir = (await stat(gitDir)).isDirectory();
515
+ } catch {
516
+ hasGitDir = false;
517
+ }
518
+ if (!hasGitDir) {
519
+ await runCommand("git", ["init"], { cwd: localDir });
520
+ await runCommand("git", ["config", "user.email", "skills-sync@local"], { cwd: localDir });
521
+ await runCommand("git", ["config", "user.name", "Skills Sync"], { cwd: localDir });
522
+ await runCommand("git", ["add", "-A"], { cwd: localDir });
523
+ try {
524
+ await runCommand("git", ["commit", "-m", "Local skills snapshot before sync"], { cwd: localDir });
525
+ } catch {
526
+ }
527
+ await runCommand("git", ["branch", "-M", branch], { cwd: localDir });
528
+ try {
529
+ await runCommand("git", ["remote", "add", "origin", repoUrl], { cwd: localDir });
530
+ } catch {
531
+ await runCommand("git", ["remote", "set-url", "origin", repoUrl], { cwd: localDir });
532
+ }
533
+ await runCommand("git", ["fetch", "origin"], { cwd: localDir });
534
+ try {
535
+ await runCommand("git", ["merge", "--allow-unrelated-histories", "--no-edit", `origin/${branch}`], { cwd: localDir });
536
+ } catch {
537
+ }
538
+ return localDir;
539
+ }
540
+ await runCommand("git", ["remote", "set-url", "origin", repoUrl], { cwd: localDir });
541
+ await runCommand("git", ["fetch", "origin"], { cwd: localDir });
542
+ await resolveMergeConflictsByNewerCommit(localDir, branch);
543
+ try {
544
+ await runCommand("git", ["checkout", branch], { cwd: localDir });
545
+ } catch {
546
+ await resolveMergeConflictsByNewerCommit(localDir, branch);
547
+ await runCommand("git", ["checkout", "-B", branch], { cwd: localDir });
548
+ }
549
+ await resolveMergeConflictsByNewerCommit(localDir, branch);
550
+ const localMtimesBeforePull = await snapshotFileMtimes(localDir);
551
+ try {
552
+ await runCommand("git", ["stash", "push", "--include-untracked", "-m", "codex-skills-autostash"], { cwd: localDir });
553
+ } catch {
554
+ }
555
+ let pulledMtimes = /* @__PURE__ */ new Map();
556
+ try {
557
+ await runCommand("git", ["pull", "--no-rebase", "origin", branch], { cwd: localDir });
558
+ pulledMtimes = await snapshotFileMtimes(localDir);
559
+ } catch {
560
+ await resolveMergeConflictsByNewerCommit(localDir, branch);
561
+ pulledMtimes = await snapshotFileMtimes(localDir);
562
+ }
563
+ try {
564
+ await runCommand("git", ["stash", "pop"], { cwd: localDir });
565
+ } catch {
566
+ await resolveStashPopConflictsByFileTime(localDir, localMtimesBeforePull, pulledMtimes);
567
+ }
568
+ return localDir;
569
+ }
570
+ async function resolveMergeConflictsByNewerCommit(repoDir, branch) {
571
+ const unmerged = (await runCommandWithOutput("git", ["diff", "--name-only", "--diff-filter=U"], { cwd: repoDir })).split(/\r?\n/).map((row) => row.trim()).filter(Boolean);
572
+ if (unmerged.length === 0) return;
573
+ for (const path of unmerged) {
574
+ const oursTime = await getCommitTime(repoDir, "HEAD", path);
575
+ const theirsTime = await getCommitTime(repoDir, `origin/${branch}`, path);
576
+ if (theirsTime > oursTime) {
577
+ await runCommand("git", ["checkout", "--theirs", "--", path], { cwd: repoDir });
578
+ } else {
579
+ await runCommand("git", ["checkout", "--ours", "--", path], { cwd: repoDir });
580
+ }
581
+ await runCommand("git", ["add", "--", path], { cwd: repoDir });
582
+ }
583
+ const mergeHead = (await runCommandWithOutput("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], { cwd: repoDir })).trim();
584
+ if (mergeHead) {
585
+ await runCommand("git", ["commit", "-m", "Auto-resolve skills merge by newer file"], { cwd: repoDir });
586
+ }
587
+ }
588
+ async function getCommitTime(repoDir, ref, path) {
589
+ try {
590
+ const output = (await runCommandWithOutput("git", ["log", "-1", "--format=%ct", ref, "--", path], { cwd: repoDir })).trim();
591
+ return output ? Number.parseInt(output, 10) : 0;
592
+ } catch {
593
+ return 0;
594
+ }
595
+ }
596
+ async function resolveStashPopConflictsByFileTime(repoDir, localMtimesBeforePull, pulledMtimes) {
597
+ const unmerged = (await runCommandWithOutput("git", ["diff", "--name-only", "--diff-filter=U"], { cwd: repoDir })).split(/\r?\n/).map((row) => row.trim()).filter(Boolean);
598
+ if (unmerged.length === 0) return;
599
+ for (const path of unmerged) {
600
+ const localMtime = localMtimesBeforePull.get(path) ?? 0;
601
+ const pulledMtime = pulledMtimes.get(path) ?? 0;
602
+ const side = localMtime >= pulledMtime ? "--theirs" : "--ours";
603
+ await runCommand("git", ["checkout", side, "--", path], { cwd: repoDir });
604
+ await runCommand("git", ["add", "--", path], { cwd: repoDir });
605
+ }
606
+ const mergeHead = (await runCommandWithOutput("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD"], { cwd: repoDir })).trim();
607
+ if (mergeHead) {
608
+ await runCommand("git", ["commit", "-m", "Auto-resolve stash-pop conflicts by file time"], { cwd: repoDir });
609
+ }
610
+ }
611
+ async function snapshotFileMtimes(dir) {
612
+ const mtimes = /* @__PURE__ */ new Map();
613
+ await walkFileMtimes(dir, dir, mtimes);
614
+ return mtimes;
615
+ }
616
+ async function walkFileMtimes(rootDir, currentDir, out) {
617
+ let entries;
618
+ try {
619
+ entries = await readdir(currentDir, { withFileTypes: true });
620
+ } catch {
621
+ return;
622
+ }
623
+ for (const entry of entries) {
624
+ const entryName = String(entry.name);
625
+ if (entryName === ".git") continue;
626
+ const absolutePath = join(currentDir, entryName);
627
+ const relativePath = absolutePath.slice(rootDir.length + 1);
628
+ if (entry.isDirectory()) {
629
+ await walkFileMtimes(rootDir, absolutePath, out);
630
+ continue;
631
+ }
632
+ if (!entry.isFile()) continue;
633
+ try {
634
+ const info = await stat(absolutePath);
635
+ out.set(relativePath, info.mtimeMs);
636
+ } catch {
637
+ }
638
+ }
639
+ }
640
+ async function syncInstalledSkillsFolderToRepo(token, repoOwner, repoName, _installedMap) {
641
+ const remoteUrl = toGitHubTokenRemote(repoOwner, repoName, token);
642
+ const branch = getPreferredSyncBranch();
643
+ const repoDir = await ensureSkillsWorkingTreeRepo(remoteUrl, branch);
644
+ const addPaths = ["."];
645
+ void _installedMap;
646
+ await runCommand("git", ["config", "user.email", "skills-sync@local"], { cwd: repoDir });
647
+ await runCommand("git", ["config", "user.name", "Skills Sync"], { cwd: repoDir });
648
+ await runCommand("git", ["add", ...addPaths], { cwd: repoDir });
649
+ const status = (await runCommandWithOutput("git", ["status", "--porcelain"], { cwd: repoDir })).trim();
650
+ if (!status) return;
651
+ await runCommand("git", ["commit", "-m", "Sync installed skills folder and manifest"], { cwd: repoDir });
652
+ await runCommand("git", ["push", "origin", `HEAD:${branch}`], { cwd: repoDir });
653
+ }
654
+ async function pullInstalledSkillsFolderFromRepo(token, repoOwner, repoName, _localSkillsDir) {
655
+ const remoteUrl = toGitHubTokenRemote(repoOwner, repoName, token);
656
+ const branch = getPreferredSyncBranch();
657
+ await ensureSkillsWorkingTreeRepo(remoteUrl, branch);
658
+ }
659
+ async function bootstrapSkillsFromUpstreamIntoLocal(_localSkillsDir) {
660
+ void _localSkillsDir;
661
+ const repoUrl = `https://github.com/${SYNC_UPSTREAM_SKILLS_OWNER}/${SYNC_UPSTREAM_SKILLS_REPO}.git`;
662
+ const branch = getPreferredSyncBranch();
663
+ await ensureSkillsWorkingTreeRepo(repoUrl, branch);
664
+ }
665
+ async function collectLocalSyncedSkills(appServer) {
666
+ const state = await readSkillsSyncState();
667
+ const owners = { ...state.installedOwners ?? {} };
668
+ const tree = await fetchSkillsTree();
669
+ const uniqueOwnerByName = /* @__PURE__ */ new Map();
670
+ const ambiguousNames = /* @__PURE__ */ new Set();
671
+ for (const entry of tree) {
672
+ if (ambiguousNames.has(entry.name)) continue;
673
+ const existingOwner = uniqueOwnerByName.get(entry.name);
674
+ if (!existingOwner) {
675
+ uniqueOwnerByName.set(entry.name, entry.owner);
676
+ continue;
677
+ }
678
+ if (existingOwner !== entry.owner) {
679
+ uniqueOwnerByName.delete(entry.name);
680
+ ambiguousNames.add(entry.name);
681
+ }
682
+ }
683
+ const skills = await appServer.rpc("skills/list", {});
684
+ const seen = /* @__PURE__ */ new Set();
685
+ const synced = [];
686
+ let ownersChanged = false;
687
+ for (const entry of skills.data ?? []) {
688
+ for (const skill of entry.skills ?? []) {
689
+ const name = typeof skill.name === "string" ? skill.name : "";
690
+ if (!name || seen.has(name)) continue;
691
+ seen.add(name);
692
+ let owner = owners[name];
693
+ if (!owner) {
694
+ owner = uniqueOwnerByName.get(name) ?? "";
695
+ if (owner) {
696
+ owners[name] = owner;
697
+ ownersChanged = true;
698
+ }
699
+ }
700
+ synced.push({ ...owner ? { owner } : {}, name, enabled: skill.enabled !== false });
701
+ }
702
+ }
703
+ if (ownersChanged) {
704
+ await writeSkillsSyncState({ ...state, installedOwners: owners });
705
+ }
706
+ synced.sort((a, b) => `${a.owner ?? ""}/${a.name}`.localeCompare(`${b.owner ?? ""}/${b.name}`));
707
+ return synced;
708
+ }
709
+ async function autoPushSyncedSkills(appServer) {
710
+ const state = await readSkillsSyncState();
711
+ if (!state.githubToken || !state.repoOwner || !state.repoName) return;
712
+ if (isUpstreamSkillsRepo(state.repoOwner, state.repoName)) {
713
+ throw new Error("Refusing to push to upstream skills repository");
714
+ }
715
+ const local = await collectLocalSyncedSkills(appServer);
716
+ const installedMap = await scanInstalledSkillsFromDisk();
717
+ await writeRemoteSkillsManifest(state.githubToken, state.repoOwner, state.repoName, local);
718
+ await syncInstalledSkillsFolderToRepo(state.githubToken, state.repoOwner, state.repoName, installedMap);
719
+ }
720
+ async function ensureCodexAgentsSymlinkToSkillsAgents() {
721
+ const codexHomeDir = getCodexHomeDir();
722
+ const skillsAgentsPath = join(codexHomeDir, "skills", "AGENTS.md");
723
+ const codexAgentsPath = join(codexHomeDir, "AGENTS.md");
724
+ try {
725
+ const skillsAgentsStat = await stat(skillsAgentsPath);
726
+ if (!skillsAgentsStat.isFile()) return;
727
+ } catch {
728
+ return;
729
+ }
730
+ const relativeTarget = join("skills", "AGENTS.md");
731
+ try {
732
+ const current = await lstat(codexAgentsPath);
733
+ if (current.isSymbolicLink()) {
734
+ const existingTarget = await readlink(codexAgentsPath);
735
+ if (existingTarget === relativeTarget) return;
736
+ }
737
+ await rm(codexAgentsPath, { force: true, recursive: true });
738
+ } catch {
739
+ }
740
+ await symlink(relativeTarget, codexAgentsPath);
741
+ }
742
+ async function initializeSkillsSyncOnStartup(appServer) {
743
+ if (startupSkillsSyncInitialized) return;
744
+ startupSkillsSyncInitialized = true;
745
+ startupSyncStatus.inProgress = true;
746
+ startupSyncStatus.lastRunAtIso = (/* @__PURE__ */ new Date()).toISOString();
747
+ startupSyncStatus.lastError = "";
748
+ startupSyncStatus.branch = getPreferredSyncBranch();
749
+ try {
750
+ const state = await readSkillsSyncState();
751
+ const localSkillsDir = getSkillsInstallDir();
752
+ if (!state.githubToken) {
753
+ await ensureCodexAgentsSymlinkToSkillsAgents();
754
+ if (!isAndroidLikeRuntime()) {
755
+ startupSyncStatus.mode = "idle";
756
+ startupSyncStatus.lastAction = "skip-upstream-non-android";
757
+ startupSyncStatus.lastSuccessAtIso = (/* @__PURE__ */ new Date()).toISOString();
758
+ return;
759
+ }
760
+ startupSyncStatus.mode = "unauthenticated-bootstrap";
761
+ startupSyncStatus.lastAction = "pull-upstream";
762
+ await bootstrapSkillsFromUpstreamIntoLocal(localSkillsDir);
763
+ try {
764
+ await appServer.rpc("skills/list", { forceReload: true });
765
+ } catch {
766
+ }
767
+ startupSyncStatus.lastSuccessAtIso = (/* @__PURE__ */ new Date()).toISOString();
768
+ startupSyncStatus.lastAction = "pull-upstream-complete";
769
+ return;
770
+ }
771
+ startupSyncStatus.mode = "authenticated-fork-sync";
772
+ startupSyncStatus.lastAction = "ensure-private-fork";
773
+ const username = state.githubUsername || await resolveGithubUsername(state.githubToken);
774
+ const repoName = DEFAULT_SKILLS_SYNC_REPO_NAME;
775
+ await ensurePrivateForkFromUpstream(state.githubToken, username, repoName);
776
+ const nextState = { ...state, githubUsername: username, repoOwner: username, repoName };
777
+ await writeSkillsSyncState(nextState);
778
+ startupSyncStatus.lastAction = "pull-private-fork";
779
+ await pullInstalledSkillsFolderFromRepo(state.githubToken, username, repoName, localSkillsDir);
780
+ try {
781
+ await appServer.rpc("skills/list", { forceReload: true });
782
+ } catch {
783
+ }
784
+ startupSyncStatus.lastAction = "push-private-fork";
785
+ await autoPushSyncedSkills(appServer);
786
+ startupSyncStatus.lastSuccessAtIso = (/* @__PURE__ */ new Date()).toISOString();
787
+ startupSyncStatus.lastAction = "startup-sync-complete";
788
+ } catch (error) {
789
+ startupSyncStatus.lastError = getErrorMessage(error, "startup-sync-failed");
790
+ startupSyncStatus.lastAction = "startup-sync-failed";
791
+ } finally {
792
+ startupSyncStatus.inProgress = false;
793
+ }
794
+ }
795
+ async function finalizeGithubLoginAndSync(token, username, appServer) {
796
+ const repoName = DEFAULT_SKILLS_SYNC_REPO_NAME;
797
+ await ensurePrivateForkFromUpstream(token, username, repoName);
798
+ const current = await readSkillsSyncState();
799
+ await writeSkillsSyncState({
800
+ ...current,
801
+ githubToken: token,
802
+ githubUsername: username,
803
+ repoOwner: username,
804
+ repoName
805
+ });
806
+ const localDir = getSkillsInstallDir();
807
+ await pullInstalledSkillsFolderFromRepo(token, username, repoName, localDir);
808
+ try {
809
+ await appServer.rpc("skills/list", { forceReload: true });
810
+ } catch {
811
+ }
812
+ await autoPushSyncedSkills(appServer);
813
+ }
346
814
  async function searchSkillsHub(allEntries, query, limit, sort, installedMap) {
347
815
  const q = query.toLowerCase().trim();
348
816
  let filtered = q ? allEntries.filter((s) => {
@@ -934,82 +1402,9 @@ function getSharedBridgeState() {
934
1402
  globalScope[SHARED_BRIDGE_KEY] = created;
935
1403
  return created;
936
1404
  }
937
- async function loadAllThreadsForSearch(appServer) {
938
- const threads = [];
939
- let cursor = null;
940
- do {
941
- const response = asRecord(await appServer.rpc("thread/list", {
942
- archived: false,
943
- limit: 100,
944
- sortKey: "updated_at",
945
- cursor
946
- }));
947
- const data = Array.isArray(response?.data) ? response.data : [];
948
- for (const row of data) {
949
- const record = asRecord(row);
950
- const id = typeof record?.id === "string" ? record.id : "";
951
- if (!id) continue;
952
- const title = typeof record?.name === "string" && record.name.trim().length > 0 ? record.name.trim() : typeof record?.preview === "string" && record.preview.trim().length > 0 ? record.preview.trim() : "Untitled thread";
953
- const preview = typeof record?.preview === "string" ? record.preview : "";
954
- threads.push({ id, title, preview });
955
- }
956
- cursor = typeof response?.nextCursor === "string" && response.nextCursor.length > 0 ? response.nextCursor : null;
957
- } while (cursor);
958
- const docs = [];
959
- const concurrency = 4;
960
- for (let offset = 0; offset < threads.length; offset += concurrency) {
961
- const batch = threads.slice(offset, offset + concurrency);
962
- const loaded = await Promise.all(batch.map(async (thread) => {
963
- try {
964
- const readResponse = await appServer.rpc("thread/read", {
965
- threadId: thread.id,
966
- includeTurns: true
967
- });
968
- const messageText = extractThreadMessageText(readResponse);
969
- const searchableText = [thread.title, thread.preview, messageText].filter(Boolean).join("\n");
970
- return {
971
- id: thread.id,
972
- title: thread.title,
973
- preview: thread.preview,
974
- messageText,
975
- searchableText
976
- };
977
- } catch {
978
- const searchableText = [thread.title, thread.preview].filter(Boolean).join("\n");
979
- return {
980
- id: thread.id,
981
- title: thread.title,
982
- preview: thread.preview,
983
- messageText: "",
984
- searchableText
985
- };
986
- }
987
- }));
988
- docs.push(...loaded);
989
- }
990
- return docs;
991
- }
992
- async function buildThreadSearchIndex(appServer) {
993
- const docs = await loadAllThreadsForSearch(appServer);
994
- const docsById = new Map(docs.map((doc) => [doc.id, doc]));
995
- return { docsById };
996
- }
997
1405
  function createCodexBridgeMiddleware() {
998
1406
  const { appServer, methodCatalog } = getSharedBridgeState();
999
- let threadSearchIndex = null;
1000
- let threadSearchIndexPromise = null;
1001
- async function getThreadSearchIndex() {
1002
- if (threadSearchIndex) return threadSearchIndex;
1003
- if (!threadSearchIndexPromise) {
1004
- threadSearchIndexPromise = buildThreadSearchIndex(appServer).then((index) => {
1005
- threadSearchIndex = index;
1006
- return index;
1007
- }).finally(() => {
1008
- threadSearchIndexPromise = null;
1009
- });
1010
- }
1011
- return threadSearchIndexPromise;
1012
- }
1407
+ void initializeSkillsSyncOnStartup(appServer);
1013
1408
  const middleware = async (req, res, next) => {
1014
1409
  try {
1015
1410
  if (!req.url) {
@@ -1075,76 +1470,6 @@ function createCodexBridgeMiddleware() {
1075
1470
  setJson(res, 200, { data: { path: homedir() } });
1076
1471
  return;
1077
1472
  }
1078
- if (req.method === "POST" && url.pathname === "/codex-api/worktree/create") {
1079
- const payload = asRecord(await readJsonBody(req));
1080
- const rawSourceCwd = typeof payload?.sourceCwd === "string" ? payload.sourceCwd.trim() : "";
1081
- if (!rawSourceCwd) {
1082
- setJson(res, 400, { error: "Missing sourceCwd" });
1083
- return;
1084
- }
1085
- const sourceCwd = isAbsolute(rawSourceCwd) ? rawSourceCwd : resolve(rawSourceCwd);
1086
- try {
1087
- const sourceInfo = await stat(sourceCwd);
1088
- if (!sourceInfo.isDirectory()) {
1089
- setJson(res, 400, { error: "sourceCwd is not a directory" });
1090
- return;
1091
- }
1092
- } catch {
1093
- setJson(res, 404, { error: "sourceCwd does not exist" });
1094
- return;
1095
- }
1096
- try {
1097
- let gitRoot = "";
1098
- try {
1099
- gitRoot = await runCommandCapture("git", ["rev-parse", "--show-toplevel"], { cwd: sourceCwd });
1100
- } catch (error) {
1101
- if (!isNotGitRepositoryError(error)) throw error;
1102
- await runCommand("git", ["init"], { cwd: sourceCwd });
1103
- gitRoot = await runCommandCapture("git", ["rev-parse", "--show-toplevel"], { cwd: sourceCwd });
1104
- }
1105
- const repoName = basename(gitRoot) || "repo";
1106
- const worktreesRoot = join(getCodexHomeDir(), "worktrees");
1107
- await mkdir(worktreesRoot, { recursive: true });
1108
- let worktreeId = "";
1109
- let worktreeParent = "";
1110
- let worktreeCwd = "";
1111
- for (let attempt = 0; attempt < 12; attempt += 1) {
1112
- const candidate = randomBytes(2).toString("hex");
1113
- const parent = join(worktreesRoot, candidate);
1114
- try {
1115
- await stat(parent);
1116
- continue;
1117
- } catch {
1118
- worktreeId = candidate;
1119
- worktreeParent = parent;
1120
- worktreeCwd = join(parent, repoName);
1121
- break;
1122
- }
1123
- }
1124
- if (!worktreeId || !worktreeParent || !worktreeCwd) {
1125
- throw new Error("Failed to allocate a unique worktree id");
1126
- }
1127
- const branch = `codex/${worktreeId}`;
1128
- await mkdir(worktreeParent, { recursive: true });
1129
- try {
1130
- await runCommand("git", ["worktree", "add", "-b", branch, worktreeCwd, "HEAD"], { cwd: gitRoot });
1131
- } catch (error) {
1132
- if (!isMissingHeadError(error)) throw error;
1133
- await ensureRepoHasInitialCommit(gitRoot);
1134
- await runCommand("git", ["worktree", "add", "-b", branch, worktreeCwd, "HEAD"], { cwd: gitRoot });
1135
- }
1136
- setJson(res, 200, {
1137
- data: {
1138
- cwd: worktreeCwd,
1139
- branch,
1140
- gitRoot
1141
- }
1142
- });
1143
- } catch (error) {
1144
- setJson(res, 500, { error: getErrorMessage(error, "Failed to create worktree") });
1145
- }
1146
- return;
1147
- }
1148
1473
  if (req.method === "PUT" && url.pathname === "/codex-api/workspace-roots-state") {
1149
1474
  const payload = await readJsonBody(req);
1150
1475
  const record = asRecord(payload);
@@ -1270,20 +1595,6 @@ function createCodexBridgeMiddleware() {
1270
1595
  setJson(res, 200, { data: cache });
1271
1596
  return;
1272
1597
  }
1273
- if (req.method === "POST" && url.pathname === "/codex-api/thread-search") {
1274
- const payload = asRecord(await readJsonBody(req));
1275
- const query = typeof payload?.query === "string" ? payload.query.trim() : "";
1276
- const limitRaw = typeof payload?.limit === "number" ? payload.limit : 200;
1277
- const limit = Math.max(1, Math.min(1e3, Math.floor(limitRaw)));
1278
- if (!query) {
1279
- setJson(res, 200, { data: { threadIds: [], indexedThreadCount: 0 } });
1280
- return;
1281
- }
1282
- const index = await getThreadSearchIndex();
1283
- const matchedIds = Array.from(index.docsById.entries()).filter(([, doc]) => isExactPhraseMatch(query, doc)).slice(0, limit).map(([id]) => id);
1284
- setJson(res, 200, { data: { threadIds: matchedIds, indexedThreadCount: index.docsById.size } });
1285
- return;
1286
- }
1287
1598
  if (req.method === "PUT" && url.pathname === "/codex-api/thread-titles") {
1288
1599
  const payload = asRecord(await readJsonBody(req));
1289
1600
  const id = typeof payload?.id === "string" ? payload.id : "";
@@ -1340,6 +1651,182 @@ function createCodexBridgeMiddleware() {
1340
1651
  }
1341
1652
  return;
1342
1653
  }
1654
+ if (req.method === "GET" && url.pathname === "/codex-api/skills-sync/status") {
1655
+ const state = await readSkillsSyncState();
1656
+ setJson(res, 200, {
1657
+ data: {
1658
+ loggedIn: Boolean(state.githubToken),
1659
+ githubUsername: state.githubUsername ?? "",
1660
+ repoOwner: state.repoOwner ?? "",
1661
+ repoName: state.repoName ?? "",
1662
+ configured: Boolean(state.githubToken && state.repoOwner && state.repoName),
1663
+ startup: {
1664
+ inProgress: startupSyncStatus.inProgress,
1665
+ mode: startupSyncStatus.mode,
1666
+ branch: startupSyncStatus.branch,
1667
+ lastAction: startupSyncStatus.lastAction,
1668
+ lastRunAtIso: startupSyncStatus.lastRunAtIso,
1669
+ lastSuccessAtIso: startupSyncStatus.lastSuccessAtIso,
1670
+ lastError: startupSyncStatus.lastError
1671
+ }
1672
+ }
1673
+ });
1674
+ return;
1675
+ }
1676
+ if (req.method === "POST" && url.pathname === "/codex-api/skills-sync/github/start-login") {
1677
+ try {
1678
+ const started = await startGithubDeviceLogin();
1679
+ setJson(res, 200, { data: started });
1680
+ } catch (error) {
1681
+ setJson(res, 502, { error: getErrorMessage(error, "Failed to start GitHub login") });
1682
+ }
1683
+ return;
1684
+ }
1685
+ if (req.method === "POST" && url.pathname === "/codex-api/skills-sync/github/token-login") {
1686
+ try {
1687
+ const payload = asRecord(await readJsonBody(req));
1688
+ const token = typeof payload?.token === "string" ? payload.token.trim() : "";
1689
+ if (!token) {
1690
+ setJson(res, 400, { error: "Missing GitHub token" });
1691
+ return;
1692
+ }
1693
+ const username = await resolveGithubUsername(token);
1694
+ await finalizeGithubLoginAndSync(token, username, appServer);
1695
+ setJson(res, 200, { ok: true, data: { githubUsername: username } });
1696
+ } catch (error) {
1697
+ setJson(res, 502, { error: getErrorMessage(error, "Failed to login with GitHub token") });
1698
+ }
1699
+ return;
1700
+ }
1701
+ if (req.method === "POST" && url.pathname === "/codex-api/skills-sync/github/logout") {
1702
+ try {
1703
+ const state = await readSkillsSyncState();
1704
+ await writeSkillsSyncState({
1705
+ ...state,
1706
+ githubToken: void 0,
1707
+ githubUsername: void 0,
1708
+ repoOwner: void 0,
1709
+ repoName: void 0
1710
+ });
1711
+ setJson(res, 200, { ok: true });
1712
+ } catch (error) {
1713
+ setJson(res, 500, { error: getErrorMessage(error, "Failed to logout GitHub") });
1714
+ }
1715
+ return;
1716
+ }
1717
+ if (req.method === "POST" && url.pathname === "/codex-api/skills-sync/github/complete-login") {
1718
+ try {
1719
+ const payload = asRecord(await readJsonBody(req));
1720
+ const deviceCode = typeof payload?.deviceCode === "string" ? payload.deviceCode : "";
1721
+ if (!deviceCode) {
1722
+ setJson(res, 400, { error: "Missing deviceCode" });
1723
+ return;
1724
+ }
1725
+ const result = await completeGithubDeviceLogin(deviceCode);
1726
+ if (!result.token) {
1727
+ setJson(res, 200, { ok: false, pending: result.error === "authorization_pending", error: result.error || "login_failed" });
1728
+ return;
1729
+ }
1730
+ const token = result.token;
1731
+ const username = await resolveGithubUsername(token);
1732
+ await finalizeGithubLoginAndSync(token, username, appServer);
1733
+ setJson(res, 200, { ok: true, data: { githubUsername: username } });
1734
+ } catch (error) {
1735
+ setJson(res, 502, { error: getErrorMessage(error, "Failed to complete GitHub login") });
1736
+ }
1737
+ return;
1738
+ }
1739
+ if (req.method === "POST" && url.pathname === "/codex-api/skills-sync/push") {
1740
+ try {
1741
+ const state = await readSkillsSyncState();
1742
+ if (!state.githubToken || !state.repoOwner || !state.repoName) {
1743
+ setJson(res, 400, { error: "Skills sync is not configured yet" });
1744
+ return;
1745
+ }
1746
+ if (isUpstreamSkillsRepo(state.repoOwner, state.repoName)) {
1747
+ setJson(res, 400, { error: "Refusing to push to upstream repository" });
1748
+ return;
1749
+ }
1750
+ const local = await collectLocalSyncedSkills(appServer);
1751
+ const installedMap = await scanInstalledSkillsFromDisk();
1752
+ await writeRemoteSkillsManifest(state.githubToken, state.repoOwner, state.repoName, local);
1753
+ await syncInstalledSkillsFolderToRepo(state.githubToken, state.repoOwner, state.repoName, installedMap);
1754
+ setJson(res, 200, { ok: true, data: { synced: local.length } });
1755
+ } catch (error) {
1756
+ setJson(res, 502, { error: getErrorMessage(error, "Failed to push synced skills") });
1757
+ }
1758
+ return;
1759
+ }
1760
+ if (req.method === "POST" && url.pathname === "/codex-api/skills-sync/pull") {
1761
+ try {
1762
+ const state = await readSkillsSyncState();
1763
+ if (!state.githubToken || !state.repoOwner || !state.repoName) {
1764
+ setJson(res, 400, { error: "Skills sync is not configured yet" });
1765
+ return;
1766
+ }
1767
+ const remote = await readRemoteSkillsManifest(state.githubToken, state.repoOwner, state.repoName);
1768
+ const tree = await fetchSkillsTree();
1769
+ const uniqueOwnerByName = /* @__PURE__ */ new Map();
1770
+ const ambiguousNames = /* @__PURE__ */ new Set();
1771
+ for (const entry of tree) {
1772
+ if (ambiguousNames.has(entry.name)) continue;
1773
+ const existingOwner = uniqueOwnerByName.get(entry.name);
1774
+ if (!existingOwner) {
1775
+ uniqueOwnerByName.set(entry.name, entry.owner);
1776
+ continue;
1777
+ }
1778
+ if (existingOwner !== entry.owner) {
1779
+ uniqueOwnerByName.delete(entry.name);
1780
+ ambiguousNames.add(entry.name);
1781
+ }
1782
+ }
1783
+ const localDir = await detectUserSkillsDir(appServer);
1784
+ await pullInstalledSkillsFolderFromRepo(state.githubToken, state.repoOwner, state.repoName, localDir);
1785
+ const installerScript = "/Users/igor/.cursor/skills/.system/skill-installer/scripts/install-skill-from-github.py";
1786
+ const localSkills = await scanInstalledSkillsFromDisk();
1787
+ for (const skill of remote) {
1788
+ const owner = skill.owner || uniqueOwnerByName.get(skill.name) || "";
1789
+ if (!owner) {
1790
+ continue;
1791
+ }
1792
+ if (!localSkills.has(skill.name)) {
1793
+ await runCommand("python3", [
1794
+ installerScript,
1795
+ "--repo",
1796
+ `${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}`,
1797
+ "--path",
1798
+ `skills/${owner}/${skill.name}`,
1799
+ "--dest",
1800
+ localDir,
1801
+ "--method",
1802
+ "git"
1803
+ ]);
1804
+ }
1805
+ const skillPath = join(localDir, skill.name);
1806
+ await appServer.rpc("skills/config/write", { path: skillPath, enabled: skill.enabled });
1807
+ }
1808
+ const remoteNames = new Set(remote.map((row) => row.name));
1809
+ for (const [name, localInfo] of localSkills.entries()) {
1810
+ if (!remoteNames.has(name)) {
1811
+ await rm(localInfo.path.replace(/\/SKILL\.md$/, ""), { recursive: true, force: true });
1812
+ }
1813
+ }
1814
+ const nextOwners = {};
1815
+ for (const item of remote) {
1816
+ const owner = item.owner || uniqueOwnerByName.get(item.name) || "";
1817
+ if (owner) nextOwners[item.name] = owner;
1818
+ }
1819
+ await writeSkillsSyncState({ ...state, installedOwners: nextOwners });
1820
+ try {
1821
+ await appServer.rpc("skills/list", { forceReload: true });
1822
+ } catch {
1823
+ }
1824
+ setJson(res, 200, { ok: true, data: { synced: remote.length } });
1825
+ } catch (error) {
1826
+ setJson(res, 502, { error: getErrorMessage(error, "Failed to pull synced skills") });
1827
+ }
1828
+ return;
1829
+ }
1343
1830
  if (req.method === "GET" && url.pathname === "/codex-api/skills-hub/readme") {
1344
1831
  try {
1345
1832
  const owner = url.searchParams.get("owner") || "";
@@ -1348,7 +1835,7 @@ function createCodexBridgeMiddleware() {
1348
1835
  setJson(res, 400, { error: "Missing owner or name" });
1349
1836
  return;
1350
1837
  }
1351
- const rawUrl = `https://raw.githubusercontent.com/openclaw/skills/main/skills/${owner}/${name}/SKILL.md`;
1838
+ const rawUrl = `https://raw.githubusercontent.com/${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}/main/skills/${owner}/${name}/SKILL.md`;
1352
1839
  const resp = await fetch(rawUrl);
1353
1840
  if (!resp.ok) throw new Error(`Failed to fetch SKILL.md: ${resp.status}`);
1354
1841
  const content = await resp.text();
@@ -1373,7 +1860,7 @@ function createCodexBridgeMiddleware() {
1373
1860
  await runCommand("python3", [
1374
1861
  installerScript,
1375
1862
  "--repo",
1376
- "openclaw/skills",
1863
+ `${HUB_SKILLS_OWNER}/${HUB_SKILLS_REPO}`,
1377
1864
  "--path",
1378
1865
  skillPathInRepo,
1379
1866
  "--dest",
@@ -1383,6 +1870,10 @@ function createCodexBridgeMiddleware() {
1383
1870
  ]);
1384
1871
  const skillDir = join(installDest, name);
1385
1872
  await ensureInstalledSkillIsValid(appServer, skillDir);
1873
+ const syncState = await readSkillsSyncState();
1874
+ const nextOwners = { ...syncState.installedOwners ?? {}, [name]: owner };
1875
+ await writeSkillsSyncState({ ...syncState, installedOwners: nextOwners });
1876
+ await autoPushSyncedSkills(appServer);
1386
1877
  setJson(res, 200, { ok: true, path: skillDir });
1387
1878
  } catch (error) {
1388
1879
  setJson(res, 502, { error: getErrorMessage(error, "Failed to install skill") });
@@ -1400,6 +1891,13 @@ function createCodexBridgeMiddleware() {
1400
1891
  return;
1401
1892
  }
1402
1893
  await rm(target, { recursive: true, force: true });
1894
+ if (name) {
1895
+ const syncState = await readSkillsSyncState();
1896
+ const nextOwners = { ...syncState.installedOwners ?? {} };
1897
+ delete nextOwners[name];
1898
+ await writeSkillsSyncState({ ...syncState, installedOwners: nextOwners });
1899
+ }
1900
+ await autoPushSyncedSkills(appServer);
1403
1901
  try {
1404
1902
  await appServer.rpc("skills/list", { forceReload: true });
1405
1903
  } catch {
@@ -1447,7 +1945,6 @@ data: ${JSON.stringify({ ok: true })}
1447
1945
  }
1448
1946
  };
1449
1947
  middleware.dispose = () => {
1450
- threadSearchIndex = null;
1451
1948
  appServer.dispose();
1452
1949
  };
1453
1950
  middleware.subscribeNotifications = (listener) => {
@@ -1462,7 +1959,7 @@ data: ${JSON.stringify({ ok: true })}
1462
1959
  }
1463
1960
 
1464
1961
  // src/server/authMiddleware.ts
1465
- import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
1962
+ import { randomBytes, timingSafeEqual } from "crypto";
1466
1963
  var TOKEN_COOKIE = "codex_web_local_token";
1467
1964
  function constantTimeCompare(a, b) {
1468
1965
  const bufA = Buffer.from(a);
@@ -1560,7 +2057,7 @@ function createAuthSession(password) {
1560
2057
  res.status(401).json({ error: "Invalid password" });
1561
2058
  return;
1562
2059
  }
1563
- const token = randomBytes2(32).toString("hex");
2060
+ const token = randomBytes(32).toString("hex");
1564
2061
  validTokens.add(token);
1565
2062
  res.setHeader("Set-Cookie", `${TOKEN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict`);
1566
2063
  res.json({ ok: true });
@@ -1579,277 +2076,11 @@ function createAuthSession(password) {
1579
2076
  };
1580
2077
  }
1581
2078
 
1582
- // src/server/localBrowseUi.ts
1583
- import { dirname, extname, join as join2 } from "path";
1584
- import { open, readFile as readFile2, readdir as readdir2, stat as stat2 } from "fs/promises";
1585
- var TEXT_EDITABLE_EXTENSIONS = /* @__PURE__ */ new Set([
1586
- ".txt",
1587
- ".md",
1588
- ".json",
1589
- ".js",
1590
- ".ts",
1591
- ".tsx",
1592
- ".jsx",
1593
- ".css",
1594
- ".scss",
1595
- ".html",
1596
- ".htm",
1597
- ".xml",
1598
- ".yml",
1599
- ".yaml",
1600
- ".log",
1601
- ".csv",
1602
- ".env",
1603
- ".py",
1604
- ".sh",
1605
- ".toml",
1606
- ".ini",
1607
- ".conf",
1608
- ".sql",
1609
- ".bat",
1610
- ".cmd",
1611
- ".ps1"
1612
- ]);
1613
- function languageForPath(pathValue) {
1614
- const extension = extname(pathValue).toLowerCase();
1615
- switch (extension) {
1616
- case ".js":
1617
- return "javascript";
1618
- case ".ts":
1619
- return "typescript";
1620
- case ".jsx":
1621
- return "javascript";
1622
- case ".tsx":
1623
- return "typescript";
1624
- case ".py":
1625
- return "python";
1626
- case ".sh":
1627
- return "sh";
1628
- case ".css":
1629
- case ".scss":
1630
- return "css";
1631
- case ".html":
1632
- case ".htm":
1633
- return "html";
1634
- case ".json":
1635
- return "json";
1636
- case ".md":
1637
- return "markdown";
1638
- case ".yaml":
1639
- case ".yml":
1640
- return "yaml";
1641
- case ".xml":
1642
- return "xml";
1643
- case ".sql":
1644
- return "sql";
1645
- case ".toml":
1646
- return "ini";
1647
- case ".ini":
1648
- case ".conf":
1649
- return "ini";
1650
- default:
1651
- return "plaintext";
1652
- }
1653
- }
1654
- function normalizeLocalPath(rawPath) {
1655
- const trimmed = rawPath.trim();
1656
- if (!trimmed) return "";
1657
- if (trimmed.startsWith("file://")) {
1658
- try {
1659
- return decodeURIComponent(trimmed.replace(/^file:\/\//u, ""));
1660
- } catch {
1661
- return trimmed.replace(/^file:\/\//u, "");
1662
- }
1663
- }
1664
- return trimmed;
1665
- }
1666
- function decodeBrowsePath(rawPath) {
1667
- if (!rawPath) return "";
1668
- try {
1669
- return decodeURIComponent(rawPath);
1670
- } catch {
1671
- return rawPath;
1672
- }
1673
- }
1674
- function isTextEditablePath(pathValue) {
1675
- return TEXT_EDITABLE_EXTENSIONS.has(extname(pathValue).toLowerCase());
1676
- }
1677
- function looksLikeTextBuffer(buffer) {
1678
- if (buffer.length === 0) return true;
1679
- for (const byte of buffer) {
1680
- if (byte === 0) return false;
1681
- }
1682
- const decoded = buffer.toString("utf8");
1683
- const replacementCount = (decoded.match(/\uFFFD/gu) ?? []).length;
1684
- return replacementCount / decoded.length < 0.05;
1685
- }
1686
- async function probeFileIsText(localPath) {
1687
- const handle = await open(localPath, "r");
1688
- try {
1689
- const sample = Buffer.allocUnsafe(4096);
1690
- const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
1691
- return looksLikeTextBuffer(sample.subarray(0, bytesRead));
1692
- } finally {
1693
- await handle.close();
1694
- }
1695
- }
1696
- async function isTextEditableFile(localPath) {
1697
- if (isTextEditablePath(localPath)) return true;
1698
- try {
1699
- const fileStat = await stat2(localPath);
1700
- if (!fileStat.isFile()) return false;
1701
- return await probeFileIsText(localPath);
1702
- } catch {
1703
- return false;
1704
- }
1705
- }
1706
- function escapeHtml(value) {
1707
- return value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;").replace(/"/gu, "&quot;").replace(/'/gu, "&#39;");
1708
- }
1709
- function toBrowseHref(pathValue) {
1710
- return `/codex-local-browse${encodeURI(pathValue)}`;
1711
- }
1712
- function toEditHref(pathValue) {
1713
- return `/codex-local-edit${encodeURI(pathValue)}`;
1714
- }
1715
- function escapeForInlineScriptString(value) {
1716
- return JSON.stringify(value).replace(/<\//gu, "<\\/").replace(/<!--/gu, "<\\!--").replace(/\u2028/gu, "\\u2028").replace(/\u2029/gu, "\\u2029");
1717
- }
1718
- async function getDirectoryItems(localPath) {
1719
- const entries = await readdir2(localPath, { withFileTypes: true });
1720
- const withMeta = await Promise.all(entries.map(async (entry) => {
1721
- const entryPath = join2(localPath, entry.name);
1722
- const entryStat = await stat2(entryPath);
1723
- const editable = !entry.isDirectory() && await isTextEditableFile(entryPath);
1724
- return {
1725
- name: entry.name,
1726
- path: entryPath,
1727
- isDirectory: entry.isDirectory(),
1728
- editable,
1729
- mtimeMs: entryStat.mtimeMs
1730
- };
1731
- }));
1732
- return withMeta.sort((a, b) => {
1733
- if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs;
1734
- if (a.isDirectory && !b.isDirectory) return -1;
1735
- if (!a.isDirectory && b.isDirectory) return 1;
1736
- return a.name.localeCompare(b.name);
1737
- });
1738
- }
1739
- async function createDirectoryListingHtml(localPath) {
1740
- const items = await getDirectoryItems(localPath);
1741
- const parentPath = dirname(localPath);
1742
- const rows = items.map((item) => {
1743
- const suffix = item.isDirectory ? "/" : "";
1744
- const editAction = item.editable ? ` <a class="icon-btn" aria-label="Edit ${escapeHtml(item.name)}" href="${escapeHtml(toEditHref(item.path))}" title="Edit">\u270F\uFE0F</a>` : "";
1745
- return `<li class="file-row"><a class="file-link" href="${escapeHtml(toBrowseHref(item.path))}">${escapeHtml(item.name)}${suffix}</a>${editAction}</li>`;
1746
- }).join("\n");
1747
- const parentLink = localPath !== parentPath ? `<p><a href="${escapeHtml(toBrowseHref(parentPath))}">..</a></p>` : "";
1748
- return `<!doctype html>
1749
- <html lang="en">
1750
- <head>
1751
- <meta charset="utf-8" />
1752
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1753
- <title>Index of ${escapeHtml(localPath)}</title>
1754
- <style>
1755
- body { font-family: ui-monospace, Menlo, Monaco, monospace; margin: 16px; background: #0b1020; color: #dbe6ff; }
1756
- a { color: #8cc2ff; text-decoration: none; }
1757
- a:hover { text-decoration: underline; }
1758
- ul { list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 8px; }
1759
- .file-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 10px; }
1760
- .file-link { display: block; padding: 10px 12px; border: 1px solid #28405f; border-radius: 10px; background: #0f1b33; overflow-wrap: anywhere; }
1761
- .icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 42px; height: 42px; border: 1px solid #36557a; border-radius: 10px; background: #162643; text-decoration: none; }
1762
- .icon-btn:hover { filter: brightness(1.08); text-decoration: none; }
1763
- h1 { font-size: 18px; margin: 0; word-break: break-all; }
1764
- @media (max-width: 640px) {
1765
- body { margin: 12px; }
1766
- .file-row { gap: 8px; }
1767
- .file-link { font-size: 15px; padding: 12px; }
1768
- .icon-btn { width: 44px; height: 44px; }
1769
- }
1770
- </style>
1771
- </head>
1772
- <body>
1773
- <h1>Index of ${escapeHtml(localPath)}</h1>
1774
- ${parentLink}
1775
- <ul>${rows}</ul>
1776
- </body>
1777
- </html>`;
1778
- }
1779
- async function createTextEditorHtml(localPath) {
1780
- const content = await readFile2(localPath, "utf8");
1781
- const parentPath = dirname(localPath);
1782
- const language = languageForPath(localPath);
1783
- const safeContentLiteral = escapeForInlineScriptString(content);
1784
- return `<!doctype html>
1785
- <html lang="en">
1786
- <head>
1787
- <meta charset="utf-8" />
1788
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1789
- <title>Edit ${escapeHtml(localPath)}</title>
1790
- <style>
1791
- html, body { width: 100%; height: 100%; margin: 0; }
1792
- body { font-family: ui-monospace, Menlo, Monaco, monospace; background: #0b1020; color: #dbe6ff; display: flex; flex-direction: column; overflow: hidden; }
1793
- .toolbar { position: sticky; top: 0; z-index: 10; display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; background: #0b1020; border-bottom: 1px solid #243a5a; }
1794
- .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
1795
- button, a { background: #1b2a4a; color: #dbe6ff; border: 1px solid #345; padding: 6px 10px; border-radius: 6px; text-decoration: none; cursor: pointer; }
1796
- button:hover, a:hover { filter: brightness(1.08); }
1797
- #editor { flex: 1 1 auto; min-height: 0; width: 100%; border: none; overflow: hidden; }
1798
- #status { margin-left: 8px; color: #8cc2ff; }
1799
- .ace_editor { background: #07101f !important; color: #dbe6ff !important; width: 100% !important; height: 100% !important; }
1800
- .ace_gutter { background: #07101f !important; color: #6f8eb5 !important; }
1801
- .ace_marker-layer .ace_active-line { background: #10213c !important; }
1802
- .ace_marker-layer .ace_selection { background: rgba(140, 194, 255, 0.3) !important; }
1803
- .meta { opacity: 0.9; font-size: 12px; overflow-wrap: anywhere; }
1804
- </style>
1805
- </head>
1806
- <body>
1807
- <div class="toolbar">
1808
- <div class="row">
1809
- <a href="${escapeHtml(toBrowseHref(parentPath))}">Back</a>
1810
- <button id="saveBtn" type="button">Save</button>
1811
- <span id="status"></span>
1812
- </div>
1813
- <div class="meta">${escapeHtml(localPath)} \xB7 ${escapeHtml(language)}</div>
1814
- </div>
1815
- <div id="editor"></div>
1816
- <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.2/ace.js"></script>
1817
- <script>
1818
- const saveBtn = document.getElementById('saveBtn');
1819
- const status = document.getElementById('status');
1820
- const editor = ace.edit('editor');
1821
- editor.setTheme('ace/theme/tomorrow_night');
1822
- editor.session.setMode('ace/mode/${escapeHtml(language)}');
1823
- editor.setValue(${safeContentLiteral}, -1);
1824
- editor.setOptions({
1825
- fontSize: '13px',
1826
- wrap: true,
1827
- showPrintMargin: false,
1828
- useSoftTabs: true,
1829
- tabSize: 2,
1830
- behavioursEnabled: true,
1831
- });
1832
- editor.resize();
1833
-
1834
- saveBtn.addEventListener('click', async () => {
1835
- status.textContent = 'Saving...';
1836
- const response = await fetch(location.pathname, {
1837
- method: 'PUT',
1838
- headers: { 'Content-Type': 'text/plain; charset=utf-8' },
1839
- body: editor.getValue(),
1840
- });
1841
- status.textContent = response.ok ? 'Saved' : 'Save failed';
1842
- });
1843
- </script>
1844
- </body>
1845
- </html>`;
1846
- }
1847
-
1848
2079
  // src/server/httpServer.ts
1849
2080
  import { WebSocketServer } from "ws";
1850
- var __dirname = dirname2(fileURLToPath(import.meta.url));
1851
- var distDir = join3(__dirname, "..", "dist");
1852
- var spaEntryFile = join3(distDir, "index.html");
2081
+ var __dirname = dirname(fileURLToPath(import.meta.url));
2082
+ var distDir = join2(__dirname, "..", "dist");
2083
+ var spaEntryFile = join2(distDir, "index.html");
1853
2084
  var IMAGE_CONTENT_TYPES = {
1854
2085
  ".avif": "image/avif",
1855
2086
  ".bmp": "image/bmp",
@@ -1872,11 +2103,6 @@ function normalizeLocalImagePath(rawPath) {
1872
2103
  }
1873
2104
  return trimmed;
1874
2105
  }
1875
- function readWildcardPathParam(value) {
1876
- if (typeof value === "string") return value;
1877
- if (Array.isArray(value)) return value.join("/");
1878
- return "";
1879
- }
1880
2106
  function createServer(options = {}) {
1881
2107
  const app = express();
1882
2108
  const bridge = createCodexBridgeMiddleware();
@@ -1892,7 +2118,7 @@ function createServer(options = {}) {
1892
2118
  res.status(400).json({ error: "Expected absolute local file path." });
1893
2119
  return;
1894
2120
  }
1895
- const contentType = IMAGE_CONTENT_TYPES[extname2(localPath).toLowerCase()];
2121
+ const contentType = IMAGE_CONTENT_TYPES[extname(localPath).toLowerCase()];
1896
2122
  if (!contentType) {
1897
2123
  res.status(415).json({ error: "Unsupported image type." });
1898
2124
  return;
@@ -1904,82 +2130,7 @@ function createServer(options = {}) {
1904
2130
  if (!res.headersSent) res.status(404).json({ error: "Image file not found." });
1905
2131
  });
1906
2132
  });
1907
- app.get("/codex-local-file", (req, res) => {
1908
- const rawPath = typeof req.query.path === "string" ? req.query.path : "";
1909
- const localPath = normalizeLocalPath(rawPath);
1910
- if (!localPath || !isAbsolute2(localPath)) {
1911
- res.status(400).json({ error: "Expected absolute local file path." });
1912
- return;
1913
- }
1914
- res.setHeader("Cache-Control", "private, no-store");
1915
- res.setHeader("Content-Disposition", "inline");
1916
- res.sendFile(localPath, { dotfiles: "allow" }, (error) => {
1917
- if (!error) return;
1918
- if (!res.headersSent) res.status(404).json({ error: "File not found." });
1919
- });
1920
- });
1921
- app.get("/codex-local-browse/*path", async (req, res) => {
1922
- const rawPath = readWildcardPathParam(req.params.path);
1923
- const localPath = decodeBrowsePath(`/${rawPath}`);
1924
- if (!localPath || !isAbsolute2(localPath)) {
1925
- res.status(400).json({ error: "Expected absolute local file path." });
1926
- return;
1927
- }
1928
- try {
1929
- const fileStat = await stat3(localPath);
1930
- res.setHeader("Cache-Control", "private, no-store");
1931
- if (fileStat.isDirectory()) {
1932
- const html = await createDirectoryListingHtml(localPath);
1933
- res.status(200).type("text/html; charset=utf-8").send(html);
1934
- return;
1935
- }
1936
- res.sendFile(localPath, { dotfiles: "allow" }, (error) => {
1937
- if (!error) return;
1938
- if (!res.headersSent) res.status(404).json({ error: "File not found." });
1939
- });
1940
- } catch {
1941
- res.status(404).json({ error: "File not found." });
1942
- }
1943
- });
1944
- app.get("/codex-local-edit/*path", async (req, res) => {
1945
- const rawPath = readWildcardPathParam(req.params.path);
1946
- const localPath = decodeBrowsePath(`/${rawPath}`);
1947
- if (!localPath || !isAbsolute2(localPath)) {
1948
- res.status(400).json({ error: "Expected absolute local file path." });
1949
- return;
1950
- }
1951
- try {
1952
- const fileStat = await stat3(localPath);
1953
- if (!fileStat.isFile()) {
1954
- res.status(400).json({ error: "Expected file path." });
1955
- return;
1956
- }
1957
- const html = await createTextEditorHtml(localPath);
1958
- res.status(200).type("text/html; charset=utf-8").send(html);
1959
- } catch {
1960
- res.status(404).json({ error: "File not found." });
1961
- }
1962
- });
1963
- app.put("/codex-local-edit/*path", express.text({ type: "*/*", limit: "10mb" }), async (req, res) => {
1964
- const rawPath = readWildcardPathParam(req.params.path);
1965
- const localPath = decodeBrowsePath(`/${rawPath}`);
1966
- if (!localPath || !isAbsolute2(localPath)) {
1967
- res.status(400).json({ error: "Expected absolute local file path." });
1968
- return;
1969
- }
1970
- if (!await isTextEditableFile(localPath)) {
1971
- res.status(415).json({ error: "Only text-like files are editable." });
1972
- return;
1973
- }
1974
- const body = typeof req.body === "string" ? req.body : "";
1975
- try {
1976
- await writeFile2(localPath, body, "utf8");
1977
- res.status(200).json({ ok: true });
1978
- } catch {
1979
- res.status(404).json({ error: "File not found." });
1980
- }
1981
- });
1982
- const hasFrontendAssets = existsSync(spaEntryFile);
2133
+ const hasFrontendAssets = existsSync2(spaEntryFile);
1983
2134
  if (hasFrontendAssets) {
1984
2135
  app.use(express.static(distDir));
1985
2136
  }
@@ -2050,11 +2201,11 @@ function generatePassword() {
2050
2201
 
2051
2202
  // src/cli/index.ts
2052
2203
  var program = new Command().name("codexui").description("Web interface for Codex app-server");
2053
- var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
2204
+ var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
2054
2205
  async function readCliVersion() {
2055
2206
  try {
2056
- const packageJsonPath = join4(__dirname2, "..", "package.json");
2057
- const raw = await readFile3(packageJsonPath, "utf8");
2207
+ const packageJsonPath = join3(__dirname2, "..", "package.json");
2208
+ const raw = await readFile2(packageJsonPath, "utf8");
2058
2209
  const parsed = JSON.parse(raw);
2059
2210
  return typeof parsed.version === "string" ? parsed.version : "unknown";
2060
2211
  } catch {
@@ -2079,127 +2230,29 @@ function runWithStatus(command, args) {
2079
2230
  return result.status ?? -1;
2080
2231
  }
2081
2232
  function getUserNpmPrefix() {
2082
- return join4(homedir2(), ".npm-global");
2233
+ return join3(homedir2(), ".npm-global");
2083
2234
  }
2084
2235
  function resolveCodexCommand() {
2085
2236
  if (canRun("codex", ["--version"])) {
2086
2237
  return "codex";
2087
2238
  }
2088
- const userCandidate = join4(getUserNpmPrefix(), "bin", "codex");
2089
- if (existsSync2(userCandidate) && canRun(userCandidate, ["--version"])) {
2239
+ const userCandidate = join3(getUserNpmPrefix(), "bin", "codex");
2240
+ if (existsSync3(userCandidate) && canRun(userCandidate, ["--version"])) {
2090
2241
  return userCandidate;
2091
2242
  }
2092
2243
  const prefix = process.env.PREFIX?.trim();
2093
2244
  if (!prefix) {
2094
2245
  return null;
2095
2246
  }
2096
- const candidate = join4(prefix, "bin", "codex");
2097
- if (existsSync2(candidate) && canRun(candidate, ["--version"])) {
2247
+ const candidate = join3(prefix, "bin", "codex");
2248
+ if (existsSync3(candidate) && canRun(candidate, ["--version"])) {
2098
2249
  return candidate;
2099
2250
  }
2100
2251
  return null;
2101
2252
  }
2102
- function resolveCloudflaredCommand() {
2103
- if (canRun("cloudflared", ["--version"])) {
2104
- return "cloudflared";
2105
- }
2106
- const localCandidate = join4(homedir2(), ".local", "bin", "cloudflared");
2107
- if (existsSync2(localCandidate) && canRun(localCandidate, ["--version"])) {
2108
- return localCandidate;
2109
- }
2110
- return null;
2111
- }
2112
- function mapCloudflaredLinuxArch(arch) {
2113
- if (arch === "x64") {
2114
- return "amd64";
2115
- }
2116
- if (arch === "arm64") {
2117
- return "arm64";
2118
- }
2119
- return null;
2120
- }
2121
- function downloadFile(url, destination) {
2122
- return new Promise((resolve2, reject) => {
2123
- const request = (currentUrl) => {
2124
- httpsGet(currentUrl, (response) => {
2125
- const code = response.statusCode ?? 0;
2126
- if (code >= 300 && code < 400 && response.headers.location) {
2127
- response.resume();
2128
- request(response.headers.location);
2129
- return;
2130
- }
2131
- if (code !== 200) {
2132
- response.resume();
2133
- reject(new Error(`Download failed with HTTP status ${String(code)}`));
2134
- return;
2135
- }
2136
- const file = createWriteStream(destination, { mode: 493 });
2137
- response.pipe(file);
2138
- file.on("finish", () => {
2139
- file.close();
2140
- resolve2();
2141
- });
2142
- file.on("error", reject);
2143
- }).on("error", reject);
2144
- };
2145
- request(url);
2146
- });
2147
- }
2148
- async function ensureCloudflaredInstalledLinux() {
2149
- const current = resolveCloudflaredCommand();
2150
- if (current) {
2151
- return current;
2152
- }
2153
- if (process.platform !== "linux") {
2154
- return null;
2155
- }
2156
- const mappedArch = mapCloudflaredLinuxArch(process.arch);
2157
- if (!mappedArch) {
2158
- throw new Error(`cloudflared auto-install is not supported for Linux architecture: ${process.arch}`);
2159
- }
2160
- const userBinDir = join4(homedir2(), ".local", "bin");
2161
- mkdirSync(userBinDir, { recursive: true });
2162
- const destination = join4(userBinDir, "cloudflared");
2163
- const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${mappedArch}`;
2164
- console.log("\ncloudflared not found. Installing to ~/.local/bin...\n");
2165
- await downloadFile(downloadUrl, destination);
2166
- chmodSync(destination, 493);
2167
- process.env.PATH = `${userBinDir}:${process.env.PATH ?? ""}`;
2168
- const installed = resolveCloudflaredCommand();
2169
- if (!installed) {
2170
- throw new Error("cloudflared download completed but executable is still not available");
2171
- }
2172
- console.log("\ncloudflared installed.\n");
2173
- return installed;
2174
- }
2175
- async function shouldInstallCloudflaredInteractively() {
2176
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
2177
- console.warn("\n[cloudflared] cloudflared is missing and terminal is non-interactive, skipping install.");
2178
- return false;
2179
- }
2180
- const prompt = createInterface({ input: process.stdin, output: process.stdout });
2181
- try {
2182
- const answer = await prompt.question("cloudflared is not installed. Install it now to ~/.local/bin? [y/N] ");
2183
- const normalized = answer.trim().toLowerCase();
2184
- return normalized === "y" || normalized === "yes";
2185
- } finally {
2186
- prompt.close();
2187
- }
2188
- }
2189
- async function resolveCloudflaredForTunnel() {
2190
- const current = resolveCloudflaredCommand();
2191
- if (current) {
2192
- return current;
2193
- }
2194
- const installApproved = await shouldInstallCloudflaredInteractively();
2195
- if (!installApproved) {
2196
- return null;
2197
- }
2198
- return ensureCloudflaredInstalledLinux();
2199
- }
2200
2253
  function hasCodexAuth() {
2201
- const codexHome = process.env.CODEX_HOME?.trim() || join4(homedir2(), ".codex");
2202
- return existsSync2(join4(codexHome, "auth.json"));
2254
+ const codexHome = process.env.CODEX_HOME?.trim() || join3(homedir2(), ".codex");
2255
+ return existsSync3(join3(codexHome, "auth.json"));
2203
2256
  }
2204
2257
  function ensureCodexInstalled() {
2205
2258
  let codexCommand = resolveCodexCommand();
@@ -2217,7 +2270,7 @@ function ensureCodexInstalled() {
2217
2270
  Global npm install requires elevated permissions. Retrying with --prefix ${userPrefix}...
2218
2271
  `);
2219
2272
  runOrFail("npm", ["install", "-g", "--prefix", userPrefix, pkg], `${label} (user prefix)`);
2220
- process.env.PATH = `${join4(userPrefix, "bin")}:${process.env.PATH ?? ""}`;
2273
+ process.env.PATH = `${join3(userPrefix, "bin")}:${process.env.PATH ?? ""}`;
2221
2274
  };
2222
2275
  if (isTermuxRuntime()) {
2223
2276
  console.log("\nCodex CLI not found. Installing Termux-compatible Codex CLI from npm...\n");
@@ -2278,27 +2331,9 @@ function parseCloudflaredUrl(chunk) {
2278
2331
  }
2279
2332
  return urlMatch[urlMatch.length - 1] ?? null;
2280
2333
  }
2281
- function getAccessibleUrls(port) {
2282
- const urls = /* @__PURE__ */ new Set([`http://localhost:${String(port)}`]);
2283
- const interfaces = networkInterfaces();
2284
- for (const entries of Object.values(interfaces)) {
2285
- if (!entries) {
2286
- continue;
2287
- }
2288
- for (const entry of entries) {
2289
- if (entry.internal) {
2290
- continue;
2291
- }
2292
- if (entry.family === "IPv4") {
2293
- urls.add(`http://${entry.address}:${String(port)}`);
2294
- }
2295
- }
2296
- }
2297
- return Array.from(urls);
2298
- }
2299
- async function startCloudflaredTunnel(command, localPort) {
2334
+ async function startCloudflaredTunnel(localPort) {
2300
2335
  return new Promise((resolve2, reject) => {
2301
- const child = spawn2(command, ["tunnel", "--url", `http://localhost:${String(localPort)}`], {
2336
+ const child = spawn2("cloudflared", ["tunnel", "--url", `http://localhost:${String(localPort)}`], {
2302
2337
  stdio: ["ignore", "pipe", "pipe"]
2303
2338
  });
2304
2339
  const timeout = setTimeout(() => {
@@ -2349,7 +2384,7 @@ function listenWithFallback(server, startPort) {
2349
2384
  };
2350
2385
  server.once("error", onError);
2351
2386
  server.once("listening", onListening);
2352
- server.listen(port, "0.0.0.0");
2387
+ server.listen(port);
2353
2388
  };
2354
2389
  attempt(startPort);
2355
2390
  });
@@ -2371,11 +2406,7 @@ async function startServer(options) {
2371
2406
  let tunnelUrl = null;
2372
2407
  if (options.tunnel) {
2373
2408
  try {
2374
- const cloudflaredCommand = await resolveCloudflaredForTunnel();
2375
- if (!cloudflaredCommand) {
2376
- throw new Error("cloudflared is not installed");
2377
- }
2378
- const tunnel = await startCloudflaredTunnel(cloudflaredCommand, port);
2409
+ const tunnel = await startCloudflaredTunnel(port);
2379
2410
  tunnelChild = tunnel.process;
2380
2411
  tunnelUrl = tunnel.url;
2381
2412
  } catch (error) {
@@ -2390,15 +2421,8 @@ async function startServer(options) {
2390
2421
  ` Version: ${version}`,
2391
2422
  " GitHub: https://github.com/friuns2/codexui",
2392
2423
  "",
2393
- ` Bind: http://0.0.0.0:${String(port)}`
2424
+ ` Local: http://localhost:${String(port)}`
2394
2425
  ];
2395
- const accessUrls = getAccessibleUrls(port);
2396
- if (accessUrls.length > 0) {
2397
- lines.push(` Local: ${accessUrls[0]}`);
2398
- for (const accessUrl of accessUrls.slice(1)) {
2399
- lines.push(` Network: ${accessUrl}`);
2400
- }
2401
- }
2402
2426
  if (port !== requestedPort) {
2403
2427
  lines.push(` Requested port ${String(requestedPort)} was unavailable; using ${String(port)}.`);
2404
2428
  }
@@ -2407,7 +2431,9 @@ async function startServer(options) {
2407
2431
  }
2408
2432
  if (tunnelUrl) {
2409
2433
  lines.push(` Tunnel: ${tunnelUrl}`);
2410
- lines.push(" Tunnel QR code below");
2434
+ lines.push("");
2435
+ lines.push(" Tunnel QR code:");
2436
+ lines.push(` URL: ${tunnelUrl}`);
2411
2437
  }
2412
2438
  printTermuxKeepAlive(lines);
2413
2439
  lines.push("");