sneakoscope 5.1.2 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +5 -1
  2. package/crates/sks-core/Cargo.lock +1 -1
  3. package/crates/sks-core/Cargo.toml +1 -1
  4. package/crates/sks-core/src/main.rs +1 -1
  5. package/dist/bin/sks.js +1 -1
  6. package/dist/commands/doctor.js +32 -3
  7. package/dist/config/skills-manifest.json +58 -58
  8. package/dist/core/agents/agent-lifecycle.js +5 -3
  9. package/dist/core/agents/agent-orchestrator.js +14 -2
  10. package/dist/core/agents/agent-proof-evidence.js +16 -0
  11. package/dist/core/agents/native-cli-worker.js +0 -1
  12. package/dist/core/bench.js +24 -2
  13. package/dist/core/codex-app/sks-menubar.js +526 -133
  14. package/dist/core/commands/agent-command.js +35 -1
  15. package/dist/core/commands/basic-cli.js +61 -4
  16. package/dist/core/commands/command-utils.js +14 -4
  17. package/dist/core/commands/goal-command.js +3 -0
  18. package/dist/core/commands/image-ux-review-command.js +4 -1
  19. package/dist/core/commands/mad-sks-command.js +10 -1
  20. package/dist/core/commands/menubar-command.js +2 -1
  21. package/dist/core/commands/naruto-command.js +12 -7
  22. package/dist/core/commands/ppt-command.js +40 -1
  23. package/dist/core/commands/qa-loop-command.js +4 -1
  24. package/dist/core/commands/release-command.js +3 -3
  25. package/dist/core/commands/research-command.js +4 -0
  26. package/dist/core/commands/route-success-helpers.js +2 -0
  27. package/dist/core/commands/run-command.js +22 -5
  28. package/dist/core/commands/seo-command.js +31 -4
  29. package/dist/core/db-safety.js +8 -1
  30. package/dist/core/doctor/doctor-dirty-planner.js +51 -7
  31. package/dist/core/feature-fixture-executor.js +262 -0
  32. package/dist/core/feature-fixtures.js +95 -52
  33. package/dist/core/fsx.js +1 -1
  34. package/dist/core/loops/loop-decomposer.js +4 -3
  35. package/dist/core/loops/loop-planner.js +7 -3
  36. package/dist/core/loops/loop-worker-runtime.js +6 -0
  37. package/dist/core/naruto/naruto-real-worker-child.js +1 -1
  38. package/dist/core/pipeline-internals/runtime-gates.js +39 -3
  39. package/dist/core/proof/selftest-proof-fixtures.js +8 -0
  40. package/dist/core/providers/glm/naruto/glm-naruto-requirement-coverage.js +18 -1
  41. package/dist/core/providers/glm/naruto/glm-naruto-requirement-ledger.js +28 -12
  42. package/dist/core/questions.js +12 -10
  43. package/dist/core/release/release-gate-node.js +2 -0
  44. package/dist/core/routes.js +11 -0
  45. package/dist/core/stop-gate/stop-gate-check.js +4 -0
  46. package/dist/core/stop-gate/stop-gate-writer.js +4 -0
  47. package/dist/core/trust-kernel/trust-report.js +10 -1
  48. package/dist/core/update/update-migration-state.js +23 -3
  49. package/dist/core/version.js +1 -1
  50. package/dist/core/work-order-ledger.js +60 -0
  51. package/dist/core/zellij/zellij-slot-telemetry.js +1 -1
  52. package/dist/scripts/doctor-context7-mcp-repair-blackbox.js +16 -0
  53. package/dist/scripts/doctor-fix-production-blackbox.js +26 -0
  54. package/dist/scripts/doctor-startup-config-repair-blackbox.js +13 -0
  55. package/dist/scripts/doctor-supabase-mcp-repair-blackbox.js +14 -0
  56. package/dist/scripts/gate-policy-audit-check.js +2 -2
  57. package/dist/scripts/naruto-real-active-pool-runtime-check.js +26 -1
  58. package/dist/scripts/packlist-performance-check.js +7 -1
  59. package/dist/scripts/project-skill-dedupe-blackbox.js +36 -0
  60. package/dist/scripts/release-dag-full-coverage-check.js +1 -1
  61. package/dist/scripts/release-gate-existence-audit.js +1 -1
  62. package/dist/scripts/release-gate-planner.js +1 -1
  63. package/dist/scripts/release-gate-script-parity-check.js +44 -16
  64. package/dist/scripts/release-metadata-1-19-check.js +2 -2
  65. package/dist/scripts/sks-menubar-install-check.js +57 -20
  66. package/dist/scripts/supabase-secret-preservation-blackbox.js +29 -0
  67. package/dist/scripts/update-preserves-supabase-keys-blackbox.js +27 -0
  68. package/dist/scripts/zellij-slot-telemetry-runtime-check.js +1 -0
  69. package/package.json +12 -65
  70. package/schemas/release/release-gate-node.schema.json +1 -0
  71. package/bench/tasks/t01-off-by-one/repo/package.json +0 -9
  72. package/bench/tasks/t01-off-by-one/repo/src/pagination.js +0 -5
  73. package/bench/tasks/t01-off-by-one/repo/test.js +0 -5
  74. package/bench/tasks/t01-off-by-one/task.json +0 -13
  75. package/bench/tasks/t02-signature-cochange/repo/package.json +0 -9
  76. package/bench/tasks/t02-signature-cochange/repo/src/admin.js +0 -5
  77. package/bench/tasks/t02-signature-cochange/repo/src/audit.js +0 -5
  78. package/bench/tasks/t02-signature-cochange/repo/src/labels.js +0 -3
  79. package/bench/tasks/t02-signature-cochange/repo/src/profile.js +0 -5
  80. package/bench/tasks/t02-signature-cochange/repo/test.js +0 -9
  81. package/bench/tasks/t02-signature-cochange/task.json +0 -12
  82. package/bench/tasks/t03-type-puzzle/repo/package.json +0 -9
  83. package/bench/tasks/t03-type-puzzle/repo/src/config.js +0 -4
  84. package/bench/tasks/t03-type-puzzle/repo/test.js +0 -6
  85. package/bench/tasks/t03-type-puzzle/task.json +0 -13
  86. package/bench/tasks/t04-refactor-preserve/repo/package.json +0 -9
  87. package/bench/tasks/t04-refactor-preserve/repo/src/cart.js +0 -8
  88. package/bench/tasks/t04-refactor-preserve/repo/test.js +0 -4
  89. package/bench/tasks/t04-refactor-preserve/task.json +0 -12
  90. package/bench/tasks/t05-performance/repo/package.json +0 -9
  91. package/bench/tasks/t05-performance/repo/src/pairs.js +0 -9
  92. package/bench/tasks/t05-performance/repo/test.js +0 -11
  93. package/bench/tasks/t05-performance/task.json +0 -12
  94. package/bench/tasks/t06-mistake-rule/repo/package.json +0 -9
  95. package/bench/tasks/t06-mistake-rule/repo/src/loader.js +0 -6
  96. package/bench/tasks/t06-mistake-rule/repo/test.js +0 -7
  97. package/bench/tasks/t06-mistake-rule/task.json +0 -12
@@ -3,6 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { ensureDir, exists, PACKAGE_VERSION, readJson, readText, runProcess, sha256, which, writeJsonAtomic, writeTextAtomic } from '../fsx.js';
6
+ import { findCodexApp } from '../codex-app.js';
6
7
  import { withHeartbeat } from '../../cli/cli-theme.js';
7
8
  export const SKS_MENUBAR_LABEL = 'com.sneakoscope.sks-menubar';
