@khalilgharbaoui/opencode-claude-code-plugin 0.1.4 → 0.1.6

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
@@ -89,6 +89,53 @@ Variants set the underlying reasoning effort. They're regular opencode model var
89
89
 
90
90
  The minimum config is just the `plugin` entry above. Everything below is optional override that goes in a `provider.claude-code` block.
91
91
 
92
+ ### Multiple Claude Code accounts
93
+
94
+ Declare account names once and the plugin expands them into separate opencode providers:
95
+
96
+ ```json
97
+ {
98
+ "plugin": ["@khalilgharbaoui/opencode-claude-code-plugin"],
99
+ "provider": {
100
+ "claude-code": {
101
+ "options": {
102
+ "accounts": ["personal", "work"]
103
+ }
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ `default` is always implicit, so the config above creates:
110
+
111
+ | Provider ID | Display name | Claude config dir |
112
+ |---|---|---|
113
+ | `claude-code-default` | `Claude Code (Default)` | normal `~/.claude` |
114
+ | `claude-code-personal` | `Claude Code (Personal)` | `~/.claude-personal` |
115
+ | `claude-code-work` | `Claude Code (Work)` | `~/.claude-work` |
116
+
117
+ Non-default accounts use `CLAUDE_CONFIG_DIR` through a generated wrapper script, so auth/session state stays isolated per account. Shared capability files and folders are symlinked from `~/.claude` into each account dir when present:
118
+
119
+ ```text
120
+ CLAUDE.md
121
+ settings.json
122
+ skills/
123
+ agents/
124
+ commands/
125
+ plugins/
126
+ ```
127
+
128
+ Identity/session state is not shared.
129
+
130
+ Login each account once:
131
+
132
+ ```bash
133
+ CLAUDE_CONFIG_DIR="$HOME/.claude-personal" claude auth login
134
+ CLAUDE_CONFIG_DIR="$HOME/.claude-work" claude auth login
135
+ ```
136
+
137
+ The account model IDs are internally suffixed, for example `claude-sonnet-4-6@work`, so long-lived Claude subprocess sessions do not collide across accounts. The generated wrapper strips the suffix before calling `claude --model`.
138
+
92
139
  ### Options reference
93
140
 
94
141
  ```json
@@ -112,6 +159,7 @@ The minimum config is just the `plugin` entry above. Everything below is optiona
112
159
  | Option | Type | Default | Description |
113
160
  |---|---|---|---|
114
161
  | `cliPath` | string | `process.env.CLAUDE_CLI_PATH ?? "claude"` | Path to the `claude` binary. |
162
+ | `accounts` | string[] | – | Optional account list. `default` is implicit. Expands into `Claude Code (Default)`, `Claude Code (Personal)`, etc. |
115
163
  | `cwd` | string | `process.cwd()` | Working directory for the spawned CLI. Resolved **lazily per request**, so opencode's project switching works. |
116
164
  | `skipPermissions` | boolean | `true` | Pass `--dangerously-skip-permissions` to `claude`. Ignored when `proxyTools` is set — the proxy handles permissions through opencode instead. |
117
165
  | `permissionMode` | `acceptEdits` \| `auto` \| `bypassPermissions` \| `default` \| `dontAsk` \| `plan` | – | Forwarded to `claude --permission-mode`. |
@@ -221,7 +269,7 @@ To replace (rather than augment) bridged MCP with your own:
221
269
 
222
270
  Each chat keeps a long-lived `claude` subprocess so the model retains its native context across turns.
223
271
 
224
- - **Session key**: `(cwd, model, tool-scope, opencode-session-id)`. The opencode session id comes from the `x-session-affinity` header opencode sets on third-party provider calls. Two chats in the same project on the same model run in **separate** CLI processes — they don't race.
272
+ - **Session key**: `(cwd, model, tool-scope, opencode-session-id)`. The opencode session id comes from the `x-session-affinity` header opencode sets on third-party provider calls. Two chats in the same project on the same model run in **separate** CLI processes — they don't race. In account mode, model IDs are suffixed per account, so account sessions do not collide.
225
273
  - **Same chat, multiple turns** → process reused, full Claude context retained.
