@4yi-dev/cli 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,11 @@ Both packages install the `4yi` command. The dev package points at `https://xcla
32
32
 
33
33
  `4yi code` installs OpenCode under `~/.4yi/vendor/opencode`, writes an OpenCode config under `~/.4yi/opencode/opencode.json`, and launches OpenCode with a 4YI provider.
34
34
 
35
- `4yi connect claude` and `4yi connect codex` first check for the corresponding local CLI. If it is missing, 4YI asks before installing the official npm package. Pass `--yes` to approve that CLI installation non-interactively. Desktop apps are detected and reported, but are never installed automatically.
35
+ `4yi connect claude` and `4yi connect codex` first check for the corresponding local CLI. If it is missing, 4YI asks before installing the official npm package. Pass `--yes` to approve that CLI installation non-interactively. Desktop apps are detected but are never installed automatically.
36
+
37
+ On macOS, `4yi connect claude` also configures an installed Claude Desktop through its official third-party Gateway mode. The Desktop credential helper reads the current token from `~/.4yi/config.json`; the token is not copied into Claude's configuration. Quit and reopen Claude Desktop only after its active tasks finish. Third-party mode has a separate session list from the normal Claude profile. Run `4yi restore claude`, then reopen Claude Desktop, to return to the previous profile and session list.
38
+
39
+ `--scope project` changes only the project's Claude Code settings and never changes the global Claude Desktop profile.
36
40
 
37
41
  For local development:
38
42
 
package/bin/4yi.mjs CHANGED
@@ -11,6 +11,7 @@ if (command === "help" || command === "--help" || command === "-h") {
11
11
  console.log(" 4yi code --model X pin model X as the default for future sessions");
12
12
  console.log(" 4yi connect <claude|codex|all> connect existing coding tools to 4YI");
13
13
  console.log(" --yes install a missing CLI without prompting");
14
+ console.log(" Claude on macOS also configures installed Claude Desktop 3P Gateway mode");
14
15
  console.log(" 4yi status [claude|codex|all] inspect the current connection");
15
16
  console.log(" 4yi restore <claude|codex|all> restore the latest protected config");
16
17
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4yi-dev/cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "4YI command-line launcher for OAuth login and OpenCode runtime",
5
5
  "type": "module",
6
6
  "bin": {
package/src/connect.mjs CHANGED
@@ -20,6 +20,8 @@ const CODEX_ROOT_START = "# >>> 4yi-cli codex-root";
20
20
  const CODEX_ROOT_END = "# <<< 4yi-cli codex-root";
21
21
  const CODEX_PROVIDER_START = "# >>> 4yi-cli codex-provider";
22
22
  const CODEX_PROVIDER_END = "# <<< 4yi-cli codex-provider";
23
+ const CLAUDE_DESKTOP_PROFILE_ID = "4e8e29f6-e0ba-4a2f-8a5f-4e2dcf95f3f0";
24
+ const CLAUDE_DESKTOP_PROFILE_FILE = `${CLAUDE_DESKTOP_PROFILE_ID}.json`;
23
25
  const TOOL_METADATA = {
24
26
  claude: {
25
27
  label: "Claude Code",
@@ -51,22 +53,39 @@ export async function prepareConnectionTools({ target = "all", autoInstall = fal
51
53
  }
52
54
  }
53
55
 
54
- export function connectionPaths({ home = os.homedir(), cwd = process.cwd(), codexHome = process.env.CODEX_HOME } = {}) {
56
+ export function connectionPaths({
57
+ home = os.homedir(),
58
+ cwd = process.cwd(),
59
+ codexHome = process.env.CODEX_HOME,
60
+ platform = process.platform,
61
+ } = {}) {
55
62
  const codexDir = codexHome || path.join(home, ".codex");
56
- return {
63
+ const paths = {
57
64
  claudeUser: path.join(home, ".claude", "settings.json"),
58
65
  claudeProject: path.join(cwd, ".claude", "settings.local.json"),
59
66
  codexConfig: path.join(codexDir, "config.toml"),
60
67
  codexCatalog: path.join(codexDir, "model-catalogs", "4yi.json"),
61
68
  };
69
+ if (platform === "darwin") {
70
+ const desktopDir = path.join(home, "Library", "Application Support", "Claude-3p");
71
+ const libraryDir = path.join(desktopDir, "configLibrary");
72
+ Object.assign(paths, {
73
+ claudeDesktopDir: desktopDir,
74
+ claudeDesktopConfig: path.join(desktopDir, "claude_desktop_config.json"),
75
+ claudeDesktopLibraryMeta: path.join(libraryDir, "_meta.json"),
76
+ claudeDesktopProfile: path.join(libraryDir, CLAUDE_DESKTOP_PROFILE_FILE),
77
+ claudeDesktopCredentialHelper: path.join(home, ".4yi", "helpers", "claude-desktop-credential"),
78
+ });
79
+ }
80
+ return paths;
62
81
  }
63
82
 
64
- function atomicWrite(file, value) {
83
+ function atomicWrite(file, value, mode = 0o600) {
65
84
  ensureDir(path.dirname(file));
66
85
  const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
67
- fs.writeFileSync(temp, value, { mode: 0o600 });
86
+ fs.writeFileSync(temp, value, { mode });
68
87
  fs.renameSync(temp, file);
69
- try { fs.chmodSync(file, 0o600); } catch { /* Windows may ignore POSIX modes. */ }
88
+ try { fs.chmodSync(file, mode); } catch { /* Windows may ignore POSIX modes. */ }
70
89
  }
71
90
 
72
91
  function timestamp() {
@@ -216,6 +235,103 @@ function chooseModel(models, preferred, keyword) {
216
235
  return match || models[0];
217
236
  }
218
237
 
238
+ function readJsonObject(file) {
239
+ if (!fs.existsSync(file) || fs.statSync(file).size === 0) return {};
240
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
241
+ if (!value || Array.isArray(value) || typeof value !== "object") {
242
+ throw new Error(`${file} must contain a JSON object.`);
243
+ }
244
+ return value;
245
+ }
246
+
247
+ function claudeDesktopCredentialHelper() {
248
+ return `#!/bin/sh
249
+ set -eu
250
+
251
+ config_file="$HOME/.4yi/config.json"
252
+ if [ ! -f "$config_file" ]; then
253
+ echo "4YI session not found. Run: 4yi login" >&2
254
+ exit 1
255
+ fi
256
+
257
+ token=$(/usr/bin/plutil -extract token raw -o - "$config_file" 2>/dev/null) || {
258
+ echo "4YI session is invalid. Run: 4yi login" >&2
259
+ exit 1
260
+ }
261
+
262
+ if [ -z "$token" ] || [ "$token" = "null" ]; then
263
+ echo "4YI session is missing a token. Run: 4yi login" >&2
264
+ exit 1
265
+ fi
266
+
267
+ printf '%s' "$token"
268
+ `;
269
+ }
270
+
271
+ function configureClaudeDesktop({
272
+ home,
273
+ claudeBaseUrl,
274
+ backupGroup,
275
+ stdout,
276
+ platform = process.platform,
277
+ env = process.env,
278
+ exists = fs.existsSync,
279
+ } = {}) {
280
+ if (platform !== "darwin") return { configured: false, supported: false };
281
+
282
+ const detected = desktopAppCandidates("claude", { home, platform, env }).some((candidate) => exists(candidate));
283
+ if (!detected) {
284
+ stdout("Claude App was not detected in a standard install location. Claude Code is connected; Claude Desktop was not changed.");
285
+ return { configured: false, supported: true, detected: false };
286
+ }
287
+
288
+ let gatewayUrl;
289
+ try {
290
+ gatewayUrl = new URL(claudeBaseUrl);
291
+ } catch {
292
+ stdout(`Claude Desktop was not configured because its Gateway URL is invalid: ${claudeBaseUrl}`);
293
+ return { configured: false, supported: true, detected: true };
294
+ }
295
+ if (gatewayUrl.protocol !== "https:") {
296
+ stdout("Claude Desktop was not configured because official 3P Gateway mode requires an HTTPS endpoint.");
297
+ return { configured: false, supported: true, detected: true };
298
+ }
299
+
300
+ const paths = connectionPaths({ home, platform });
301
+ const files = [
302
+ paths.claudeDesktopConfig,
303
+ paths.claudeDesktopLibraryMeta,
304
+ paths.claudeDesktopProfile,
305
+ paths.claudeDesktopCredentialHelper,
306
+ ];
307
+ for (const file of files) backupFile("claude", file, home, backupGroup);
308
+
309
+ const gateway = gatewayUrl.toString().replace(/\/+$/, "");
310
+ const inference = {
311
+ inferenceProvider: "gateway",
312
+ inferenceGatewayBaseUrl: gateway,
313
+ inferenceCredentialKind: "helper-script",
314
+ inferenceCredentialHelper: paths.claudeDesktopCredentialHelper,
315
+ inferenceGatewayAuthScheme: "bearer",
316
+ disableEssentialTelemetry: true,
317
+ disableNonessentialTelemetry: true,
318
+ };
319
+ const desktopConfig = { ...readJsonObject(paths.claudeDesktopConfig), deploymentMode: "3p", ...inference };
320
+ const libraryMeta = { ...readJsonObject(paths.claudeDesktopLibraryMeta), appliedId: CLAUDE_DESKTOP_PROFILE_ID };
321
+ const profile = { ...readJsonObject(paths.claudeDesktopProfile), ...inference };
322
+
323
+ // Activate last so Claude never observes a selected profile before its
324
+ // credential helper and config-library entry are durable on disk.
325
+ atomicWrite(paths.claudeDesktopCredentialHelper, claudeDesktopCredentialHelper(), 0o700);
326
+ atomicWrite(paths.claudeDesktopProfile, `${JSON.stringify(profile, null, 2)}\n`);
327
+ atomicWrite(paths.claudeDesktopLibraryMeta, `${JSON.stringify(libraryMeta, null, 2)}\n`);
328
+ atomicWrite(paths.claudeDesktopConfig, `${JSON.stringify(desktopConfig, null, 2)}\n`);
329
+
330
+ stdout(`Connected Claude Desktop (official 3P Gateway): ${paths.claudeDesktopProfile}`);
331
+ stdout("Quit and reopen Claude Desktop when no tasks are running. It will use a separate 3P session list; `4yi restore claude` switches back to the previous profile.");
332
+ return { configured: true, supported: true, detected: true, paths };
333
+ }
334
+
219
335
  async function loadClaudeModels(session, claudeBaseUrl) {
220
336
  const base = claudeBaseUrl.replace(/\/+$/, "");
221
337
  const response = await requestJson(base, "/v1/models", { token: session.token });
@@ -224,8 +340,8 @@ async function loadClaudeModels(session, claudeBaseUrl) {
224
340
  return models;
225
341
  }
226
342
 
227
- async function connectClaude({ session, home, cwd, scope, claudeBaseUrl, stdout }) {
228
- const paths = connectionPaths({ home, cwd });
343
+ async function connectClaude({ session, home, cwd, scope, claudeBaseUrl, stdout, tooling = {} }) {
344
+ const paths = connectionPaths({ home, cwd, platform: tooling.platform });
229
345
  const files = scope === "project" ? [paths.claudeProject] : scope === "both" ? [paths.claudeUser, paths.claudeProject] : [paths.claudeUser];
230
346
  const models = await loadClaudeModels(session, claudeBaseUrl);
231
347
  const defaultModel = chooseModel(models, "claude-sonnet-4-6", "sonnet");
@@ -236,11 +352,7 @@ async function connectClaude({ session, home, cwd, scope, claudeBaseUrl, stdout
236
352
  const backupGroup = timestamp();
237
353
  for (const file of files) {
238
354
  const backup = backupFile("claude", file, home, backupGroup);
239
- let data = {};
240
- if (fs.existsSync(file) && fs.statSync(file).size > 0) {
241
- data = JSON.parse(fs.readFileSync(file, "utf8"));
242
- if (!data || Array.isArray(data) || typeof data !== "object") throw new Error(`${file} must contain a JSON object.`);
243
- }
355
+ const data = readJsonObject(file);
244
356
  const env = data.env && !Array.isArray(data.env) && typeof data.env === "object" ? data.env : {};
245
357
  Object.assign(env, {
246
358
  ANTHROPIC_BASE_URL: claudeBaseUrl.replace(/\/+$/, ""),
@@ -260,6 +372,11 @@ async function connectClaude({ session, home, cwd, scope, claudeBaseUrl, stdout
260
372
  stdout(`Backup: ${backup}`);
261
373
  }
262
374
  stdout(`Available Claude models: ${models.join(", ")}`);
375
+ if (scope === "project") {
376
+ stdout("Claude Desktop was not changed because --scope project only configures this project.");
377
+ } else {
378
+ configureClaudeDesktop({ home, claudeBaseUrl, backupGroup, stdout, ...tooling });
379
+ }
263
380
  }
264
381
 
265
382
  function removeManagedBlock(text, start, end) {
@@ -311,6 +428,16 @@ function buildCodexCatalog(models, template) {
311
428
  description: `${planModel.display_name || planModel.id} via 4YI Gateway with OpenAI Responses tool support.`,
312
429
  supported_in_api: true,
313
430
  use_responses_lite: false,
431
+ // The template is whatever `codex debug models --bundled` returns on this
432
+ // machine, and that is not a stable schema: 0.147 carries this field,
433
+ // 0.150 omits it, and connect installs the latest codex -- so it upgrades
434
+ // its own template source into the version that drops it. Codex.app
435
+ // requires it and will not start without one, so it is set here rather
436
+ // than inherited. The endpoint is authoritative when it answers; `true`
437
+ // is the fallback because every model this catalog can describe is served
438
+ // through the Responses ingress, which requires `parallel_tool_calls` on
439
+ // each request.
440
+ supports_parallel_tool_calls: planModel.supports_parallel_tool_calls ?? true,
314
441
  tool_mode: undefined,
315
442
  })).map((model) => {
316
443
  delete model.tool_mode;
@@ -406,8 +533,7 @@ export async function connect({ target = "all", session, home = os.homedir(), cw
406
533
  await prepareConnectionTools({ target: normalized, autoInstall: options.autoInstall, stdout, tooling });
407
534
  }
408
535
  if (normalized === "claude" || normalized === "all") {
409
- await connectClaude({ session, home, cwd, scope, claudeBaseUrl: urls.claudeBaseUrl, stdout });
410
- reportDesktopApp("claude", { ...tooling, home, stdout });
536
+ await connectClaude({ session, home, cwd, scope, claudeBaseUrl: urls.claudeBaseUrl, stdout, tooling });
411
537
  }
412
538
  if (normalized === "codex" || normalized === "all") {
413
539
  await connectCodex({ session, home, codexHome, codexBaseUrl: urls.codexBaseUrl, stdout, skipCheck: options.skipCheck, tooling });
@@ -426,12 +552,52 @@ function readClaudeStatus(file) {
426
552
  }
427
553
  }
428
554
 
429
- export function connectionStatus({ target = "all", home = os.homedir(), cwd = process.cwd(), codexHome = process.env.CODEX_HOME, scope = "user", stdout = console.log } = {}) {
555
+ function readClaudeDesktopStatus(paths) {
556
+ const base = {
557
+ config: paths.claudeDesktopConfig,
558
+ profile: paths.claudeDesktopProfile,
559
+ connected: false,
560
+ };
561
+ try {
562
+ const config = readJsonObject(paths.claudeDesktopConfig);
563
+ const meta = readJsonObject(paths.claudeDesktopLibraryMeta);
564
+ const profile = readJsonObject(paths.claudeDesktopProfile);
565
+ const selected = meta.appliedId === CLAUDE_DESKTOP_PROFILE_ID;
566
+ const connected = config.deploymentMode === "3p"
567
+ && selected
568
+ && profile.inferenceProvider === "gateway"
569
+ && profile.inferenceCredentialKind === "helper-script"
570
+ && profile.inferenceGatewayAuthScheme === "bearer"
571
+ && profile.inferenceCredentialHelper === paths.claudeDesktopCredentialHelper
572
+ && fs.existsSync(paths.claudeDesktopCredentialHelper);
573
+ return {
574
+ ...base,
575
+ connected,
576
+ deploymentMode: config.deploymentMode,
577
+ selected,
578
+ baseUrl: profile.inferenceGatewayBaseUrl,
579
+ credential: profile.inferenceCredentialKind,
580
+ };
581
+ } catch (error) {
582
+ return { ...base, error: error.message };
583
+ }
584
+ }
585
+
586
+ export function connectionStatus({
587
+ target = "all",
588
+ home = os.homedir(),
589
+ cwd = process.cwd(),
590
+ codexHome = process.env.CODEX_HOME,
591
+ scope = "user",
592
+ platform = process.platform,
593
+ stdout = console.log,
594
+ } = {}) {
430
595
  const normalized = normalizeTarget(target);
431
- const paths = connectionPaths({ home, cwd, codexHome });
596
+ const paths = connectionPaths({ home, cwd, codexHome, platform });
432
597
  if (normalized === "claude" || normalized === "all") {
433
598
  const files = scope === "project" ? [paths.claudeProject] : scope === "both" ? [paths.claudeUser, paths.claudeProject] : [paths.claudeUser];
434
599
  for (const file of files) stdout(`Claude Code: ${JSON.stringify(readClaudeStatus(file))}`);
600
+ if (paths.claudeDesktopConfig) stdout(`Claude Desktop: ${JSON.stringify(readClaudeDesktopStatus(paths))}`);
435
601
  }
436
602
  if (normalized === "codex" || normalized === "all") {
437
603
  const text = fs.existsSync(paths.codexConfig) ? fs.readFileSync(paths.codexConfig, "utf8") : "";
@@ -473,7 +639,11 @@ export const __testing = {
473
639
  ensureToolCli,
474
640
  executableName,
475
641
  reportDesktopApp,
642
+ configureClaudeDesktop,
643
+ claudeDesktopCredentialHelper,
644
+ readClaudeDesktopStatus,
476
645
  removeManagedBlock,
477
646
  removeCodexRootAssignments,
478
647
  CLAUDE_MANAGED_ENV_KEYS,
648
+ CLAUDE_DESKTOP_PROFILE_ID,
479
649
  };
package/src/opencode.mjs CHANGED
@@ -10,6 +10,7 @@ const DEFAULT_CONTEXT_LIMIT = 200000;
10
10
  const DEFAULT_OUTPUT_LIMIT = 8192;
11
11
 
12
12
  function isClaudeModel(model) {
13
+ if (model?.family === "claude") return true;
13
14
  const id = model?.id || "";
14
15
  const name = model?.display_name || "";
15
16
  return /claude/i.test(id) || /claude/i.test(name);
@@ -23,14 +24,23 @@ function modelOutputLimit(model) {
23
24
  return model.output_limit || model.max_output_tokens || model.max_tokens || DEFAULT_OUTPUT_LIMIT;
24
25
  }
25
26
 
27
+ function modelPickerName(model) {
28
+ const displayName = model.display_name || model.id;
29
+ if (model.family === "claude") {
30
+ return `Claude · ${displayName.replace(/^(?:anthropic[ ._-]*)?claude[ ._-]*/i, "")}`;
31
+ }
32
+ if (model.family === "codex") return `Codex · ${displayName}`;
33
+ return displayName;
34
+ }
35
+
26
36
  export function buildOpenCodeConfig({ modelConfig, preferredModel = null, tokenEnv = "FOURYI_CLI_TOKEN", orgEnv = "FOURYI_ORG_ID" }) {
27
- // Register EVERY model the server returned so OpenCode's native switcher
28
- // (`Tab` / `/models`) can move between them. Claude stays the default.
37
+ // Register every server-approved coding model so OpenCode's native switcher
38
+ // (`Tab` / `/models`) can move between Claude and Codex. Claude stays the default.
29
39
  const list = modelConfig.models || [];
30
40
  const models = {};
31
41
  for (const model of list) {
32
42
  const entry = {
33
- name: model.display_name || model.id,
43
+ name: modelPickerName(model),
34
44
  limit: {
35
45
  context: modelContextLimit(model),
36
46
  output: modelOutputLimit(model),
@@ -111,7 +121,7 @@ export function ensureOpenCodeRuntime({ home = os.homedir(), stdout = console.lo
111
121
 
112
122
  export async function runCode({ session, home = os.homedir(), argv = [], stdout = console.log, preferredModel = null } = {}) {
113
123
  if (!session?.token) throw new Error("Not signed in. Run `4yi login`.");
114
- const modelConfig = await requestJson(session.baseUrl, "/api/cli/models", { token: session.token });
124
+ const modelConfig = await requestJson(session.baseUrl, "/api/cli/models?runtime=opencode", { token: session.token });
115
125
  const list = modelConfig.models || [];
116
126
  if (list.length === 0) throw new Error("No chat models available for this organization.");
117
127