8
9
  const LABEL = SKS_MENUBAR_LABEL;
@@ -20,6 +21,7 @@ const MENU_ITEMS = [
20
21
  'Open Dashboard',
21
22
  'Open Codex Settings',
22
23
  'Restart Codex',
24
+ 'View Last Log',
23
25
  'Quit SKS Menu'
24
26
  ];
25
27
  export function sksMenuBarPaths(homeInput, rootInput) {
@@ -43,10 +45,13 @@ export function sksMenuBarPaths(homeInput, rootInput) {
43
45
  info_plist_path: path.join(contentsPath, 'Info.plist'),
44
46
  action_script_path: path.join(installDir, 'sks-menubar-action.sh'),
45
47
  build_stamp_path: path.join(installDir, 'build-stamp.json'),
48
+ config_path: path.join(installDir, 'config.json'),
46
49
  launch_agent_path: path.join(home, 'Library', 'LaunchAgents', `${LABEL}.plist`),
47
50
  report_path: path.join(root, '.sneakoscope', 'reports', 'sks-menubar.json'),
48
51
  stdout_log_path: path.join(installDir, 'menubar.out.log'),
49
- stderr_log_path: path.join(installDir, 'menubar.err.log')
52
+ stderr_log_path: path.join(installDir, 'menubar.err.log'),
53
+ logs_dir: path.join(installDir, 'logs'),
54
+ last_action_log_path: path.join(installDir, 'logs', 'last-action.log')
50
55
  };
51
56
  }