226
274
  - **New chat** → fresh process under the new session key.
227
275
  - **Resumed chat after restart** → in-memory state is gone; a new process spawns and the conversation history is summarized and prepended.
package/dist/index.d.ts CHANGED
@@ -83,6 +83,9 @@ interface ClaudeCodeConfig {
83
83
  provider: string;
84
84
  cliPath: string;
85
85
  cwd?: string;
86
+ account?: string;
87
+ configDir?: string;
88
+ providerID?: string;
86
89
  skipPermissions?: boolean;
87
90
  permissionMode?: PermissionMode;
88
91
  mcpConfig?: string | string[];
@@ -97,6 +100,10 @@ interface ClaudeCodeProviderSettings {
97
100
  cliPath?: string;
98
101
  cwd?: string;
99
102
  name?: string;
103
+ providerID?: string;
104
+ account?: string;
105
+ configDir?: string;
106
+ accounts?: string[];
100
107
  skipPermissions?: boolean;
101
108
  permissionMode?: PermissionMode;
102
109
  mcpConfig?: string | string[];
package/dist/index.js CHANGED
@@ -2457,10 +2457,43 @@ function defineModel(opts) {
2457
2457
  var haikuCost = { input: 1e-6, output: 5e-6, cacheRead: 1e-7, cacheWrite: 125e-8 };
2458
2458
  var sonnetCost = { input: 3e-6, output: 15e-6, cacheRead: 3e-7, cacheWrite: 375e-8 };
2459
2459
  var opusCost = { input: 15e-6, output: 75e-6, cacheRead: 15e-7, cacheWrite: 1875e-8 };
2460
+ function toConfigModel(model) {
2461
+ const inputMods = [];
2462
+ const outputMods = [];
2463
+ for (const [k, v] of Object.entries(model.capabilities.input)) {
2464
+ if (v) inputMods.push(k);
2465
+ }
2466
+ for (const [k, v] of Object.entries(model.capabilities.output)) {
2467
+ if (v) outputMods.push(k);
2468
+ }
2469
+ return {
2470
+ id: model.api.id,
2471
+ name: model.name,
2472
+ status: model.status,
2473
+ family: model.family ?? "",
2474
+ release_date: model.release_date,
2475
+ temperature: model.capabilities.temperature,
2476
+ reasoning: model.capabilities.reasoning,
2477
+ attachment: model.capabilities.attachment,
2478
+ tool_call: model.capabilities.toolcall,
2479
+ modalities: { input: inputMods, output: outputMods },
2480
+ interleaved: model.capabilities.interleaved,
2481
+ cost: {
2482
+ input: model.cost.input,
2483
+ output: model.cost.output,
2484
+ cache_read: model.cost.cache.read,
2485
+ cache_write: model.cost.cache.write
2486
+ },
2487
+ limit: model.limit,
2488
+ options: model.options,
2489
+ headers: model.headers,
2490
+ variants: model.variants
2491
+ };
2492
+ }
2460
2493
  var defaultModels = {
2461
2494
  "claude-haiku-4-5": defineModel({
2462
2495
  id: "claude-haiku-4-5",
2463
- name: "Claude Code Haiku 4.5",
2496
+ name: "Claude Haiku 4.5",
2464
2497
  family: "haiku",
2465
2498
  reasoning: false,
2466
2499
  context: 2e5,
@@ -2470,7 +2503,7 @@ var defaultModels = {
2470
2503
  }),
2471
2504
  "claude-sonnet-4-5": defineModel({
2472
2505
  id: "claude-sonnet-4-5",
2473
- name: "Claude Code Sonnet 4.5",
2506
+ name: "Claude Sonnet 4.5",
2474
2507
  family: "sonnet",
2475
2508
  reasoning: true,
2476
2509
  context: 1e6,
@@ -2480,7 +2513,7 @@ var defaultModels = {
2480
2513
  }),
2481
2514
  "claude-sonnet-4-6": defineModel({
2482
2515
  id: "claude-sonnet-4-6",
2483
- name: "Claude Code Sonnet 4.6",
2516
+ name: "Claude Sonnet 4.6",
2484
2517
  family: "sonnet",
2485
2518
  reasoning: true,
2486
2519
  context: 1e6,
@@ -2490,7 +2523,7 @@ var defaultModels = {
2490
2523
  }),
2491
2524
  "claude-opus-4-5": defineModel({
2492
2525
  id: "claude-opus-4-5",
2493
- name: "Claude Code Opus 4.5",
2526
+ name: "Claude Opus 4.5",
2494
2527
  family: "opus",
2495
2528
  reasoning: true,
2496
2529
  context: 1e6,
@@ -2500,7 +2533,7 @@ var defaultModels = {
2500
2533
  }),
2501
2534
  "claude-opus-4-6": defineModel({
2502
2535
  id: "claude-opus-4-6",
2503
- name: "Claude Code Opus 4.6",
2536
+ name: "Claude Opus 4.6",
2504
2537
  family: "opus",
2505
2538
  reasoning: true,
2506
2539
  context: 1e6,
@@ -2510,7 +2543,7 @@ var defaultModels = {
2510
2543
  }),
2511
2544
  "claude-opus-4-7": defineModel({
2512
2545
  id: "claude-opus-4-7",
2513
- name: "Claude Code Opus 4.7",
2546
+ name: "Claude Opus 4.7",
2514
2547
  family: "opus",
2515
2548
  reasoning: true,
2516
2549
  context: 1e6,
@@ -2520,16 +2553,161 @@ var defaultModels = {
2520
2553
  })
2521
2554
  };
2522
2555
 
2556
+ // src/accounts.ts
2557
+ import { chmod, lstat, mkdir, readlink, symlink, writeFile } from "fs/promises";
2558
+ import path3 from "path";
2559
+ var BASE_PROVIDER_ID = "claude-code";
2560
+ var DEFAULT_ACCOUNT = "default";
2561
+ var SHARED_CAPABILITY_ITEMS = [
2562
+ "CLAUDE.md",
2563
+ "settings.json",
2564
+ "skills",
2565
+ "agents",
2566
+ "commands",
2567
+ "plugins"
2568
+ ];
2569
+ function normalizeAccountName(account) {
2570
+ return account.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2571
+ }
2572
+ function resolveAccounts(value) {
2573
+ if (!Array.isArray(value)) return null;
2574
+ const accounts = value.map((account) => normalizeAccountName(String(account))).filter(Boolean);
2575
+ return Array.from(/* @__PURE__ */ new Set([DEFAULT_ACCOUNT, ...accounts]));
2576
+ }
2577
+ function accountProviderId(account) {
2578
+ return `${BASE_PROVIDER_ID}-${normalizeAccountName(account)}`;
2579
+ }
2580
+ function accountDisplayName(account) {
2581
+ return `Claude Code (${titleizeAccount(account)})`;
2582
+ }
2583
+ function accountModelSuffix(account) {
2584
+ const normalized = normalizeAccountName(account);
2585
+ return normalized === DEFAULT_ACCOUNT ? void 0 : normalized;
2586
+ }
2587
+ function accountConfigDir(account) {
2588
+ const normalized = normalizeAccountName(account);
2589
+ if (!normalized || normalized === DEFAULT_ACCOUNT) return void 0;
2590
+ return `~/.claude-${normalized}`;
2591
+ }
2592
+ function expandHome(value) {
2593
+ const home = process.env.HOME ?? process.env.USERPROFILE;
2594
+ if (value === "~") return home ?? value;
2595
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
2596
+ return home ? path3.join(home, value.slice(2)) : value;
2597
+ }
2598
+ return value;
2599
+ }
2600
+ async function ensureAccountRuntime(account, baseCliPath) {
2601
+ const configDir = accountConfigDir(account);
2602
+ if (!configDir) return { cliPath: baseCliPath };
2603
+ const expandedConfigDir = expandHome(configDir);
2604
+ await mkdir(expandedConfigDir, { recursive: true });
2605
+ try {
2606
+ await ensureSharedCapabilities(expandedConfigDir);
2607
+ } catch (err) {
2608
+ log.warn("failed to symlink shared capabilities; continuing anyway", {
2609
+ account,
2610
+ configDir: expandedConfigDir,
2611
+ error: String(err)
2612
+ });
2613
+ }
2614
+ const cliPath = await writeAccountWrapper(
2615
+ normalizeAccountName(account),
2616
+ baseCliPath,
2617
+ expandedConfigDir
2618
+ );
2619
+ return { cliPath, configDir };
2620
+ }
2621
+ async function ensureSharedCapabilities(targetRoot) {
2622
+ const sourceRoot = expandHome("~/.claude");
2623
+ for (const item of SHARED_CAPABILITY_ITEMS) {
2624
+ await ensureSharedCapabilityItem(sourceRoot, targetRoot, item);
2625
+ }
2626
+ }
2627
+ async function ensureSharedCapabilityItem(sourceRoot, targetRoot, item) {
2628
+ const source = path3.join(sourceRoot, item);
2629
+ const target = path3.join(targetRoot, item);
2630
+ let sourceStat;
2631
+ try {
2632
+ sourceStat = await lstat(source);
2633
+ } catch {
2634
+ return;
2635
+ }
2636
+ try {
2637
+ const targetStat = await lstat(target);
2638
+ if (targetStat.isSymbolicLink()) {
2639
+ const current = await readlink(target);
2640
+ const resolvedCurrent = path3.resolve(path3.dirname(target), current);
2641
+ const resolvedSource = path3.resolve(source);
2642
+ if (resolvedCurrent === resolvedSource) return;
2643
+ }
2644
+ log.warn("shared Claude capability already exists; leaving untouched", {
2645
+ item,
2646
+ target,
2647
+ source
2648
+ });
2649
+ return;
2650
+ } catch {
2651
+ }
2652
+ const type = sourceStat.isDirectory() ? process.platform === "win32" ? "junction" : "dir" : "file";
2653
+ await symlink(source, target, type);
2654
+ }
2655
+ async function writeAccountWrapper(account, baseCliPath, configDir) {
2656
+ const cacheRoot = path3.join(
2657
+ process.env.XDG_CACHE_HOME ?? expandHome("~/.cache"),
2658
+ "opencode-claude-code-plugin"
2659
+ );
2660
+ const wrapperPath = path3.join(cacheRoot, `claude-${account}`);
2661
+ const suffix = `@${account}`;
2662
+ await mkdir(cacheRoot, { recursive: true });
2663
+ const script = `#!/usr/bin/env bash
2664
+ set -euo pipefail
2665
+
2666
+ args=()
2667
+ while [[ $# -gt 0 ]]; do
2668
+ if [[ "$1" == "--model" && $# -ge 2 ]]; then
2669
+ model="$2"
2670
+ if [[ "$model" == *${shellDoubleQuote(suffix)} ]]; then
2671
+ model="\${model%${shellDoubleQuote(suffix)}}"
2672
+ fi
2673
+ args+=("$1" "$model")
2674
+ shift 2
2675
+ else
2676
+ args+=("$1")
2677
+ shift
2678
+ fi
2679
+ done
2680
+
2681
+ export CLAUDE_CONFIG_DIR=${shellSingleQuote(configDir)}
2682
+ exec ${shellSingleQuote(baseCliPath)} "\${args[@]}"
2683
+ `;
2684
+ await writeFile(wrapperPath, script, "utf8");
2685
+ await chmod(wrapperPath, 493);
2686
+ return wrapperPath;
2687
+ }
2688
+ function shellSingleQuote(value) {
2689
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
2690
+ }
2691
+ function shellDoubleQuote(value) {
2692
+ return value.replace(/[$`"\\]/g, "\\$&");
2693
+ }
2694
+ function titleizeAccount(account) {
2695
+ return normalizeAccountName(account).split("-").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2696
+ }
2697
+
2523
2698
  // src/index.ts
2524
2699
  function createClaudeCode(settings = {}) {
2525
2700
  const cliPath = settings.cliPath ?? process.env.CLAUDE_CLI_PATH ?? "claude";
2526
- const providerName = settings.name ?? "claude-code";
2701
+ const providerName = settings.providerID ?? settings.name ?? "claude-code";
2527
2702
  const proxyTools = settings.proxyTools ?? ["Bash", "Edit", "Write", "WebFetch"];
2528
2703
  const createModel = (modelId) => {
2529
2704
  return new ClaudeCodeLanguageModel(modelId, {
2530
2705
  provider: providerName,
2531
2706
  cliPath,
2532
2707
  cwd: settings.cwd,
2708
+ account: settings.account,
2709
+ configDir: settings.configDir,
2710
+ providerID: settings.providerID,
2533
2711
  skipPermissions: settings.skipPermissions ?? true,
2534
2712
  permissionMode: settings.permissionMode,
2535
2713
  mcpConfig: settings.mcpConfig,
@@ -2548,11 +2726,16 @@ function createClaudeCode(settings = {}) {
2548
2726
  provider.languageModel = createModel;
2549
2727
  return provider;
2550
2728
  }
2551
- var PROVIDER_ID2 = "claude-code";
2729
+ var PROVIDER_ID2 = BASE_PROVIDER_ID;
2552
2730
  var PACKAGE_NPM = "@khalilgharbaoui/opencode-claude-code-plugin";
2553
2731
  function pluginEntrypoint() {
2554
2732
  return import.meta.url.startsWith("file:") ? import.meta.url : PACKAGE_NPM;
2555
2733
  }
2734
+ function cleanProviderOptions(options = {}) {
2735
+ const result = { ...options };
2736
+ delete result.accounts;
2737
+ return result;
2738
+ }
2556
2739
  function mergeDefaultVariants(models = {}) {
2557
2740
  const result = { ...models };
2558
2741
  for (const [id, model] of Object.entries(defaultModels)) {
@@ -2569,16 +2752,20 @@ function mergeDefaultVariants(models = {}) {
2569
2752
  }
2570
2753
  return result;
2571
2754
  }
2572
- function defaultModelsForProvider(providerModels) {
2755
+ function defaultModelsForProvider(providerModels, providerID = PROVIDER_ID2, modelSuffix) {
2573
2756
  const models = Object.fromEntries(
2574
2757
  Object.entries(defaultModels).map(([id, model]) => {
2575
- const existing = providerModels[id];
2758
+ const modelId = modelSuffix ? `${id}@${modelSuffix}` : id;
2759
+ const existing = providerModels[id] ?? providerModels[modelId];
2576
2760
  return [
2577
- id,
2761
+ modelId,
2578
2762
  {
2579
2763
  ...model,
2764
+ id: modelId,
2765
+ providerID,
2580
2766
  api: {
2581
2767
  ...model.api,
2768
+ id: modelId,
2582
2769
  npm: existing?.api?.npm ?? model.api.npm,
2583
2770
  url: existing?.api?.url ?? model.api.url
2584
2771
  }
@@ -2587,29 +2774,113 @@ function defaultModelsForProvider(providerModels) {
2587
2774
  })
2588
2775
  );
2589
2776
  for (const [id, model] of Object.entries(providerModels)) {
2590
- if (!(id in models)) models[id] = model;
2777
+ if (!(id in models)) {
2778
+ models[id] = {
2779
+ ...model,
2780
+ providerID
2781
+ };
2782
+ }
2591
2783
  }
2592
2784
  return models;
2593
2785
  }
2594
- function providerConfig(existing) {
2786
+ function configModelsForProvider(providerModels, providerID, modelSuffix) {
2787
+ const models = {};
2788
+ for (const [id, model] of Object.entries(defaultModels)) {
2789
+ const modelId = modelSuffix ? `${id}@${modelSuffix}` : id;
2790
+ const existing = providerModels[id] ?? providerModels[modelId];
2791
+ const full = {
2792
+ ...model,
2793
+ id: modelId,
2794
+ providerID,
2795
+ api: {
2796
+ ...model.api,
2797
+ id: modelId,
2798
+ npm: existing?.api?.npm ?? model.api.npm,
2799
+ url: existing?.api?.url ?? model.api.url
2800
+ }
2801
+ };
2802
+ models[modelId] = toConfigModel(full);
2803
+ }
2804
+ for (const [id, model] of Object.entries(providerModels)) {
2805
+ if (!(id in models)) {
2806
+ models[id] = toConfigModel({ ...model, providerID });
2807
+ }
2808
+ }
2809
+ return models;
2810
+ }
2811
+ async function providerConfig(existing, providerID = PROVIDER_ID2, optionDefaults = {}, displayName) {
2812
+ const mergedOptions = {
2813
+ cliPath: "claude",
2814
+ proxyTools: ["Bash", "Edit", "Write", "WebFetch"],
2815
+ ...optionDefaults,
2816
+ ...cleanProviderOptions(existing?.options),
2817
+ providerID
2818
+ };
2819
+ const cliPath = String(mergedOptions.cliPath ?? "claude");
2820
+ const account = typeof mergedOptions.account === "string" ? mergedOptions.account : void 0;
2821
+ const runtime = account ? await ensureAccountRuntime(account, cliPath) : { cliPath };
2595
2822
  return {
2596
- name: existing?.name,
2823
+ name: displayName ?? existing?.name,
2597
2824
  npm: existing?.npm ?? pluginEntrypoint(),
2598
2825
  options: {
2599
- cliPath: "claude",
2600
- proxyTools: ["Bash", "Edit", "Write", "WebFetch"],
2601
- ...existing?.options ?? {}
2826
+ ...mergedOptions,
2827
+ ...runtime
2602
2828
  },
2603
2829
  models: mergeDefaultVariants(existing?.models)
2604
2830
  };
2605
2831
  }
2832
+ async function expandAccountProviders(config) {
2833
+ const seed = config.provider?.[PROVIDER_ID2];
2834
+ const accounts = resolveAccounts(seed?.options?.accounts);
2835
+ if (!accounts) return false;
2836
+ config.provider ??= {};
2837
+ const seedOptions = cleanProviderOptions(seed?.options);
2838
+ let expandedCount = 0;
2839
+ for (const account of accounts) {
2840
+ const providerID = accountProviderId(account);
2841
+ try {
2842
+ const existing = config.provider[providerID];
2843
+ const modelSuffix = accountModelSuffix(account);
2844
+ config.provider[providerID] = {
2845
+ ...existing,
2846
+ ...await providerConfig(
2847
+ existing,
2848
+ providerID,
2849
+ {
2850
+ ...seedOptions,
2851
+ account
2852
+ },
2853
+ accountDisplayName(account)
2854
+ ),
2855
+ models: configModelsForProvider(
2856
+ existing?.models ?? seed?.models ?? {},
2857
+ providerID,
2858
+ modelSuffix
2859
+ )
2860
+ };
2861
+ expandedCount++;
2862
+ } catch (err) {
2863
+ log.error("failed to expand account provider", {
2864
+ account,
2865
+ providerID,
2866
+ error: String(err)
2867
+ });
2868
+ }
2869
+ }
2870
+ if (expandedCount > 0) {
2871
+ delete config.provider[PROVIDER_ID2];
2872
+ }
2873
+ return expandedCount > 0;
2874
+ }
2606
2875
  var server = async () => ({
2607
2876
  config: async (config) => {
2608
2877
  config.provider ??= {};
2878
+ const expanded = await expandAccountProviders(config);
2879
+ if (expanded) return;
2609
2880
  const existing = config.provider[PROVIDER_ID2];
2610
2881
  config.provider[PROVIDER_ID2] = {
2611
2882
  ...existing,
2612
- ...providerConfig(existing)
2883
+ ...await providerConfig(existing)
2613
2884
  };
2614
2885
  },
2615
2886
  provider: {