52
57
  export async function installSksMenuBar(opts = {}) {
@@ -57,6 +62,7 @@ export async function installSksMenuBar(opts = {}) {
57
62
  const warnings = [];
58
63
  const nextActions = defaultNextActions();
59
64
  let secretEnvCleanup;
65
+ let codexBundleId = null;
60
66
  if (process.platform !== 'darwin') {
61
67
  const result = {
62
68
  schema: 'sks.codex-app-sks-menubar.v1',
@@ -69,7 +75,9 @@ export async function installSksMenuBar(opts = {}) {
69
75
  launch_agent_path: null,
70
76
  action_script_path: null,
71
77
  build_stamp_path: null,
78
+ config_path: null,
72
79
  report_path: apply ? paths.report_path : null,
80
+ codex_bundle_id: null,
73
81
  menu_items: MENU_ITEMS,
74
82
  actions: [],
75
83
  launch: { requested: false, method: 'none', ok: true },
@@ -86,9 +94,10 @@ export async function installSksMenuBar(opts = {}) {
86
94
  const installed = await exists(paths.executable_path);
87
95
  const launchAgent = await exists(paths.launch_agent_path);
88
96
  const status = await inspectSksMenuBarStatus({ home: paths.home, root: paths.root }).catch(() => null);
97
+ const statusWarnings = [...(status?.warnings || []), ...(launchAgent ? [] : ['launch_agent_not_installed_yet'])];
89
98
  return {
90
99
  schema: 'sks.codex-app-sks-menubar.v1',
91
- ok: true,
100
+ ok: status ? status.ok === true : true,
92
101
  apply,
93
102
  status: 'planned',
94
103
  platform: process.platform,
@@ -97,7 +106,9 @@ export async function installSksMenuBar(opts = {}) {
97
106
  launch_agent_path: paths.launch_agent_path,
98
107
  action_script_path: paths.action_script_path,
99
108
  build_stamp_path: paths.build_stamp_path,
109
+ config_path: paths.config_path,
100
110
  report_path: paths.report_path,
111
+ codex_bundle_id: status?.codex_sync.bundle_id || null,
101
112
  menu_items: MENU_ITEMS,
102
113
  actions: installed ? ['menubar_app_present'] : ['menubar_app_install_available'],
103
114
  launch: {
@@ -116,11 +127,12 @@ export async function installSksMenuBar(opts = {}) {
116
127
  build_stamp: status?.build_stamp || null,
117
128
  tcc_automation_status: 'unknown',
118
129
  next_actions: launchAgent ? defaultNextActions() : ['Run: sks menubar install'],
119
- blockers: [],
120
- warnings: launchAgent ? [] : ['launch_agent_not_installed_yet']
130
+ blockers: status?.blockers || [],
131
+ warnings: statusWarnings
121
132
  };
122
133
  }
123
134
  await ensureDir(paths.install_dir);
135
+ await ensureDir(paths.logs_dir);
124
136
  await ensureDir(path.dirname(paths.launch_agent_path));
125
137
  secretEnvCleanup = await cleanupMacLaunchSecretEnvironment({ env }).catch((err) => ({
126
138
  ok: false,
@@ -139,9 +151,17 @@ export async function installSksMenuBar(opts = {}) {
139
151
  const open = env.SKS_MENUBAR_OPEN || await which('open').catch(() => null) || await fallbackTool('/usr/bin/open');
140
152
  const codesign = env.SKS_MENUBAR_CODESIGN || await which('codesign').catch(() => null) || await fallbackTool('/usr/bin/codesign');
141
153
  const xcodeSelect = env.SKS_MENUBAR_XCODE_SELECT || await which('xcode-select').catch(() => null) || await fallbackTool('/usr/bin/xcode-select');
154
+ const clt = await xcodeCltStatus(xcodeSelect);
155
+ if (!clt.ok)
156
+ return await blockedResult('xcode_clt_missing', clt.error || 'Xcode Command Line Tools missing');
142
157
  if (!swiftc)
143
158
  return await blockedResult('swiftc_missing', 'swiftc not found');
144
159
  const swiftcVersion = await toolVersion(swiftc, ['--version']);
160
+ codexBundleId = await resolveCodexBundleId({ home: paths.home, env, warnings });
161
+ if (!codexBundleId)
162
+ warnings.push('codex_app_bundle_id_unresolved');
163
+ const config = await writeDefaultMenuBarConfig(paths.config_path, codexBundleId);
164
+ actions.push(`wrote ${paths.config_path}`);
145
165
  const target = await resolveSksEntryForInstall({
146
166
  ...(opts.sksEntry ? { explicit: opts.sksEntry } : {}),
147
167
  root: paths.root,
@@ -162,6 +182,9 @@ export async function installSksMenuBar(opts = {}) {
162
182
  const swiftSource = swiftMenuSource({
163
183
  actionScriptPath: paths.action_script_path,
164
184
  buildStampPath: paths.build_stamp_path,
185
+ configPath: paths.config_path,
186
+ lastActionLogPath: paths.last_action_log_path,
187
+ codexBundleId: config.codex_bundle_id,
165
188
  packageVersion: PACKAGE_VERSION
166
189
  });
167
190
  const infoPlist = infoPlistSource(PACKAGE_VERSION);
@@ -192,17 +215,25 @@ export async function installSksMenuBar(opts = {}) {
192
215
  else {
193
216
  if (!target.used_previous_script && await readText(paths.action_script_path, '') !== actionScript) {
194
217
  await writeTextAtomic(paths.action_script_path, actionScript);
195
- await fs.chmod(paths.action_script_path, 0o755);
196
218
  actions.push(`wrote ${paths.action_script_path}`);
197
219
  }
198
- else if (!target.used_previous_script) {
199
- await fs.chmod(paths.action_script_path, 0o755).catch(() => undefined);
220
+ }
221
+ // The Swift app executes the action script DIRECTLY (Process.executableURL points at the
222
+ // script itself), so a lost executable bit breaks every menu action even when the script
223
+ // content is current. Re-assert the bit on every install run — including the up-to-date
224
+ // fast path, which previously never touched permissions and therefore could never repair
225
+ // a 0644 script — and surface chmod failures instead of swallowing them.
226
+ if (await exists(paths.action_script_path)) {
227
+ const previouslyExecutable = await fs.access(paths.action_script_path, fs.constants.X_OK).then(() => true).catch(() => false);
228
+ const chmodError = await fs.chmod(paths.action_script_path, 0o755).then(() => null).catch((err) => (err?.message ? String(err.message) : String(err)));
229
+ if (chmodError) {
230
+ warnings.push(`action_script_chmod_failed:${chmodError}`);
231
+ }
232
+ else if (!previouslyExecutable) {
233
+ actions.push('restored action script executable bit');
200
234
  }
201
235
  }
202
236
  if (!stampMatches && !binaryStable) {
203
- const clt = await xcodeCltStatus(xcodeSelect);
204
- if (!clt.ok)
205
- return await blockedResult('xcode_clt_missing', clt.error || 'Xcode Command Line Tools missing');
206
237
  try {
207
238
  await buildMenuBarAppAtomically({
208
239
  paths,
@@ -277,7 +308,9 @@ export async function installSksMenuBar(opts = {}) {
277
308
  launch_agent_path: paths.launch_agent_path,
278
309
  action_script_path: paths.action_script_path,
279
310
  build_stamp_path: paths.build_stamp_path,
311
+ config_path: paths.config_path,
280
312
  report_path: paths.report_path,
313
+ codex_bundle_id: codexBundleId,
281
314
  menu_items: MENU_ITEMS,
282
315
  actions,
283
316
  launch,
@@ -285,10 +318,7 @@ export async function installSksMenuBar(opts = {}) {
285
318
  build_stamp: stamp,
286
319
  tcc_automation_status: 'unknown',
287
320
  secret_env_cleanup: secretEnvCleanup,
288
- next_actions: [
289
- ...nextActions,
290
- 'If Terminal automation was denied: System Settings > Privacy & Security > Automation > SKS Menu Bar > Terminal.'
291
- ],
321
+ next_actions: nextActions,
292
322
  blockers: ok ? [] : [launch.error || 'sks_menubar_launch_failed'],
293
323
  warnings
294
324
  };
@@ -306,7 +336,9 @@ export async function installSksMenuBar(opts = {}) {
306
336
  launch_agent_path: paths.launch_agent_path,
307
337
  action_script_path: paths.action_script_path,
308
338
  build_stamp_path: paths.build_stamp_path,
339
+ config_path: paths.config_path,
309
340
  report_path: paths.report_path,
341
+ codex_bundle_id: typeof codexBundleId === 'string' ? codexBundleId : null,
310
342
  menu_items: MENU_ITEMS,
311
343
  actions,
312
344
  launch: { requested: false, method: 'none', ok: false, error: detail || reason },
@@ -358,6 +390,99 @@ export async function cleanupMacLaunchSecretEnvironment(opts = {}) {
358
390
  next_actions: ['Rotate CODEX_LB_API_KEY and OPENROUTER_API_KEY if they were previously exposed in launchd.']
359
391
  };
360
392
  }
393
+ async function writeDefaultMenuBarConfig(configPath, codexBundleId) {
394
+ const previous = await readMenuBarConfig(configPath);
395
+ const config = {
396
+ schema: 'sks.sks-menubar-config.v1',
397
+ codex_bundle_id: codexBundleId,
398
+ quit_with_codex: previous.quit_with_codex === true
399
+ };
400
+ await writeJsonAtomic(configPath, config);
401
+ return config;
402
+ }
403
+ async function readMenuBarConfig(configPath) {
404
+ const config = await readJson(configPath, null);
405
+ return {
406
+ schema: 'sks.sks-menubar-config.v1',
407
+ codex_bundle_id: typeof config?.codex_bundle_id === 'string' && config.codex_bundle_id.trim()
408
+ ? config.codex_bundle_id.trim()
409
+ : null,
410
+ quit_with_codex: config?.quit_with_codex === true
411
+ };
412
+ }
413
+ async function resolveCodexBundleId(input) {
414
+ if (process.platform !== 'darwin')
415
+ return null;
416
+ const appPath = await findCodexApp({ home: input.home, env: input.env }).catch(() => null);
417
+ if (!appPath) {
418
+ input.warnings.push('codex_app_not_found_for_bundle_sync');
419
+ return null;
420
+ }
421
+ const mdls = input.env.SKS_MENUBAR_MDLS || await which('mdls').catch(() => null) || await fallbackTool('/usr/bin/mdls');
422
+ if (mdls) {
423
+ const result = await runProcess(mdls, ['-name', 'kMDItemCFBundleIdentifier', '-raw', appPath], {
424
+ timeoutMs: 3_000,
425
+ maxOutputBytes: 8 * 1024
426
+ }).catch((err) => ({ code: 1, stdout: '', stderr: err?.message || String(err) }));
427
+ const value = String(result.stdout || '').trim();
428
+ if (result.code === 0 && value && value !== '(null)' && value !== 'null')
429
+ return value;
430
+ }
431
+ const defaults = input.env.SKS_MENUBAR_DEFAULTS || await which('defaults').catch(() => null) || await fallbackTool('/usr/bin/defaults');
432
+ if (defaults) {
433
+ const result = await runProcess(defaults, ['read', path.join(appPath, 'Contents', 'Info'), 'CFBundleIdentifier'], {
434
+ timeoutMs: 3_000,
435
+ maxOutputBytes: 8 * 1024
436
+ }).catch((err) => ({ code: 1, stdout: '', stderr: err?.message || String(err) }));
437
+ const value = String(result.stdout || '').trim();
438
+ if (result.code === 0 && value)
439
+ return value;
440
+ }
441
+ return null;
442
+ }
443
+ export async function smokeSksMenuBarAction(actionScriptPath) {
444
+ if (!(await exists(actionScriptPath)))
445
+ return { ok: false, code: null, output: null, versionDetected: false, executable: false };
446
+ // The Swift app runs the script directly (which requires the executable bit), so the smoke
447
+ // check must invoke it the same way. Running it via `/bin/zsh <script>` — as this check used
448
+ // to — succeeds even when +x is missing, which let doctor/status report a healthy action
449
+ // target while the menu bar itself was showing "action script broken".
450
+ const executable = await fs.access(actionScriptPath, fs.constants.X_OK).then(() => true).catch(() => false);
451
+ if (!executable) {
452
+ return {
453
+ ok: false,
454
+ code: null,
455
+ output: 'action script is not executable (missing +x); the menu bar app cannot run it',
456
+ versionDetected: false,
457
+ executable: false
458
+ };
459
+ }
460
+ const result = await runProcess(actionScriptPath, ['version'], {
461
+ timeoutMs: 5_000,
462
+ maxOutputBytes: 16 * 1024
463
+ }).catch((err) => ({ code: 1, stdout: '', stderr: err?.message || String(err) }));
464
+ const output = String(`${result.stdout || ''}\n${result.stderr || ''}`).trim();
465
+ const versionDetected = /\b(?:sks|sneakoscope)?\s*v?\d+\.\d+\.\d+\b/i.test(output);
466
+ return {
467
+ ok: result.code === 0 && versionDetected,
468
+ code: result.code,
469
+ output: output ? output.slice(0, 700) : null,
470
+ versionDetected,
471
+ executable: true
472
+ };
473
+ }
474
+ async function isCodexAppRunningByBundleId(bundleId, env = process.env) {
475
+ if (process.platform !== 'darwin' || !bundleId)
476
+ return false;
477
+ const osascript = env.SKS_MENUBAR_OSASCRIPT || await which('osascript').catch(() => null) || await fallbackTool('/usr/bin/osascript');
478
+ if (!osascript)
479
+ return false;
480
+ const result = await runProcess(osascript, ['-e', `application id "${bundleId.replace(/"/g, '\\"')}" is running`], {
481
+ timeoutMs: 2_000,
482
+ maxOutputBytes: 8 * 1024
483
+ }).catch(() => ({ code: 1, stdout: '', stderr: '' }));
484
+ return result.code === 0 && String(result.stdout || '').trim().toLowerCase() === 'true';
485
+ }
361
486
  export async function inspectSksMenuBarStatus(opts = {}) {
362
487
  const paths = sksMenuBarPaths(opts.home || opts.env?.HOME, opts.root);
363
488
  const installed = await exists(paths.executable_path);
@@ -367,6 +492,16 @@ export async function inspectSksMenuBarStatus(opts = {}) {
367
492
  const sksEntry = shellAssignment(actionText, 'SKS_ENTRY');
368
493
  const nodeExists = nodeBin ? await isExecutable(nodeBin) : false;
369
494
  const sksEntryExists = sksEntry ? await exists(sksEntry) : false;
495
+ const actionSmoke = await smokeSksMenuBarAction(paths.action_script_path);
496
+ const config = await readMenuBarConfig(paths.config_path);
497
+ const codexRunning = config.codex_bundle_id ? await isCodexAppRunningByBundleId(config.codex_bundle_id, opts.env) : null;
498
+ const codexSync = {
499
+ ok: Boolean(config.codex_bundle_id),
500
+ bundle_id: config.codex_bundle_id,
501
+ codex_running: codexRunning,
502
+ icon_visible_expected: config.codex_bundle_id ? codexRunning === true : true,
503
+ warning: config.codex_bundle_id ? null : 'codex_sync_disabled'
504
+ };
370
505
  const buildStamp = await readJson(paths.build_stamp_path, null);
371
506
  const launchd = await inspectLaunchdService(opts.env);
372
507
  const signature = await inspectSignature(paths.app_path, opts.env);
@@ -374,10 +509,16 @@ export async function inspectSksMenuBarStatus(opts = {}) {
374
509
  const warnings = [];
375
510
  if (!installed)
376
511
  blockers.push('menubar_app_missing');
377
- if (installed && !sksEntryExists)
378
- blockers.push('action_script_target_missing');
512
+ if (installed && launchd.checked && !launchd.ok)
513
+ blockers.push('launchd_not_running');
514
+ if (installed && !actionSmoke.executable)
515
+ blockers.push('action_script_not_executable');
516
+ if (installed && !actionSmoke.ok)
517
+ blockers.push('action_script_smoke_failed');
379
518
  if (installed && signature.checked && !signature.ok)
380
519
  warnings.push('codesign_identifier_unexpected');
520
+ if (!codexSync.ok)
521
+ warnings.push('codex_sync_disabled');
381
522
  if (buildStamp?.package_version && buildStamp.package_version !== PACKAGE_VERSION)
382
523
  warnings.push('build_stamp_package_version_mismatch');
383
524
  return {
@@ -393,8 +534,13 @@ export async function inspectSksMenuBarStatus(opts = {}) {
393
534
  node_exists: nodeExists,
394
535
  sks_entry: sksEntry,
395
536
  sks_entry_exists: sksEntryExists,
396
- ok: Boolean(nodeExists && sksEntryExists)
537
+ smoke_code: actionSmoke.code,
538
+ smoke_output: actionSmoke.output,
539
+ version_detected: actionSmoke.versionDetected,
540
+ executable: actionSmoke.executable,
541
+ ok: actionSmoke.ok
397
542
  },
543
+ codex_sync: codexSync,
398
544
  build_stamp: buildStamp,
399
545
  package_version: PACKAGE_VERSION,
400
546
  signature,
@@ -543,16 +689,7 @@ async function resolveSksEntryForInstall(input) {
543
689
  let existsResolved = await exists(resolved);
544
690
  let projectLocal = isSubpath(resolved, input.root);
545
691
  if (projectLocal) {
546
- const globalCandidate = await findGlobalSksEntry(input.home, input.env, packaged);
547
- if (globalCandidate && globalCandidate !== resolved) {
548
- input.warnings.push('sks_entry_project_local_ignored_global_package_used');
549
- resolved = globalCandidate;
550
- existsResolved = true;
551
- projectLocal = isSubpath(resolved, input.root);
552
- }
553
- else {
554
- input.warnings.push('sks_entry_resolved_under_project_root');
555
- }
692
+ input.warnings.push('sks_entry_project_local');
556
693
  }
557
694
  const usedPreviousScript = !existsResolved && await exists(input.actionScriptPath);
558
695
  if (!existsResolved && usedPreviousScript)
@@ -566,81 +703,227 @@ async function resolveSksEntryForInstall(input) {
566
703
  used_previous_script: usedPreviousScript
567
704
  };
568
705
  }
569
- async function findGlobalSksEntry(home, env, packaged) {
570
- const candidates = new Set();
571
- const nodeRoot = path.resolve(path.dirname(process.execPath), '..');
572
- candidates.add(path.join(nodeRoot, 'lib', 'node_modules', 'sneakoscope', 'dist', 'bin', 'sks.js'));
573
- candidates.add(path.join(home, '.nvm', 'versions', 'node'));
574
- const nvmRoot = path.join(home, '.nvm', 'versions', 'node');
575
- try {
576
- const versions = await fs.readdir(nvmRoot);
577
- for (const version of versions.sort().reverse()) {
578
- candidates.add(path.join(nvmRoot, version, 'lib', 'node_modules', 'sneakoscope', 'dist', 'bin', 'sks.js'));
579
- }
580
- }
581
- catch { }
582
- if (env.SKS_GLOBAL_ROOT)
583
- candidates.add(path.join(path.resolve(env.SKS_GLOBAL_ROOT), 'dist', 'bin', 'sks.js'));
584
- for (const candidate of candidates) {
585
- if (candidate === nvmRoot)
586
- continue;
587
- if (path.resolve(candidate) === path.resolve(packaged))
588
- continue;
589
- if (await exists(candidate))
590
- return path.resolve(candidate);
591
- }
592
- return null;
593
- }
594
- function actionScriptSource(input) {
706
+ export function actionScriptSource(input) {
595
707
  return `#!/bin/zsh
596
708
  set -e
597
709
  export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
598
710
  NODE_BIN=${shellQuote(input.nodeBin)}
599
711
  SKS_ENTRY=${shellQuote(input.sksEntry)}
600
- if [ -x "$NODE_BIN" ] && [ -f "$SKS_ENTRY" ]; then
601
- exec "$NODE_BIN" "$SKS_ENTRY" "$@"
712
+
713
+ notify_sks_missing() {
714
+ /usr/bin/osascript -e 'display notification "sks CLI를 찾을 수 없습니다. sks doctor --fix 또는 npm install -g sneakoscope 실행 후 다시 시도하세요." with title "SKS Menu Bar"' >/dev/null 2>&1 || true
715
+ }
716
+
717
+ resolve_node_bin() {
718
+ if [ -x "$NODE_BIN" ]; then
719
+ printf '%s\\n' "$NODE_BIN"
720
+ return 0
721
+ fi
722
+ local login_node
723
+ login_node="$(/bin/zsh -lc 'command -v node' 2>/dev/null | /usr/bin/head -n 1 || true)"
724
+ if [ -n "$login_node" ] && [ -x "$login_node" ]; then
725
+ printf '%s\\n' "$login_node"
726
+ return 0
727
+ fi
728
+ for cand in "$HOME"/.nvm/versions/node/*/bin/node(Nn[-1]) /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node; do
729
+ if [ -x "$cand" ]; then
730
+ printf '%s\\n' "$cand"
731
+ return 0
732
+ fi
733
+ done
734
+ return 1
735
+ }
736
+
737
+ run_node_entry() {
738
+ local entry="$1"
739
+ shift
740
+ if [ ! -f "$entry" ]; then
741
+ return 1
742
+ fi
743
+ local node_bin
744
+ node_bin="$(resolve_node_bin || true)"
745
+ if [ -z "$node_bin" ]; then
746
+ return 1
747
+ fi
748
+ exec "$node_bin" "$entry" "$@"
749
+ }
750
+
751
+ SKS_BIN="$(/bin/zsh -lc 'command -v sks' 2>/dev/null | /usr/bin/head -n 1 || true)"
752
+ if [ -n "$SKS_BIN" ] && [ -x "$SKS_BIN" ]; then
753
+ exec "$SKS_BIN" "$@"
754
+ fi
755
+
756
+ NPM_ROOT="$(/bin/zsh -lc 'npm root -g' 2>/dev/null | /usr/bin/head -n 1 || true)"
757
+ if [ -n "$NPM_ROOT" ]; then
758
+ run_node_entry "$NPM_ROOT/sneakoscope/dist/bin/sks.js" "$@" || true
602
759
  fi
603
- for cand in "$HOME"/.nvm/versions/node/*/bin/node(Nn[-1]); do
604
- if [ -x "$cand" ] && [ -f "$SKS_ENTRY" ]; then
605
- exec "$cand" "$SKS_ENTRY" "$@"
760
+
761
+ for entry in "$HOME"/.nvm/versions/node/*/lib/node_modules/sneakoscope/dist/bin/sks.js(Nn[-1]) /opt/homebrew/lib/node_modules/sneakoscope/dist/bin/sks.js /usr/local/lib/node_modules/sneakoscope/dist/bin/sks.js; do
762
+ if [ -f "$entry" ]; then
763
+ run_node_entry "$entry" "$@" || true
606
764
  fi
607
765
  done
608
- if /bin/zsh -lc 'command -v sks' >/dev/null 2>&1; then
609
- exec /bin/zsh -lc "sks $(printf '%q ' "$@")"
610
- fi
766
+ run_node_entry "$SKS_ENTRY" "$@" || true
767
+ notify_sks_missing
611
768
  echo "SKS command not found. Run npm install -g sneakoscope or sks doctor --fix, then try again." >&2
612
769
  exit 127
613
770
  `;
614
771
  }
615
- function swiftMenuSource(input) {
772
+ export function swiftMenuSource(input) {
773
+ const codexLifecycleSource = input.codexBundleId ? `
774
+ func configureCodexLifecycleSync() {
775
+ setIconVisible(isCodexRunning())
776
+ let center = NSWorkspace.shared.notificationCenter
777
+ center.addObserver(self, selector: #selector(workspaceAppLaunched(_:)), name: NSWorkspace.didLaunchApplicationNotification, object: nil)
778
+ center.addObserver(self, selector: #selector(workspaceAppTerminated(_:)), name: NSWorkspace.didTerminateApplicationNotification, object: nil)
779
+ }
780
+
781
+ @objc func workspaceAppLaunched(_ notification: Notification) {
782
+ guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
783
+ if app.bundleIdentifier == codexBundleId {
784
+ setIconVisible(true)
785
+ updateState()
786
+ }
787
+ }
788
+
789
+ @objc func workspaceAppTerminated(_ notification: Notification) {
790
+ guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
791
+ if app.bundleIdentifier == codexBundleId {
792
+ if quitWithCodex {
793
+ NSApplication.shared.terminate(nil)
794
+ } else {
795
+ setIconVisible(false)
796
+ }
797
+ }
798
+ }
799
+
800
+ func setIconVisible(_ visible: Bool) {
801
+ statusItem.isVisible = visible
802
+ if visible {
803
+ reassertControlCenterVisibility()
804
+ }
805
+ }
806
+
807
+ func isCodexRunning() -> Bool {
808
+ guard let bundle = codexBundleId else { return true }
809
+ return NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == bundle }
810
+ }
811
+ ` : `
812
+ func configureCodexLifecycleSync() {
813
+ setIconVisible(true)
814
+ statusLineItem.title = "Codex app not detected — sync disabled"
815
+ }
816
+
817
+ func setIconVisible(_ visible: Bool) {
818
+ statusItem.isVisible = visible
819
+ if visible {
820
+ reassertControlCenterVisibility()
821
+ }
822
+ }
823
+
824
+ func isCodexRunning() -> Bool {
825
+ return true
826
+ }
827
+ `;
616
828
  return `import Cocoa
617
829
  import Foundation
618
830
 
619
831
  let actionScript = ${swiftString(input.actionScriptPath)}
620
832
  let buildStampPath = ${swiftString(input.buildStampPath)}
833
+ let menubarConfigPath = ${swiftString(input.configPath)}
834
+ let lastActionLogPath = ${swiftString(input.lastActionLogPath)}
835
+ let codexBundleId: String? = ${input.codexBundleId ? swiftString(input.codexBundleId) : 'nil'}
621
836
  let packageVersion = ${swiftString(input.packageVersion)}
837
+ let menuBarLabel = ${swiftString(SKS_MENUBAR_LABEL)}
838
+ let controlCenterDomain = ${swiftString(CONTROL_CENTER_DOMAIN)}
839
+
840
+ /// macOS persists status-item visibility hints per-label in Control Center's
841
+ /// defaults domain (see installSksMenuBar's seedMenuBarPreferredPosition).
842
+ /// Toggling NSStatusItem.isVisible back to true inside a resident process is
843
+ /// not always sufficient to make Control Center re-render a previously
844
+ /// hidden item, so re-show must reassert the same Control Center defaults
845
+ /// the installer seeds, or the icon can stay invisible after a Codex
846
+ /// quit/relaunch cycle even though isVisible is technically true again.
847
+ func reassertControlCenterVisibility() {
848
+ let defaultsBin = "/usr/bin/defaults"
849
+ guard FileManager.default.isExecutableFile(atPath: defaultsBin) else { return }
850
+ let writes: [[String]] = [
851
+ ["write", controlCenterDomain, "NSStatusItem Visible \\(menuBarLabel)", "-bool", "true"],
852
+ ["write", controlCenterDomain, "NSStatusItem VisibleCC \\(menuBarLabel)", "-bool", "true"]
853
+ ]
854
+ for args in writes {
855
+ let process = Process()
856
+ process.executableURL = URL(fileURLWithPath: defaultsBin)
857
+ process.arguments = args
858
+ process.standardOutput = FileHandle.nullDevice
859
+ process.standardError = FileHandle.nullDevice
860
+ try? process.run()
861
+ }
862
+ }
622
863
 
623
864
  func shellQuote(_ value: String) -> String {
624
865
  return "'" + value.replacingOccurrences(of: "'", with: "'\\\\''") + "'"
625
866
  }
626
867
 
868
+ func clipped(_ value: String, limit: Int = 700) -> String {
869
+ return String(value.prefix(limit))
870
+ }
871
+
627
872
  func showAlert(_ message: String, informative: String = "") {
628
873
  DispatchQueue.main.async {
874
+ NSApp.activate(ignoringOtherApps: true)
629
875
  let alert = NSAlert()
630
876
  alert.messageText = message
631
- alert.informativeText = informative
877
+ alert.informativeText = clipped(informative)
632
878
  alert.alertStyle = .warning
633
879
  alert.runModal()
634
880
  }
635
881
  }
636
882
 
637
- func runProcess(_ executable: String, _ args: [String] = [], completion: ((Int32, String) -> Void)? = nil) {
883
+ func promptText(title: String, message: String, placeholder: String = "", secure: Bool = false) -> String? {
884
+ NSApp.activate(ignoringOtherApps: true)
885
+ let alert = NSAlert()
886
+ alert.messageText = title
887
+ alert.informativeText = message
888
+ alert.addButton(withTitle: "OK")
889
+ alert.addButton(withTitle: "Cancel")
890
+ let field: NSTextField = secure ? NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) : NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24))
891
+ field.placeholderString = placeholder
892
+ alert.accessoryView = field
893
+ let response = alert.runModal()
894
+ if response != .alertFirstButtonReturn { return nil }
895
+ let value = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
896
+ return value.isEmpty ? nil : value
897
+ }
898
+
899
+ func promptChoice(title: String, message: String, options: [String]) -> String? {
900
+ NSApp.activate(ignoringOtherApps: true)
901
+ let alert = NSAlert()
902
+ alert.messageText = title
903
+ alert.informativeText = message
904
+ alert.addButton(withTitle: "OK")
905
+ alert.addButton(withTitle: "Cancel")
906
+ let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false)
907
+ popup.addItems(withTitles: options)
908
+ alert.accessoryView = popup
909
+ let response = alert.runModal()
910
+ if response != .alertFirstButtonReturn { return nil }
911
+ return popup.titleOfSelectedItem
912
+ }
913
+
914
+ func runProcess(_ executable: String, _ args: [String] = [], stdinText: String? = nil, completion: ((Int32, String) -> Void)? = nil) {
638
915
  let process = Process()
639
916
  let output = Pipe()
640
917
  process.executableURL = URL(fileURLWithPath: executable)
641
918
  process.arguments = args
642
919
  process.standardOutput = output
643
920
  process.standardError = output
921
+ var inputPipe: Pipe?
922
+ if stdinText != nil {
923
+ let pipe = Pipe()
924
+ process.standardInput = pipe
925
+ inputPipe = pipe
926
+ }
644
927
  process.terminationHandler = { proc in
645
928
  let data = output.fileHandleForReading.readDataToEndOfFile()
646
929
  let text = String(data: data, encoding: .utf8) ?? ""
@@ -648,14 +931,17 @@ func runProcess(_ executable: String, _ args: [String] = [], completion: ((Int32
648
931
  }
649
932
  do {
650
933
  try process.run()
934
+ if let stdinText = stdinText, let inputPipe = inputPipe {
935
+ inputPipe.fileHandleForWriting.write(Data(stdinText.utf8))
936
+ inputPipe.fileHandleForWriting.closeFile()
937
+ }
651
938
  } catch {
652
939
  completion?(-1, String(describing: error))
653
940
  }
654
941
  }
655
942
 
656
943
  func showNotification(_ title: String, _ body: String) {
657
- let clipped = String(body.prefix(700))
658
- let script = "display notification " + shellQuote(clipped) + " with title " + shellQuote(title)
944
+ let script = "display notification " + shellQuote(clipped(body)) + " with title " + shellQuote(title)
659
945
  runProcess("/usr/bin/osascript", ["-e", script]) { code, output in
660
946
  if code != 0 {
661
947
  showAlert(title, informative: output)
@@ -663,35 +949,40 @@ func showNotification(_ title: String, _ body: String) {
663
949
  }
664
950
  }
665
951
 
666
- func runInTerminal(_ command: String) {
667
- let commandWithExit = "printf '\\\\e]0;SKS\\\\a'; " + command + "; exit"
668
- let escaped = commandWithExit
669
- .replacingOccurrences(of: "\\\\", with: "\\\\\\\\")
670
- .replacingOccurrences(of: "\\\"", with: "\\\\\\\"")
671
- let script = """
672
- tell application "Terminal"
673
- activate
674
- set sksWindow to missing value
675
- repeat with w in windows
676
- if name of w contains "SKS" then set sksWindow to w
677
- end repeat
678
- if sksWindow is missing value then
679
- do script "\\(escaped)"
680
- else
681
- do script "\\(escaped)" in selected tab of sksWindow
682
- end if
683
- end tell
684
- """
685
- runProcess("/usr/bin/osascript", ["-e", script]) { code, output in
686
- if code != 0 {
687
- let denied = output.contains("-1743") || output.localizedCaseInsensitiveContains("not authorized")
688
- if denied {
689
- showAlert("SKS menu cannot control Terminal", informative: "Open System Settings > Privacy & Security > Automation, then allow SKS Menu Bar to control Terminal.")
690
- } else {
691
- showAlert("SKS menu action failed", informative: output)
692
- }
952
+ func redactSecrets(_ value: String, secrets: [String] = []) -> String {
953
+ var text = value
954
+ for secret in secrets where secret.count >= 4 {
955
+ text = text.replacingOccurrences(of: secret, with: "[redacted]")
956
+ }
957
+ let patterns = [
958
+ #"sk-proj-[A-Za-z0-9_-]{12,}"#,
959
+ #"sk-or-v1-[A-Za-z0-9_-]{12,}"#,
960
+ #"sk-or-[A-Za-z0-9_-]{12,}"#,
961
+ #"sk-clb-[A-Za-z0-9_-]{8,}"#,
962
+ #"sk-[A-Za-z0-9_-]{20,}"#,
963
+ #"(?i)(api[_-]?key|secret|token)\\s*[:=]\\s*[^\\s"',}]+"#
964
+ ]
965
+ for pattern in patterns {
966
+ if let regex = try? NSRegularExpression(pattern: pattern) {
967
+ let range = NSRange(text.startIndex..<text.endIndex, in: text)
968
+ text = regex.stringByReplacingMatches(in: text, range: range, withTemplate: "[redacted]")
693
969
  }
694
970
  }
971
+ return text
972
+ }
973
+
974
+ func writeActionLog(_ text: String) {
975
+ let url = URL(fileURLWithPath: lastActionLogPath)
976
+ try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
977
+ if !FileManager.default.fileExists(atPath: lastActionLogPath) {
978
+ FileManager.default.createFile(atPath: lastActionLogPath, contents: Data(), attributes: [.posixPermissions: 0o600])
979
+ }
980
+ try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: lastActionLogPath)
981
+ if let handle = try? FileHandle(forWritingTo: url) {
982
+ try? handle.truncate(atOffset: 0)
983
+ handle.write(Data(text.utf8))
984
+ try? handle.close()
985
+ }
695
986
  }
696
987
 
697
988
  struct MenuState {
@@ -699,27 +990,37 @@ struct MenuState {
699
990
  let line: String
700
991
  }
701
992
 
702
- final class AppDelegate: NSObject, NSApplicationDelegate {
993
+ struct MenuBarConfig {
994
+ let quitWithCodex: Bool
995
+ }
996
+
997
+ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
703
998
  var statusItem: NSStatusItem!
704
999
  var statusLineItem: NSMenuItem!
1000
+ var codexLbItem: NSMenuItem!
1001
+ var oauthItem: NSMenuItem!
705
1002
  var timer: Timer?
1003
+ var busy = false
1004
+ var lastFailure = false
1005
+ var quitWithCodex = false
706
1006
 
707
1007
  func applicationDidFinishLaunching(_ notification: Notification) {
708
1008
  NSApp.setActivationPolicy(.accessory)
1009
+ quitWithCodex = readConfig().quitWithCodex
709
1010
  statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
710
1011
  statusItem.autosaveName = "com.sneakoscope.sks-menubar"
711
- statusItem.isVisible = true
712
1012
  if let button = statusItem.button {
713
1013
  configureStatusButton(button, title: "SKS")
714
1014
  }
715
1015
 
716
1016
  let menu = NSMenu()
1017
+ menu.delegate = self
717
1018
  statusLineItem = NSMenuItem(title: "SKS v\\(packageVersion) - starting", action: nil, keyEquivalent: "")
718
1019
  statusLineItem.isEnabled = false
719
1020
  menu.addItem(statusLineItem)
720
1021
  menu.addItem(NSMenuItem.separator())
721
- add(menu, "Use codex-lb", #selector(useCodexLb))
722
- add(menu, "Use ChatGPT OAuth", #selector(useChatGptOAuth))
1022
+ codexLbItem = add(menu, "Use codex-lb", #selector(useCodexLb))
1023
+ oauthItem = add(menu, "Use ChatGPT OAuth", #selector(useChatGptOAuth))
723
1024
  add(menu, "Set codex-lb Domain and Key", #selector(setCodexLbDomainAndKey))
724
1025
  menu.addItem(NSMenuItem.separator())
725
1026
  add(menu, "Set OpenRouter Key and GLM Profiles", #selector(setOpenRouterKey))
@@ -731,12 +1032,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
731
1032
  add(menu, "Open Codex Settings", #selector(openCodexSettings))
732
1033
  add(menu, "Restart Codex", #selector(restartCodex))
733
1034
  menu.addItem(NSMenuItem.separator())
1035
+ add(menu, "View Last Log", #selector(viewLastLog))
734
1036
  add(menu, "Quit SKS Menu", #selector(quit))
735
1037
  statusItem.menu = menu
1038
+ configureCodexLifecycleSync()
736
1039
  updateState()
737
1040
  timer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in self?.updateState() }
738
1041
  }
739
1042
 
1043
+ func menuWillOpen(_ menu: NSMenu) {
1044
+ updateState()
1045
+ updateAuthModeChecks()
1046
+ }
1047
+
740
1048
  func configureStatusButton(_ button: NSStatusBarButton, title: String) {
741
1049
  button.image = nil
742
1050
  button.title = title
@@ -752,10 +1060,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
752
1060
  button.setAccessibilityHelp("Open SKS menu")
753
1061
  }
754
1062
 
755
- func add(_ menu: NSMenu, _ title: String, _ selector: Selector) {
1063
+ func add(_ menu: NSMenu, _ title: String, _ selector: Selector) -> NSMenuItem {
756
1064
  let item = NSMenuItem(title: title, action: selector, keyEquivalent: "")
757
1065
  item.target = self
758
1066
  menu.addItem(item)
1067
+ return item
1068
+ }
1069
+
1070
+ ${codexLifecycleSource}
1071
+
1072
+ func readConfig() -> MenuBarConfig {
1073
+ guard let data = FileManager.default.contents(atPath: menubarConfigPath),
1074
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
1075
+ return MenuBarConfig(quitWithCodex: false)
1076
+ }
1077
+ return MenuBarConfig(quitWithCodex: json["quit_with_codex"] as? Bool == true)
759
1078
  }
760
1079
 
761
1080
  func updateState() {
@@ -767,20 +1086,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
767
1086
  }
768
1087
 
769
1088
  func readMenuState() -> MenuState {
770
- let actionTargetOk = actionScriptTargetExists()
771
- if !actionTargetOk {
1089
+ if codexBundleId == nil {
1090
+ return MenuState(title: "SKS", line: "Codex app not detected — sync disabled")
1091
+ }
1092
+ if busy {
1093
+ return MenuState(title: "SKS ⋯", line: "SKS v\\(packageVersion) - working")
1094
+ }
1095
+ if !actionScriptUsable() {
772
1096
  return MenuState(title: "SKS ⚠", line: "SKS v\\(packageVersion) - action script broken (run sks doctor --fix)")
773
1097
  }
1098
+ if lastFailure {
1099
+ return MenuState(title: "SKS ⚠", line: "SKS v\\(packageVersion) - last action failed")
1100
+ }
774
1101
  if updateAvailable() {
775
1102
  return MenuState(title: "SKS ↑", line: "SKS v\\(packageVersion) - update available")
776
1103
  }
777
1104
  return MenuState(title: "SKS", line: "SKS v\\(packageVersion) - OK")
778
1105
  }
779
1106
 
780
- func actionScriptTargetExists() -> Bool {
781
- guard let text = try? String(contentsOfFile: actionScript, encoding: .utf8) else { return false }
782
- guard let entry = shellAssignment(text, key: "SKS_ENTRY") else { return false }
783
- return FileManager.default.fileExists(atPath: entry)
1107
+ func actionScriptUsable() -> Bool {
1108
+ return FileManager.default.isExecutableFile(atPath: actionScript)
784
1109
  }
785
1110
 
786
1111
  func updateAvailable() -> Bool {
@@ -792,48 +1117,83 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
792
1117
  return latest != packageVersion && !latest.isEmpty
793
1118
  }
794
1119
 
795
- func shellAssignment(_ text: String, key: String) -> String? {
796
- for line in text.components(separatedBy: .newlines) {
797
- if line.hasPrefix(key + "=") {
798
- var value = String(line.dropFirst(key.count + 1))
799
- if value.hasPrefix("'") && value.hasSuffix("'") && value.count >= 2 {
800
- value.removeFirst()
801
- value.removeLast()
802
- return value.replacingOccurrences(of: "'\\\\''", with: "'")
1120
+ func runSksCapture(_ args: [String], title: String, stdinText: String? = nil, notify: Bool = true, completion: ((Int32, String) -> Void)? = nil) {
1121
+ busy = true
1122
+ lastFailure = false
1123
+ updateState()
1124
+ runProcess(actionScript, args, stdinText: stdinText) { [weak self] code, output in
1125
+ let redacted = redactSecrets(output, secrets: stdinText == nil ? [] : [stdinText ?? ""])
1126
+ writeActionLog("$ sks \\(args.joined(separator: " "))\\n\\(redacted)\\n")
1127
+ DispatchQueue.main.async {
1128
+ self?.busy = false
1129
+ self?.lastFailure = code != 0
1130
+ self?.updateState()
1131
+ if notify {
1132
+ if code == 0 {
1133
+ showNotification(title, "OK\\n" + redacted)
1134
+ } else {
1135
+ showAlert(title + " failed", informative: redacted)
1136
+ }
803
1137
  }
804
- return value
1138
+ completion?(code, redacted)
805
1139
  }
806
1140
  }
807
- return nil
808
1141
  }
809
1142
 
810
- func runSksInTerminal(_ args: [String], tail: String = "echo; echo 'SKS command finished.'") {
811
- let quoted = args.map(shellQuote).joined(separator: " ")
812
- runInTerminal("\\(shellQuote(actionScript)) \\(quoted); \\(tail)")
1143
+ func runSksBackground(_ args: [String], title: String, stdinText: String? = nil, completion: ((Int32, String) -> Void)? = nil) {
1144
+ runSksCapture(args, title: title, stdinText: stdinText, notify: true, completion: completion)
1145
+ }
1146
+
1147
+ func startSksDetached(_ args: [String], title: String) {
1148
+ let process = Process()
1149
+ process.executableURL = URL(fileURLWithPath: actionScript)
1150
+ process.arguments = args
1151
+ writeActionLog("$ sks \\(args.joined(separator: " "))\\nstarted\\n")
1152
+ if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: lastActionLogPath)) {
1153
+ process.standardOutput = handle
1154
+ process.standardError = handle
1155
+ }
1156
+ do {
1157
+ try process.run()
1158
+ showNotification(title, "started")
1159
+ } catch {
1160
+ showAlert(title + " failed", informative: String(describing: error))
1161
+ }
813
1162
  }
814
1163
 
815
- func runSksBackground(_ args: [String], title: String) {
816
- runProcess(actionScript, args) { code, output in
817
- let status = code == 0 ? "OK" : "failed (\\(code))"
818
- showNotification(title, status + "\\n" + output)
1164
+ func updateAuthModeChecks() {
1165
+ runSksCapture(["codex-lb", "status", "--json"], title: "SKS Auth Status", notify: false) { [weak self] code, output in
1166
+ guard let self = self else { return }
1167
+ let lower = output.lowercased()
1168
+ let codexLbActive = code == 0 && (lower.contains(#""configured": true"#) || lower.contains(#""model_provider": "codex-lb""#) || lower.contains(#""mode": "codex-lb""#))
1169
+ self.codexLbItem.state = codexLbActive ? .on : .off
1170
+ self.oauthItem.state = codexLbActive ? .off : .on
819
1171
  }
820
1172
  }
821
1173
 
822
1174
  @objc func useCodexLb() {
823
- runSksInTerminal(["codex-lb", "use-codex-lb"])
1175
+ runSksBackground(["codex-lb", "use-codex-lb", "--json"], title: "Use codex-lb") { [weak self] code, _ in
1176
+ if code == 0 { self?.updateAuthModeChecks() }
1177
+ }
824
1178
  }
825
1179
 
826
1180
  @objc func useChatGptOAuth() {
827
- runSksInTerminal(["codex-lb", "use-oauth"])
1181
+ runSksBackground(["codex-lb", "use-oauth", "--json"], title: "Use ChatGPT OAuth") { [weak self] code, _ in
1182
+ if code == 0 { self?.updateAuthModeChecks() }
1183
+ }
828
1184
  }
829
1185
 
830
1186
  @objc func setCodexLbDomainAndKey() {
831
- runSksInTerminal(["codex-lb", "setup"])
1187
+ guard let domain = promptText(title: "Set codex-lb Domain", message: "Enter your codex-lb domain or base URL.", placeholder: "https://lb.example.com/backend-api/codex") else { return }
1188
+ guard let key = promptText(title: "Set codex-lb Key", message: "Enter your codex-lb API key.", placeholder: "sk-clb-...", secure: true) else { return }
1189
+ runSksBackground(["codex-lb", "setup", "--host", domain, "--api-key-stdin", "--yes", "--json"], title: "Set codex-lb", stdinText: key + "\\n") { [weak self] code, _ in
1190
+ if code == 0 { self?.updateAuthModeChecks() }
1191
+ }
832
1192
  }
833
1193
 
834
1194
  @objc func setOpenRouterKey() {
835
- let command = "printf 'Paste OpenRouter key, then press Return: '; read -r key; printf '%s\\\\n' \\"$key\\" | \\(shellQuote(actionScript)) codex-app set-openrouter-key --api-key-stdin; \\(shellQuote(actionScript)) codex-app glm-profile install; echo; echo 'OpenRouter/GLM update finished. Restart Codex if the model picker was already open.'"
836
- runInTerminal(command)
1195
+ guard let key = promptText(title: "Set OpenRouter Key", message: "Enter your OpenRouter API key.", placeholder: "sk-or-v1-...", secure: true) else { return }
1196
+ runSksBackground(["codex-app", "set-openrouter-key", "--api-key-stdin", "--json"], title: "Set OpenRouter Key", stdinText: key + "\\n")
837
1197
  }
838
1198
 
839
1199
  @objc func fastCheck() {
@@ -845,13 +1205,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
845
1205
  }
846
1206
 
847
1207
  @objc func updateSksNow() {
848
- runSksInTerminal(["update"], tail: "echo; echo 'SKS update finished.'")
1208
+ runSksBackground(["update"], title: "Update SKS Now")
849
1209
  }
850
1210
 
851
1211
  @objc func openDashboard() {
852
- let command = "\\(shellQuote(actionScript)) ui; echo; echo 'SKS dashboard requested.'"
853
- runInTerminal(command)
854
- runProcess("/usr/bin/open", ["http://127.0.0.1:4477"])
1212
+ let urlString = "http://127.0.0.1:4477"
1213
+ runProcess("/usr/bin/curl", ["-fsS", "--max-time", "1", urlString]) { [weak self] code, _ in
1214
+ DispatchQueue.main.async {
1215
+ if code != 0 {
1216
+ self?.startSksDetached(["ui"], title: "SKS Dashboard")
1217
+ }
1218
+ if let url = URL(string: urlString) {
1219
+ NSWorkspace.shared.open(url)
1220
+ }
1221
+ }
1222
+ }
855
1223
  }
856
1224
 
857
1225
  @objc func openCodexSettings() {
@@ -859,7 +1227,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
859
1227
  }
860
1228
 
861
1229
  @objc func restartCodex() {
862
- runInTerminal("/usr/bin/osascript -e 'tell application \\"Codex\\" to quit'; sleep 1; /usr/bin/open -a Codex; echo 'Codex restart requested.'")
1230
+ let running = NSWorkspace.shared.runningApplications.filter { app in
1231
+ if let bundle = codexBundleId, app.bundleIdentifier == bundle { return true }
1232
+ return app.localizedName == "Codex"
1233
+ }
1234
+ for app in running {
1235
+ app.terminate()
1236
+ }
1237
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
1238
+ if let bundle = codexBundleId, let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundle) {
1239
+ NSWorkspace.shared.open(url)
1240
+ showNotification("Restart Codex", "requested")
1241
+ } else {
1242
+ showAlert("Restart Codex failed", informative: "Codex app bundle could not be resolved.")
1243
+ }
1244
+ }
1245
+ }
1246
+
1247
+ @objc func viewLastLog() {
1248
+ if FileManager.default.fileExists(atPath: lastActionLogPath) {
1249
+ NSWorkspace.shared.open(URL(fileURLWithPath: lastActionLogPath))
1250
+ } else {
1251
+ showAlert("No SKS menu log yet", informative: lastActionLogPath)
1252
+ }
863
1253
  }
864
1254
 
865
1255
  @objc func quit() {
@@ -1077,13 +1467,14 @@ async function inspectLaunchdService(env = process.env) {
1077
1467
  const text = `${result.stdout || ''}\n${result.stderr || ''}`;
1078
1468
  const state = text.match(/\bstate = ([^\n]+)/)?.[1]?.trim() || null;
1079
1469
  const pidText = text.match(/\bpid = (\d+)/)?.[1] || null;
1470
+ const running = result.code === 0 && (state === 'running' || Boolean(pidText));
1080
1471
  return {
1081
1472
  checked: true,
1082
- ok: result.code === 0,
1473
+ ok: running,
1083
1474
  service,
1084
1475
  state,
1085
1476
  pid: pidText ? Number(pidText) : null,
1086
- error: result.code === 0 ? null : String(result.stderr || result.stdout || '').trim()
1477
+ error: running ? null : String(result.stderr || result.stdout || state || 'launchd_not_running').trim()
1087
1478
  };
1088
1479
  }
1089
1480
  async function inspectSignature(appPath, env = process.env) {
@@ -1175,6 +1566,8 @@ async function writeReport(reportPath, result) {
1175
1566
  }
1176
1567
  catch (err) {
1177
1568
  result.report_write_failed = true;
1569
+ if (!result.warnings.includes('menubar_report_write_failed'))
1570
+ result.warnings.push('menubar_report_write_failed');
1178
1571
  console.error(`warning: failed to write SKS menubar report ${reportPath}: ${err?.message || err}`);
1179
1572
  }
1180
1573
  }