From b0fd97b134b094af65f6bf4cb480df638eb5960a Mon Sep 17 00:00:00 2001
From: JUN
Date: Sat, 12 Sep 2026 13:43:25 +0900
Subject: [PATCH 1/6] docs: record source-backed Cline integration roadmap
---
devlog/_plan/260912_cline_client/000_plan.md | 24 +++++++++++++++++++
.../_plan/260912_cline_client/001_contract.md | 14 +++++++++++
.../260912_cline_client/009_roadmap_result.md | 5 ++++
.../_plan/260912_cline_client/010_contract.md | 22 +++++++++++++++++
.../_plan/260912_cline_client/020_surfaces.md | 12 ++++++++++
.../260912_cline_client/030_verification.md | 9 +++++++
6 files changed, 86 insertions(+)
create mode 100644 devlog/_plan/260912_cline_client/000_plan.md
create mode 100644 devlog/_plan/260912_cline_client/001_contract.md
create mode 100644 devlog/_plan/260912_cline_client/009_roadmap_result.md
create mode 100644 devlog/_plan/260912_cline_client/010_contract.md
create mode 100644 devlog/_plan/260912_cline_client/020_surfaces.md
create mode 100644 devlog/_plan/260912_cline_client/030_verification.md
diff --git a/devlog/_plan/260912_cline_client/000_plan.md b/devlog/_plan/260912_cline_client/000_plan.md
new file mode 100644
index 0000000000..db1e08735d
--- /dev/null
+++ b/devlog/_plan/260912_cline_client/000_plan.md
@@ -0,0 +1,24 @@
+# Cline integration roadmap
+
+Cline users need a reversible connection and routed model list. This unit connects the current Cline CLI storage contract through the existing integration operations and dashboard. Cline stores connection settings and the model catalog separately, so one operation must snapshot and restore both files.
+
+Loop: satisfy-spec, HOTL; trigger: #4214 and delegated lane=cline. Scope: config exporters, integration writer/reader/journal projection, existing CLI/catalog/dashboard registries, translated copy, source fixtures. Non-goals: legacy extension storage migration, Cline process control, user configuration changes during development, merges/releases, new dependencies. All local product suites, builds/typecheck/install are NOT RUN by instruction; regression execution belongs to final-tip GitHub hosted CI. Text checks and independent source audits are allowed. No user token/time/agent caps; existing tool/account scope only.
+
+Outcome: DONE requires source-backed contracts, actual implementation, separate PABCD cycles, independent reviews, final cumulative head CI, PR and durable handoff. Unavailable tools or genuine external blockers are recorded without claiming completion. Main implements; inherited read-only subagents review. Native architect role selection is unavailable; the explicit parent instruction authorizes supported spawn for actual design and reflection reviews. No role installation or settings changes.
+
+Sources: [001_contract.md](001_contract.md). Repository owners: src/clients/config-export.ts, src/integrations/{registry,state,writer,journal,store,config-io}. Existing config builder, exact fragment ownership, atomic file writes and journal are reused. No-op/manual-only configuration cannot meet sync/undo; a separate standalone configuration engine is unnecessary.
+
+| Cycle | Deliverable | Dependency | Design |
+| --- | --- | --- | --- |
+| roadmap | Audited docs only | none | This roadmap and all decade docs |
+| contract | Pure Cline documents, paths, paired journal adapter and regression fixtures | roadmap | 010_contract.md |
+| surfaces | Catalog refresh, CLI help and existing dashboard exposure | contract | 020_surfaces.md |
+| verification | Independent audit, fixes, PR publication and final-tip hosted CI | surfaces | 030_verification.md |
+
+PR decision: one cohesive Cline PR unless the audited paired-file foundation is independently useful and large enough to split. Ordinary manual chain only if split; no native stacks. Intermediate commits may be pushed without waiting on CI. Parent owns merge.
+
+Verification: git diff --check (text only), source review, final-head Cross-platform CI (tests/typecheck/GUI build/lint). Source tests use temporary home/store; no real Cline data. Fixture cases: missing/partial install; malformed, non-regular or foreign edited files; wrong version; foreign provider preserved; owned model removal/port change; second-file failure; bookkeeping failure; interrupted transaction and drift; exact two-file restore including absence. A Cline restart is required after externally written catalogs. Live client process behavior is source-backed, not a claimed local canary.
+
+Public source docs update structure/clients/integrations.md plus CLI/UI owning docs for changed surfaces; public user workflow resides in docs-site. Unreleased security analysis stays under .tmp/cline. Durable handoff: .tmp/cline/handoff.md.
+
+Design reflection: ALIGNED D09/D10. R02 accepted: no unattended Cline refresh; explicit sync only with stopped-client precondition. R03 accepted: journal presence never blesses a mixed pair or inconsistent ownership. Verify both intended bytes and final record before clearing a committed marker.
diff --git a/devlog/_plan/260912_cline_client/001_contract.md b/devlog/_plan/260912_cline_client/001_contract.md
new file mode 100644
index 0000000000..bbd9cd3c0d
--- /dev/null
+++ b/devlog/_plan/260912_cline_client/001_contract.md
@@ -0,0 +1,14 @@
+# Cline source contract
+
+Upstream revision: cline/cline cfe9cadab99617d5013bf89f07b079d105057791, read 2026-09-12. Scope is the current CLI/shared SDK provider store. Legacy VS Code globalState/secrets storage is not the same contract and is not detected as compatible.
+
+- sdk/packages/shared/src/storage/paths.ts:152-185,424-430 resolves CLINE_PROVIDER_SETTINGS_PATH; otherwise CLINE_DATA_DIR/settings/providers.json; otherwise CLINE_DIR/data/settings/providers.json; otherwise ~/.cline/data/settings/providers.json. Relative overrides are rejected by OpenCodex because its cwd is not Cline's cwd.
+- sdk/packages/core/src/types/provider-settings.ts:33-68 defines version=1, optional lastUsedProvider, modes={}, providers[id]={settings,updatedAt,tokenSource}. settings.provider is the provider ID; protocol openai-responses, client openai, baseUrl, apiKey and model are accepted.
+- sdk/packages/core/src/services/llms/provider-settings.ts:155-199,224-315 maps protocol openai-responses to the OpenAI handler while retaining the custom provider ID and namespaced model.
+- sdk/packages/core/src/services/providers/local-provider-registry.ts:48-121 defines sibling models.json: version=1, providers[id]={provider:{name,baseUrl,protocol,client,defaultModelId},models:{id:{name,contextWindow,modalities,supportsVision}}}.
+- The same file:689-716 caches model-file loading per process. Restart Cline after external updates; do not claim a running picker is live-synchronized.
+
+CLINE-D09 accepted: settings.modelCatalog.url is not an OpenAI /v1/models endpoint. Its loader expects models.dev data, and picker paths do not forward that setting. A single providers.json does not meet full catalog acceptance.
+CLINE-D10 accepted: write providers.json plus sibling models.json as one recoverable journal operation. Each rename is atomic; a filesystem has no atomic rename across both files. Stop Cline before apply/refresh/restore and restart after. Interrupted writes require durable recovery and foreign edits must refuse recovery.
+
+All paths above are pinned under https://github.com/cline/cline/blob/cfe9cadab99617d5013bf89f07b079d105057791/ . No upstream implementation is copied; schema-shaped fixtures use synthetic data. No original carry PR or author credit applies. #3833 is a Command Code reference, not a dependency.
diff --git a/devlog/_plan/260912_cline_client/009_roadmap_result.md b/devlog/_plan/260912_cline_client/009_roadmap_result.md
new file mode 100644
index 0000000000..d5fe20dcb0
--- /dev/null
+++ b/devlog/_plan/260912_cline_client/009_roadmap_result.md
@@ -0,0 +1,5 @@
+# Roadmap cycle result
+
+Docs-only roadmap locked after inherited source/design review and independent A audit. Accepted D09/D10, excluded unattended refresh, strengthened journaled recovery recognition, and added target collision refusal. Existing CLI syntax is --client cline / restore --op; no positional alias is invented.
+
+Source audit confirms two files are needed. No product files changed in this cycle. git diff --check is the permitted document whitespace check; local product tests are NOT RUN. Next cycle executes 010_contract.md; downstream implementation and hosted proof remain open. The one-file modelCatalog URL hypothesis did not survive upstream inspection.
diff --git a/devlog/_plan/260912_cline_client/010_contract.md b/devlog/_plan/260912_cline_client/010_contract.md
new file mode 100644
index 0000000000..1195609740
--- /dev/null
+++ b/devlog/_plan/260912_cline_client/010_contract.md
@@ -0,0 +1,22 @@
+# Contract and paired-file operations
+
+Depends on roadmap. C4 care for reversible config mutation; no local execution. Use existing journal and ownership, not a second ownership database.
+
+NEW src/clients/config-export/cline.ts: typed pure buildClineClientConfig, summarizeCline and buildClineContribution. Build {settings:{version:1,modes:{},providers:{opencodex:{settings:{provider:'opencodex',protocol:'openai-responses',client:'openai',apiKey:LOOPBACK_API_KEY_PLACEHOLDER,baseUrl},updatedAt:stable schema timestamp,tokenSource:'manual'}}},catalog:{version:1,providers:{opencodex:{provider:{name:'OpenCodex',baseUrl,protocol:'openai-responses',client:'openai'},models}}}}. Namespaced routed IDs are model-map keys. Preserve authoritative context and modality metadata; omit invented costs/output limits. Do not manage lastUsedProvider: users select OpenCodex explicitly (cline --provider opencodex --model provider/model), preserving their default. Empty catalog exports no invented default/model. Managed paths are settings.providers.opencodex and catalog.providers.opencodex; schema envelope defaults are initialized only when absent and retained on disable.
+
+MODIFY src/clients/config-export/contracts.ts: append cline to ExportClientId. Creation: EXPORT_CLIENTS; serialization: builder/JSON download; deserialization: isExportClientId and journal isIntegrationClientId; consumers: integration registries, CLI export, management route and browser lists in 020.
+MODIFY src/clients/config-export.ts: import/register cline builder and append EXPORT_CLIENTS.cline; export path helpers using existing absoluteClientPath. destination is providers.json; filename cline-config-bundle.json; format json; hint explains two document members and restart, no standalone direct-file claim; loopbackOnly true.
+MODIFY src/integrations/registry.ts: append cline paths, detect settings directory, sibling lock .lock. Resolve explicit provider-file override first and derive catalog from dirname; no user discovery scanning.
+
+NEW src/integrations/cline-document.ts: private codec between two raw file strings (snapshot bundle) and logical settings/catalog objects (managed fragments). Reads preserve exact raw bytes and file absence. Parsing invokes existing safe strict JSON parser for each member; reject invalid/non-object schema and unsupported version. Rendering initializes missing version=1/modes={} and serializes both native files, then canonicalizes the raw-string snapshot envelope. Never serialize journal-only metadata into Cline files.
+NEW src/integrations/cline-io.ts: adapt the existing IntegrationIO only for cline and the resolved primary path. Secondary path is dirname(primary)/models.json; no caller-provided arbitrary secondary path. Reject primary path named models.json (case-insensitive), and refuse symlink/non-regular members so two logical members cannot alias one target. Read/stat inspect both members; write uses existing atomic writer for each, compensates on any failure. Snapshot remains the existing journal's one bundle snapshot. Persist a private pending transaction before first rename, including original/result bundle and prior ownership; clear only after journal append. Recover an interrupted operation only on an explicit mutation, only if every current file is exactly original or intended result; if its opId is already journaled, recognize completion only when BOTH intended bytes and final ownership match; otherwise retain unsafe pending state. Foreign edits or invalid pending data refuse and retain recovery evidence. Read-only status never performs recovery. Report partial compensation failure truthfully.
+MODIFY src/integrations/config-io.ts: optional begin/finish transaction hooks on IntegrationIO; existing clients unaffected.
+MODIFY src/integrations/writer.ts: resolve Cline adapter after path resolution, invoke begin hook before commit; use client codec at parse/render boundaries; finish after journal or successful compensation; preserve existing state/refusal logic. Restore reads the same bundle and journal. Coordinated mutation locks encompass recovery and commit.
+MODIFY src/integrations/state.ts: same adapter/codec for classification; pending state unsafe; no mutation on read. Journal API's matchesOperationResult projection reads through the adapter so restore eligibility describes both files.
+MODIFY structure/clients/integrations.md: state two-file ownership and transaction/restart/rollback contract.
+
+NEW tests/clients/cline-client.test.ts: source-shaped generation, env precedence, no secrets, model removal/metadata, initialized install and missing install. NEW tests/clients/cline-writer.test.ts: temporary home and store with real writer, both-file exact restore, absent member restore, invalid JSON/schema, occupied provider refusal/explicit overwrite, foreign edits, second-file and journal failures, crash recovery and foreign-edit refusal. Register both paths in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json. Extend existing exact client-list assertions without deleting checks.
+
+Activation evidence is final-tip hosted tests. Locally only diff/text audit; code unverified until hosted result. Pending marker is an early recovery mechanism, not cross-process exclusion against Cline: Cline does not share OpenCodex's lock. User-visible contract requires it stopped; filesystem rename cannot prevent a non-cooperating writer. No claim of simultaneous two-file visibility.
+
+A synthesis: accepted target collision guard and regression; post-journal marker cleanup is best-effort and must not cause compensation. Normal Cline settings saves update updatedAt/model, so declare only settings.providers.opencodex.updatedAt and settings.providers.opencodex.settings.model as refreshable; preserve selected model while still in desired catalog, remove it if no longer routed. All connection/auth metadata remains protected.
diff --git a/devlog/_plan/260912_cline_client/020_surfaces.md b/devlog/_plan/260912_cline_client/020_surfaces.md
new file mode 100644
index 0000000000..3cee18b85c
--- /dev/null
+++ b/devlog/_plan/260912_cline_client/020_surfaces.md
@@ -0,0 +1,12 @@
+# Existing CLI, catalog and dashboard surfaces
+
+Depends on contract; re-read source after 010 before B. Extend existing entry maps, no new endpoint/component architecture.
+
+Keep Cline out of unattended catalog-refresh default ids because the client must be stopped. MODIFY explicit sync lists src/cli/dispatch.ts and src/server/management/config-routes.ts: append cline. Keep lazy owned-only refresh and foreign-edit refusal; regression in tests/clients/cline-writer.test.ts verifies unowned untouched, owned refresh changes both files, model removal and endpoint changes.
+MODIFY src/cli/registry.ts export usage/summary: append cline/Cline. Existing integration verbs remain --client cline and restore --op ID; documentation must not invent positional arguments. Update focused CLI export/list expectations and existing sync source fixtures.
+
+MODIFY gui/src/pages/integrations/integration-api.ts client tuple, integration-tabs.ts TABS/FILE_CLIENTS, gui/src/app-routing.ts hash list, overview-clients.ts label map, FileIntegrationPage.tsx semantics/label maps, gui/src/components/apikeys-workspace/client-config-clients.ts CLIENTS/labels/marks, gui/src/components/integration-marks.ts exhaustive map: append cline. Reuse existing page and consequence/rollback dialogs. Reuse the existing gui/public/provider-icons/cline-color.svg already used by provider-icons.ts; no new brand asset.
+MODIFY all gui/src/i18n locale modules: append integrations.tab.cline, integrations.semantics.cline, api.clientConfig.clientCline. Copy states: current Cline CLI provider store; both files; stop before mutations, restart after; Undo restores both originals; default provider remains user-controlled. Parent handoff records these exact shared-file touches.
+MODIFY docs-site/src/content/docs/guides/integrations.md: documented installation contract, env precedence, --client verbs, conflict opt-in, --op restore/drift, two-file export format and running-client limitation. Update structure/runtime.md and structure GUI/CLI ownership docs where applicable with factual links to canonical integration contract.
+
+Verification: existing GUI client-list/route/i18n assertions extended for cline. No local GUI tests/build. Final hosted GUI build/lint/tests; obtain hosted screenshot artifact where available and inspect it. If unavailable report missing visual evidence rather than fabricate screenshot. No real user server configuration is used for capture.
diff --git a/devlog/_plan/260912_cline_client/030_verification.md b/devlog/_plan/260912_cline_client/030_verification.md
new file mode 100644
index 0000000000..e05266d7a3
--- /dev/null
+++ b/devlog/_plan/260912_cline_client/030_verification.md
@@ -0,0 +1,9 @@
+# Independent audit and final hosted tip
+
+Depends on surfaces. Audit all acceptance rows against final source and tests; use a fresh inherited read-only subagent (no local tests/build/install, no user data or writes). Fold correct findings into code and docs in this cycle; add further cycles if a new implementation unit is needed. Security analysis stays in .tmp/cline.
+
+MODIFY only files implicated by concrete findings. git diff --check is whitespace evidence, never test evidence. Commit scoped source and docs; git push --no-verify origin HEAD. Create ordinary PR with base dev (or actual parent branch if audited split), filling Summary/Verification/Checklist and linking #4214 without claiming unresolved acceptance. No original PR exists; no carried author identity invented.
+
+Read current head/base/reviews and CI workflow event/ref. Track final cumulative SHA through hosted Cross-platform CI; record run URL/ID, actual head and check results. Do not cancel runs or modify workflow/protection. Fix attributable final failures and repeat at the resulting head. No suite can be hidden in a receipt helper; receipt can capture permitted source checks or read-only GitHub CI verification, labeled accurately.
+
+MODIFY .tmp/cline/handoff.md immediately after each deliverable: worktree/branch, PABCD cycle/phase, PR URL/full head SHA/order, issue disposition, remaining acceptance, attribution, hosted run evidence, NOT RUN local checks and unresolved reviewer findings. Final completion requires all recorded criteria; CI pending is not success. Parent performs all merges and decides issue closure.
From 90975e9feaff450c352dafee70d24810428fb05e Mon Sep 17 00:00:00 2001
From: JUN
Date: Sat, 12 Sep 2026 13:55:39 +0900
Subject: [PATCH 2/6] feat: journal Cline provider and model configuration as a
recoverable pair
---
.../_plan/260912_cline_client/010_contract.md | 4 +
.../019_contract_result.md | 5 +
scripts/test-layout/layout.json | 2 +
src/clients/config-export.ts | 33 ++-
src/clients/config-export/cline.ts | 71 ++++++
src/clients/config-export/contracts.ts | 3 +-
src/integrations/cline-document.ts | 73 ++++++
src/integrations/cline-io.ts | 152 +++++++++++
src/integrations/config-io.ts | 23 +-
src/integrations/ownership-policy.ts | 7 +
src/integrations/registry.ts | 8 +
src/integrations/state.ts | 7 +-
src/integrations/writer.ts | 32 ++-
src/server/management/integration-routes.ts | 7 +-
structure/clients/integrations.md | 19 ++
structure/gui-and-management-api.md | 4 +
tests/clients/cline-client.test.ts | 58 +++++
tests/clients/cline-writer.test.ts | 241 ++++++++++++++++++
tests/clients/integrations-state.test.ts | 2 +-
.../client-config-export-new-clients.test.ts | 2 +-
tests/config/client-config-export.test.ts | 2 +-
tests/fixtures/test-layout-expected.json | 2 +
tests/gui/integrations-invariants.test.ts | 17 +-
23 files changed, 756 insertions(+), 18 deletions(-)
create mode 100644 devlog/_plan/260912_cline_client/019_contract_result.md
create mode 100644 src/clients/config-export/cline.ts
create mode 100644 src/integrations/cline-document.ts
create mode 100644 src/integrations/cline-io.ts
create mode 100644 tests/clients/cline-client.test.ts
create mode 100644 tests/clients/cline-writer.test.ts
diff --git a/devlog/_plan/260912_cline_client/010_contract.md b/devlog/_plan/260912_cline_client/010_contract.md
index 1195609740..d822cd1881 100644
--- a/devlog/_plan/260912_cline_client/010_contract.md
+++ b/devlog/_plan/260912_cline_client/010_contract.md
@@ -20,3 +20,7 @@ NEW tests/clients/cline-client.test.ts: source-shaped generation, env precedence
Activation evidence is final-tip hosted tests. Locally only diff/text audit; code unverified until hosted result. Pending marker is an early recovery mechanism, not cross-process exclusion against Cline: Cline does not share OpenCodex's lock. User-visible contract requires it stopped; filesystem rename cannot prevent a non-cooperating writer. No claim of simultaneous two-file visibility.
A synthesis: accepted target collision guard and regression; post-journal marker cleanup is best-effort and must not cause compensation. Normal Cline settings saves update updatedAt/model, so declare only settings.providers.opencodex.updatedAt and settings.providers.opencodex.settings.model as refreshable; preserve selected model while still in desired catalog, remove it if no longer routed. All connection/auth metadata remains protected.
+
+Contract P resumes previous D: "Docs-only roadmap complete; next contract cycle" (009). No product source changed since source inspection. Implementation uses existing JSON format plus client-specific parse/render dispatch, avoiding a new public ConfigFormat enum. Export is a human-readable {settings,catalog} document bundle; internal journal envelope stores each raw file string for exact restore. This distinction is explicitly documented and tested. Path helper implementation additionally rejects case-insensitive models.json collision. No product command is executed locally.
+
+Implementation delta: cline builder/paths, private raw pair codec, paired IO and pending journal hooks are implemented; normal model/timestamp writes use the existing narrow refreshable-path mechanism. Existing lifecycle tests now read Cline's logical pair through the production adapter instead of treating its primary file as the whole document. Source checks only so far; hosted suite remains in verification cycle. New tests cover file absence, unsafe version/nonregular member, explicit conflict overwrite, model retirement, foreign edits, write/bookkeeping failure and pending recovery.
diff --git a/devlog/_plan/260912_cline_client/019_contract_result.md b/devlog/_plan/260912_cline_client/019_contract_result.md
new file mode 100644
index 0000000000..77d93bddca
--- /dev/null
+++ b/devlog/_plan/260912_cline_client/019_contract_result.md
@@ -0,0 +1,5 @@
+# Contract implementation checkpoint
+
+The Cline builder, path resolver, raw-byte pair projection, pending recovery and existing writer/journal integration are implemented. Regression source covers normal lifecycle, native schema refusal, two-file compensation and recoverable interruption. Independent implementation review is in flight and is a required input to the final verification cycle.
+
+Local product tests, typecheck, builds and installation: NOT RUN by explicit user instruction. This checkpoint asserts code/source completion only; runtime correctness remains unverified until final cumulative hosted CI. git diff --check is the permitted text check. Next cycle wires existing CLI/dashboard surfaces and explicit catalog sync. No PR or merge yet.
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 8472806df6..b23d208200 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -378,10 +378,12 @@
"client-injection-guard.test.ts": "codex-integration",
"client-lifecycle-lock.test.ts": "clients",
"client-machine-listener.test.ts": "clients",
+ "cline-client.test.ts": "clients",
"cline-pass-deepseek-v4-tool-replay.test.ts": "providers",
"cline-pass-provider.test.ts": "providers",
"cline-pass-reasoning-efforts.test.ts": "providers",
"cline-provider.test.ts": "providers",
+ "cline-writer.test.ts": "clients",
"closed-pr-branch-cleanup.test.ts": "ci-workflows",
"codebuddy-adapter.test.ts": "providers",
"codebuddy-protocol.test.ts": "providers",
diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts
index 13d8f952e5..2aac798de4 100644
--- a/src/clients/config-export.ts
+++ b/src/clients/config-export.ts
@@ -21,7 +21,7 @@
*/
import { homedir } from "node:os";
import { existsSync, readFileSync } from "node:fs";
-import { isAbsolute, join, resolve } from "node:path";
+import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import { shouldInjectApiAuthHeader, standaloneCodexRoutingTarget } from "../codex/inject";
import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize";
import { canonicalizeReasoningEfforts } from "../reasoning-effort";
@@ -47,6 +47,7 @@ import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./conf
import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh";
import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode";
import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode";
+import { buildClineClientConfig, summarizeCline, buildClineContribution } from "./config-export/cline";
import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast";
@@ -1199,6 +1200,24 @@ function buildOmoContribution(ctx: ExportContext): ManagedContribution {
return singleFragment("omo", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
}
+/** Cline's shared SDK store; command-local --config must be mirrored by the env override. */
+export function clineConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
+ const explicit = env.CLINE_PROVIDER_SETTINGS_PATH?.trim();
+ const data = env.CLINE_DATA_DIR?.trim();
+ const root = env.CLINE_DIR?.trim();
+ const path = explicit ? absoluteClientPath(explicit, home, "CLINE_PROVIDER_SETTINGS_PATH")
+ : join(data ? absoluteClientPath(data, home, "CLINE_DATA_DIR")
+ : join(root ? absoluteClientPath(root, home, "CLINE_DIR") : join(home, ".cline"), "data"), "settings", "providers.json");
+ if (basename(path).toLowerCase() === "models.json") {
+ throw new ClientPathError("CLINE_PROVIDER_SETTINGS_PATH must differ from the sibling models.json catalog");
+ }
+ return path;
+}
+
+export function clineSettingsDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
+ return dirname(clineConfigPath(env, home));
+}
+
export const EXPORT_CLIENTS: Record = {
opencode: {
id: "opencode",
@@ -1418,6 +1437,18 @@ export const EXPORT_CLIENTS: Record = {
*/
loopbackOnly: true,
},
+ cline: {
+ id: "cline",
+ filename: "cline-config-bundle.json",
+ destination: env => clineConfigPath(env),
+ apiKeyEnv: "",
+ exportHint: "Cline CLI bundle: settings goes in providers.json, catalog in sibling models.json. Stop Cline before enabling/syncing/restoring, then restart. Select --provider opencodex; the default provider stays unchanged. Loopback only.",
+ build: buildClineClientConfig,
+ format: "json",
+ summarize: summarizeCline,
+ buildContribution: buildClineContribution,
+ loopbackOnly: true,
+ },
};
export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[];
diff --git a/src/clients/config-export/cline.ts b/src/clients/config-export/cline.ts
new file mode 100644
index 0000000000..82398de788
--- /dev/null
+++ b/src/clients/config-export/cline.ts
@@ -0,0 +1,71 @@
+import type { ExportContext, ManagedContribution } from "./contracts";
+import { LOOPBACK_API_KEY_PLACEHOLDER, OPENCODE_PROVIDER_ID } from "./constants";
+import { authoritativeContextWindow, exportModelLabel, inputModalitiesForClient, normalizeExportModels } from "./model-metadata";
+
+/** Cline CLI/shared SDK schema at cline/cline cfe9cadab996 (2026-09-12). */
+export interface ClineModel {
+ name: string;
+ contextWindow?: number;
+ modalities?: { input: string[]; output: string[] };
+ supportsVision?: boolean;
+}
+
+export function buildClineClientConfig(ctx: ExportContext) {
+ const models: Record = {};
+ for (const model of normalizeExportModels(ctx.models)) {
+ const input = inputModalitiesForClient("pi", model.inputModalities);
+ if (input === null) continue;
+ const contextWindow = authoritativeContextWindow(model.contextWindow);
+ models[model.namespaced] = {
+ name: exportModelLabel(model),
+ ...(contextWindow === undefined ? {} : { contextWindow }),
+ ...(model.inputModalities?.length ? {
+ modalities: { input, output: ["text"] },
+ supportsVision: input.includes("image"),
+ } : {}),
+ };
+ }
+ const connection = { baseUrl: ctx.baseUrl, protocol: "openai-responses", client: "openai" } as const;
+ return {
+ settings: {
+ version: 1,
+ modes: {},
+ providers: {
+ [OPENCODE_PROVIDER_ID]: {
+ settings: { provider: OPENCODE_PROVIDER_ID, ...connection, apiKey: LOOPBACK_API_KEY_PLACEHOLDER },
+ // Required by Cline's schema; a stable generation sentinel keeps export deterministic.
+ // The operation's actual time belongs to the OpenCodex journal.
+ updatedAt: "1970-01-01T00:00:00.000Z",
+ tokenSource: "manual",
+ },
+ },
+ },
+ catalog: {
+ version: 1,
+ providers: {
+ [OPENCODE_PROVIDER_ID]: {
+ provider: { name: "OpenCodex", ...connection },
+ models,
+ },
+ },
+ },
+ };
+}
+
+export type ClineGeneratedConfig = ReturnType;
+
+export function summarizeCline(document: unknown) {
+ const models = Object.values((document as ClineGeneratedConfig | undefined)?.catalog?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? {});
+ return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length };
+}
+
+export function buildClineContribution(ctx: ExportContext): ManagedContribution {
+ const document = buildClineClientConfig(ctx);
+ return {
+ clientId: "cline",
+ fragments: [
+ { path: ["settings", "providers", OPENCODE_PROVIDER_ID], value: document.settings.providers[OPENCODE_PROVIDER_ID] },
+ { path: ["catalog", "providers", OPENCODE_PROVIDER_ID], value: document.catalog.providers[OPENCODE_PROVIDER_ID] },
+ ],
+ };
+}
diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts
index aa3eab4320..5ed2626180 100644
--- a/src/clients/config-export/contracts.ts
+++ b/src/clients/config-export/contracts.ts
@@ -102,7 +102,8 @@ export type ExportClientId =
| "prime"
| "aside"
| "raycast"
- | "omo";
+ | "omo"
+ | "cline";
export interface ExportClientSpec {
id: ExportClientId;
diff --git a/src/integrations/cline-document.ts b/src/integrations/cline-document.ts
new file mode 100644
index 0000000000..06ed337429
--- /dev/null
+++ b/src/integrations/cline-document.ts
@@ -0,0 +1,73 @@
+import { PARSE_FAILED, parseConfig } from "./config-io";
+import { serializeDocument, UnserializableValueError } from "./serialize";
+
+/** Private journal representation, never written into either Cline file. */
+export interface ClineRawPair { settings: string | null; catalog: string | null }
+
+export function isClineObject(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+export function encodeClinePair(pair: ClineRawPair): string | null {
+ return pair.settings === null && pair.catalog === null ? null : JSON.stringify(pair);
+}
+
+export function decodeClinePair(text: string | null): ClineRawPair {
+ if (text === null) return { settings: null, catalog: null };
+ let value: unknown;
+ try { value = JSON.parse(text); }
+ catch { throw new UnserializableValueError("invalid Cline snapshot bundle"); }
+ if (!isClineObject(value) || Object.keys(value).length !== 2
+ || !(value.settings === null || typeof value.settings === "string")
+ || !(value.catalog === null || typeof value.catalog === "string")) {
+ throw new UnserializableValueError("invalid Cline snapshot bundle");
+ }
+ return { settings: value.settings, catalog: value.catalog };
+}
+
+function nativeDocument(value: unknown): value is Record {
+ return isClineObject(value) && (value.version === undefined || value.version === 1)
+ && (value.providers === undefined || isClineObject(value.providers));
+}
+
+export function parseClineDocument(text: string | null): unknown | typeof PARSE_FAILED {
+ try {
+ const pair = decodeClinePair(text);
+ const settings = parseConfig(pair.settings, "json");
+ const catalog = parseConfig(pair.catalog, "json");
+ if (!nativeDocument(settings) || !nativeDocument(catalog)
+ || (settings.modes !== undefined && !isClineObject(settings.modes))) return PARSE_FAILED;
+ return { settings, catalog };
+ } catch { return PARSE_FAILED; }
+}
+
+export function serializeClineDocument(value: unknown): string {
+ if (!isClineObject(value)) throw new UnserializableValueError("invalid Cline document");
+ const settings = value.settings ?? {};
+ const catalog = value.catalog ?? {};
+ if (!nativeDocument(settings) || !nativeDocument(catalog)) {
+ throw new UnserializableValueError("unsupported Cline document version or providers shape");
+ }
+ return JSON.stringify({
+ settings: serializeDocument({ version: 1, modes: {}, providers: {}, ...settings }, "json"),
+ catalog: serializeDocument({ version: 1, providers: {}, ...catalog }, "json"),
+ });
+}
+
+/** Cline saves these two fields during normal model selection. Keep only a still-routed choice. */
+export function preserveClineSelection(previous: unknown, next: unknown): void {
+ if (!isClineObject(previous) || !isClineObject(next)) return;
+ const entry = (document: Record, part: string): Record | undefined => {
+ const root = document[part];
+ const providers = isClineObject(root) ? root.providers : undefined;
+ return isClineObject(providers) && isClineObject(providers.opencodex) ? providers.opencodex : undefined;
+ };
+ const old = entry(previous, "settings");
+ const current = entry(next, "settings");
+ const models = entry(next, "catalog")?.models;
+ if (!current || !isClineObject(current.settings) || !old || !isClineObject(old.settings)) return;
+ if (typeof old.settings.model === "string" && isClineObject(models) && Object.hasOwn(models, old.settings.model)) {
+ current.settings.model = old.settings.model;
+ }
+ if (typeof old.updatedAt === "string") current.updatedAt = old.updatedAt;
+}
diff --git a/src/integrations/cline-io.ts b/src/integrations/cline-io.ts
new file mode 100644
index 0000000000..98f761a77b
--- /dev/null
+++ b/src/integrations/cline-io.ts
@@ -0,0 +1,152 @@
+import { dirname, join, resolve } from "node:path";
+import { type IntegrationIO, type IntegrationTransaction, type ReadResult, type StatKind } from "./config-io";
+import type { IntegrationStateStore } from "./store";
+import { fingerprint } from "./ownership";
+import { decodeClinePair, encodeClinePair, isClineObject, type ClineRawPair } from "./cline-document";
+
+/** Per-file atomic replacement with durable pair recovery, not simultaneous two-file visibility. */
+export class ClineTransactionError extends Error {
+ readonly residual = true;
+ constructor(readonly snapshotPath: string) {
+ super(`Cline has an unfinished two-file operation. Stop Cline and retry the mutation. If either file was edited, recover from ${snapshotPath} before retrying.`);
+ }
+}
+
+function sameRecord(a: unknown, b: unknown): boolean {
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
+}
+
+export function clinePendingPath(store: IntegrationStateStore, configPath: string): string {
+ return join(store.root, `cline-pending-${fingerprint(resolve(configPath))}.json`);
+}
+
+/** Snapshot/ownership remain in the existing store. Only the filesystem projection changes. */
+export function createClineIO(
+ base: IntegrationIO,
+ configPath: string,
+ store: IntegrationStateStore,
+ recover = false,
+): IntegrationIO {
+ const catalogPath = join(dirname(configPath), "models.json");
+ const markerPath = clinePendingPath(store, configPath);
+ const targets = { settings: configPath, catalog: catalogPath };
+ const keys = ["settings", "catalog"] as const;
+ const kind = (path: string) => (base.lstatKind ?? base.statKind)(path);
+ const readPair = (): ClineRawPair => {
+ const pair: ClineRawPair = { settings: null, catalog: null };
+ for (const key of keys) {
+ const path = targets[key];
+ const state = kind(path);
+ if (state === "missing") continue;
+ if (state !== "file") throw new ClineTransactionError(markerPath);
+ const read = base.readText(path);
+ if (read.kind !== "text") throw new ClineTransactionError(markerPath);
+ pair[key] = read.text;
+ }
+ return pair;
+ };
+ const writeMember = (key: typeof keys[number], text: string | null): void => {
+ if (text === null) base.removeFile(targets[key]);
+ else { base.mkdirp(dirname(targets[key])); base.writeText(targets[key], text); }
+ };
+ const writePair = (pair: ClineRawPair): void => {
+ for (const key of keys) writeMember(key, pair[key]);
+ };
+ const readPending = (): IntegrationTransaction | null => {
+ if (kind(markerPath) === "missing") return null;
+ if (kind(markerPath) !== "file") throw new ClineTransactionError(markerPath);
+ const read = base.readText(markerPath);
+ if (read.kind !== "text") throw new ClineTransactionError(markerPath);
+ try {
+ const value: unknown = JSON.parse(read.text);
+ if (!isClineObject(value) || !isClineObject(value.entry)
+ || value.entry.clientId !== "cline" || value.entry.configPath !== configPath
+ || typeof value.entry.opId !== "string"
+ || !(typeof value.before === "string" || value.before === null)
+ || !(typeof value.nextText === "string" || value.nextText === null)
+ || !Object.hasOwn(value, "record") || !Object.hasOwn(value, "priorRecord")) throw new Error();
+ for (const record of [value.record, value.priorRecord]) {
+ if (record !== null && (!isClineObject(record) || record.clientId !== "cline" || record.configPath !== configPath)) throw new Error();
+ }
+ decodeClinePair(value.before);
+ decodeClinePair(value.nextText);
+ return value as unknown as IntegrationTransaction;
+ } catch { throw new ClineTransactionError(markerPath); }
+ };
+ const committed = (pending: IntegrationTransaction, current: ClineRawPair): boolean => {
+ const operation = store.findOperation(pending.entry.opId);
+ return operation !== null && operation.clientId === "cline" && operation.configPath === configPath
+ && operation.resultFingerprint === pending.entry.resultFingerprint
+ && encodeClinePair(current) === pending.nextText
+ && sameRecord(store.readRecords().cline, pending.record);
+ };
+ const cleanup = (): void => {
+ // The append is the commit boundary. Cleanup failure never rolls back a committed pair.
+ try { base.removeFile(markerPath); } catch { /* The next mutation recognizes the committed record. */ }
+ };
+ const pendingSafe = (): boolean => {
+ const pending = readPending();
+ return pending === null || committed(pending, readPair());
+ };
+
+ if (resolve(configPath).toLowerCase() === resolve(catalogPath).toLowerCase()) throw new ClineTransactionError(markerPath);
+ if (recover) {
+ const pending = readPending();
+ if (pending) {
+ const current = readPair();
+ if (committed(pending, current)) cleanup();
+ else {
+ // A journaled but mixed/inconsistent result is not an interrupted uncommitted write.
+ if (store.findOperation(pending.entry.opId)) throw new ClineTransactionError(markerPath);
+ const before = decodeClinePair(pending.before);
+ const after = decodeClinePair(pending.nextText);
+ if (!keys.every(key => current[key] === before[key] || current[key] === after[key])) throw new ClineTransactionError(markerPath);
+ const record = store.readRecords().cline ?? null;
+ if (!sameRecord(record, pending.priorRecord) && !sameRecord(record, pending.record)) throw new ClineTransactionError(markerPath);
+ try {
+ writePair(before);
+ if (pending.priorRecord) base.putRecord(pending.priorRecord);
+ else base.dropRecord("cline");
+ base.removeFile(markerPath);
+ } catch { throw new ClineTransactionError(markerPath); }
+ }
+ }
+ }
+
+ return {
+ ...base,
+ statKind: (path): StatKind => {
+ if (path !== configPath) return base.statKind(path);
+ try {
+ if (!pendingSafe()) return "failed";
+ const pair = readPair();
+ return pair.settings === null && pair.catalog === null ? "missing" : "file";
+ } catch { return "failed"; }
+ },
+ readText: (path): ReadResult => {
+ if (path !== configPath) return base.readText(path);
+ try {
+ if (!pendingSafe()) return { kind: "failed", code: "CLINE_PENDING" };
+ const text = encodeClinePair(readPair());
+ return text === null ? { kind: "missing" } : { kind: "text", text };
+ } catch { return { kind: "failed", code: "CLINE_UNSAFE" }; }
+ },
+ beginTransaction: transaction => {
+ if (transaction.entry.clientId !== "cline" || transaction.entry.configPath !== configPath) throw new ClineTransactionError(markerPath);
+ if (readPending() !== null) throw new ClineTransactionError(markerPath);
+ if (encodeClinePair(readPair()) !== transaction.before) throw new ClineTransactionError(markerPath);
+ base.mkdirp(store.root);
+ const { entry, before, nextText, record, priorRecord } = transaction;
+ base.writeText(markerPath, JSON.stringify({ entry, before, nextText, record, priorRecord }));
+ },
+ writeText: (path, text) => {
+ if (path !== configPath) return base.writeText(path, text);
+ writePair(decodeClinePair(text));
+ },
+ removeFile: path => {
+ if (path !== configPath) return base.removeFile(path);
+ writePair({ settings: null, catalog: null });
+ },
+ finishTransaction: cleanup,
+ };
+}
diff --git a/src/integrations/config-io.ts b/src/integrations/config-io.ts
index dd61fcf351..8a1e1480ec 100644
--- a/src/integrations/config-io.ts
+++ b/src/integrations/config-io.ts
@@ -7,7 +7,7 @@
*
* Design of record: devlog/_fin/260802_client_toggle_api/021 §5-6.
*/
-import { mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
+import { lstatSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
import type { ConfigFormat } from "../clients/config-export";
import { MAX_JSON_NESTING } from "./serialize";
import { atomicWriteFile } from "../config";
@@ -200,6 +200,8 @@ export interface IntegrationIO {
readText: (path: string) => ReadResult;
/** `failed` is distinct from `missing` for the same reason. */
statKind: (path: string) => StatKind;
+ /** Optional no-follow probe for paired-file clients. */
+ lstatKind?: (path: string) => StatKind;
writeText: (path: string, text: string) => void;
removeFile: (path: string) => void;
mkdirp: (path: string) => void;
@@ -208,6 +210,16 @@ export interface IntegrationIO {
appendJournal: (entry: JournalEntry) => void;
putRecord: (record: OwnershipRecord) => void;
dropRecord: (clientId: IntegrationClientId) => void;
+ beginTransaction?: (transaction: IntegrationTransaction) => void;
+ finishTransaction?: () => void;
+}
+
+export interface IntegrationTransaction {
+ entry: JournalEntry;
+ before: string | null;
+ nextText: string | null;
+ record: OwnershipRecord | null;
+ priorRecord: OwnershipRecord | null;
}
export type TargetState =
@@ -240,6 +252,14 @@ export function loadTarget(io: IntegrationIO, configPath: string): TargetState {
*/
export function fileIO(): Omit {
return {
+ lstatKind: path => {
+ try {
+ const stats = lstatSync(path);
+ return stats.isFile() ? "file" : stats.isDirectory() ? "dir" : "other";
+ } catch (error) {
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "failed";
+ }
+ },
readText: path => {
try {
return { kind: "text", text: readFileSync(path, "utf8") };
@@ -334,4 +354,3 @@ export function assertIntegrationWriteOwnership(
+ "of sharing the mount.",
);
}
-
diff --git a/src/integrations/ownership-policy.ts b/src/integrations/ownership-policy.ts
index 508910955f..a0cf52bf61 100644
--- a/src/integrations/ownership-policy.ts
+++ b/src/integrations/ownership-policy.ts
@@ -63,6 +63,10 @@ function cloneFragment(fragment: ManagedFragment): ManagedFragment {
export function refreshablePathsOf(
contribution: ManagedContribution,
): readonly (readonly string[])[] {
+ if (contribution.clientId === "cline") return [
+ ["settings", "providers", OPENCODE_PROVIDER_ID, "updatedAt"],
+ ["settings", "providers", OPENCODE_PROVIDER_ID, "settings", "model"],
+ ];
if (contribution.clientId !== "zcode") return [];
const fragment = contribution.fragments.find(candidate => (
candidate.path.length === 2
@@ -90,6 +94,9 @@ export function validRefreshablePaths(
contribution: ManagedContribution,
value: unknown,
): value is readonly (readonly string[])[] {
+ if (contribution.clientId === "cline") {
+ return JSON.stringify(value) === JSON.stringify(refreshablePathsOf(contribution));
+ }
if (contribution.clientId !== "zcode" || !Array.isArray(value) || value.length === 0) {
return false;
}
diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts
index 493a347819..4181316207 100644
--- a/src/integrations/registry.ts
+++ b/src/integrations/registry.ts
@@ -12,6 +12,8 @@ import { homedir } from "node:os";
import { join } from "node:path";
import {
ClientPathError,
+ clineConfigPath,
+ clineSettingsDir,
EXPORT_CLIENTS,
asideAccountDir,
asideConfigPath,
@@ -299,6 +301,12 @@ export const INTEGRATION_CLIENTS: Record omoAgentDir(env, home),
},
+ cline: {
+ id: "cline",
+ configPath: (env = process.env, home = homedir()) => clineConfigPath(env, home),
+ detectDir: (env = process.env, home = homedir()) => clineSettingsDir(env, home),
+ writerLock: { suffix: ".lock" },
+ },
};
export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
diff --git a/src/integrations/state.ts b/src/integrations/state.ts
index 0249987027..c1c9096140 100644
--- a/src/integrations/state.ts
+++ b/src/integrations/state.ts
@@ -8,6 +8,8 @@
*
* Design of record: devlog/_fin/260802_client_toggle_api/021 §3.
*/
+import { createClineIO } from "./cline-io";
+import { parseClineDocument } from "./cline-document";
import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel, type ManagedContribution } from "../clients/config-export";
import type { OcxConfig } from "../types";
import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
@@ -487,7 +489,7 @@ function retentionOf(
export function readIntegrationState(input: IntegrationStateInput): IntegrationStatus {
const store = input.store ?? createIntegrationStateStore();
retryPendingPrunesOnce(store);
- const io = input.io ?? store.io();
+ let io = input.io ?? store.io();
const spec = INTEGRATION_CLIENTS[input.clientId];
const exportSpec = EXPORT_CLIENTS[input.clientId];
const retention = retentionOf(input.clientId, store);
@@ -529,6 +531,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
};
}
+ if (input.clientId === "cline") io = createClineIO(io, configPath, store);
const target = loadTarget(io, configPath);
if (!target.ok) {
return {
@@ -541,7 +544,7 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
};
}
- const parsed = parseConfig(target.before, exportSpec.format);
+ const parsed = input.clientId === "cline" ? parseClineDocument(target.before) : parseConfig(target.before, exportSpec.format);
const contribution = exportSpec.buildContribution(exportContextOf(input));
const record = store.readRecords()[input.clientId] ?? null;
const { state, reason } = classifyIntegration({
diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts
index 2dcc60bda5..0bac4da13a 100644
--- a/src/integrations/writer.ts
+++ b/src/integrations/writer.ts
@@ -10,6 +10,8 @@
* Design of record: devlog/_fin/260802_client_toggle_api/030 and 031.
*/
import { homedir } from "node:os";
+import { createClineIO, ClineTransactionError } from "./cline-io";
+import { parseClineDocument, serializeClineDocument, preserveClineSelection } from "./cline-document";
import { dirname } from "node:path";
import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
import { shouldInjectApiAuthHeader } from "../codex/inject";
@@ -148,13 +150,19 @@ interface CommitArgs {
*/
function commit(args: CommitArgs): WriteOutcome {
const { io, clientId, configPath } = args;
+ let transactionStarted = false;
try {
+ if (io.beginTransaction) {
+ io.beginTransaction(args);
+ transactionStarted = true;
+ }
if (args.nextText === null) io.removeFile(configPath);
else {
io.mkdirp(dirname(configPath));
io.writeText(configPath, args.nextText);
}
} catch (error) {
+ if (transactionStarted) return compensate(args, error, "could not write both Cline files");
return refuse(clientId, "write_failed", args.state, messageOf(error), args.snapshotPath);
}
try {
@@ -168,6 +176,7 @@ function commit(args: CommitArgs): WriteOutcome {
} catch (error) {
return compensate(args, error, "could not append the journal row");
}
+ io.finishTransaction?.();
return {
ok: true,
changed: true,
@@ -198,6 +207,7 @@ function compensate(args: CommitArgs, cause: unknown, what: string): WriteRefuse
message: `${what}, and the change could not be rolled back. The file or its ownership record is in an intermediate state; the backup is at ${args.snapshotPath ?? "(none)"}.`,
};
}
+ io.finishTransaction?.();
return refuse(clientId, "write_failed", args.state, `${what}; the change was rolled back. Cause: ${messageOf(cause)}`, args.snapshotPath);
}
@@ -220,7 +230,7 @@ function sourcePreservingFragmentValue(
/** Shared preflight: detect, gate, read, parse and classify. */
function preflight(input: IntegrationWriteInput) {
const store = input.store ?? createIntegrationStateStore();
- const io = input.io ?? defaultIntegrationIO(store);
+ let io = input.io ?? defaultIntegrationIO(store);
const clientId = input.clientId;
const spec = INTEGRATION_CLIENTS[clientId];
const exportSpec = EXPORT_CLIENTS[clientId];
@@ -246,7 +256,11 @@ function preflight(input: IntegrationWriteInput) {
const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
configPath = resolved.configPath;
detectDir = resolved.detectDir;
+ if (clientId === "cline") io = createClineIO(io, configPath, store, true);
} catch (error) {
+ if (error instanceof ClineTransactionError) {
+ return { failed: { ...refuse(clientId, "unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const;
+ }
if (!(error instanceof ClientPathError)) throw error;
return { failed: refuse(clientId, "unsafe", "unsafe", error.message) } as const;
}
@@ -262,7 +276,7 @@ function preflight(input: IntegrationWriteInput) {
} as const;
}
const before = target.before;
- const parsed = parseConfig(before, exportSpec.format);
+ const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, exportSpec.format);
if (parsed === PARSE_FAILED) {
return { failed: refuse(clientId, "unsafe", "unsafe",
`${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const;
@@ -409,6 +423,7 @@ function applyOrRefreshIntegration(
// every container exists and "did we create this?" is unanswerable.
created = createdContainerPaths(base, contribution);
const nextDocument = mergeContribution(base, contribution);
+ if (clientId === "cline") preserveClineSelection(parsed, nextDocument);
if (spec.sourcePreservingYaml && before !== null) {
const value = sourcePreservingFragmentValue(contribution, spec.sourcePreservingYaml.path);
const patched = value === undefined
@@ -425,7 +440,7 @@ function applyOrRefreshIntegration(
}
text = patched;
} else {
- text = serializeDocument(nextDocument, exportSpec.format);
+ text = clientId === "cline" ? serializeClineDocument(nextDocument) : serializeDocument(nextDocument, exportSpec.format);
}
} catch (error) {
if (error instanceof AmbiguousSelectorError) {
@@ -585,7 +600,7 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
}
text = patched;
} else {
- text = serializeDocument(doc, exportSpec.format);
+ text = clientId === "cline" ? serializeClineDocument(doc) : serializeDocument(doc, exportSpec.format);
}
} catch (error) {
if (!(error instanceof UnserializableValueError)) throw error;
@@ -615,7 +630,7 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome {
const store = input.store ?? createIntegrationStateStore();
- const io = input.io ?? defaultIntegrationIO(store);
+ let io = input.io ?? defaultIntegrationIO(store);
const entry = store.findOperation(input.opId);
if (!entry) throw new Error(`unknown operation ${input.opId}`);
if (entry.clientId !== input.clientId) throw new Error("restore input names a different client than the operation");
@@ -631,6 +646,13 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
return refuse(clientId, "conflict", "conflict",
`that operation was recorded for ${configPath}, but this client now resolves to ${resolvedPath}`);
}
+ if (clientId === "cline") {
+ try { io = createClineIO(io, configPath, store, true); }
+ catch (error) {
+ if (!(error instanceof ClineTransactionError)) throw error;
+ return { ...refuse(clientId, "unsafe", "unsafe", error.message, error.snapshotPath), residual: true };
+ }
+ }
const snapshot = store.readSnapshot(entry);
if (snapshot.kind === "expired") {
return refuse(clientId, "snapshot_expired", "absent", "that backup has expired");
diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts
index 43c0e6a98c..2363e745d5 100644
--- a/src/server/management/integration-routes.ts
+++ b/src/server/management/integration-routes.ts
@@ -16,6 +16,7 @@ import {
asideJournalDeleteResponse, type AsideProfileRouteOptions,
} from "./aside-profile-routes";
import type { IntegrationIO } from "../../integrations/config-io";
+import { createClineIO } from "../../integrations/cline-io";
import { matchesOperationResult } from "../../integrations/journal";
import {
INTEGRATION_CLIENT_IDS,
@@ -534,7 +535,11 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise {
if (snapshot === "expired") return false;
if (newestByClient.get(operation.clientId) !== operation.opId) return false;
- const current = currentConfigText(operation.configPath);
+ const current = operation.clientId === "cline" ? (() => {
+ const io = createClineIO(integrationMutationTestHooks?.io ?? store.io(), operation.configPath, store);
+ const read = io.readText(operation.configPath);
+ return read.kind === "text" ? read.text : read.kind === "missing" ? null : undefined;
+ })() : currentConfigText(operation.configPath);
return current === undefined ? false : matchesOperationResult(operation, current);
})(),
/*
diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md
index ae71389ad7..ba438344dd 100644
--- a/structure/clients/integrations.md
+++ b/structure/clients/integrations.md
@@ -168,3 +168,22 @@ pin one legacy root owner before changing it. Sibling stores remain independent.
precede coordinated writes under one scoped flight, and actual file state/refusals remain
separate. Restore reconciles target intent from validated snapshot ownership without changing
sibling policy. Profile journal views retain source-store provenance for older legacy entries.
+
+## Cline paired files
+
+Cline CLI uses `providers.json` for connection settings and sibling `models.json` for its
+catalog. `src/integrations/cline-document.ts` separates native documents from raw-byte snapshot
+bundles; `src/integrations/cline-io.ts` projects both files onto the existing writer/journal.
+Only each file's `providers.opencodex` entry is owned. Schema envelopes and the user's default
+provider remain user-owned. Cline's selected model and update timestamp can change during normal
+use; refresh preserves a selection only while its model remains routed. Connection fields and
+catalog membership remain protected.
+
+Each file replacement is atomic; the pair is recoverable, not simultaneously visible. Stop Cline
+before explicit enable/sync/disable/restore and restart afterward. Unattended refresh excludes
+Cline. A private pending record precedes replacement and survives partial failure. Read-only
+status reports an unfinished pair as unsafe; explicit mutation recovers only unchanged original
+or intended bytes and compatible ownership. A journaled pair must match both intended files and
+final ownership before pending cleanup. Foreign edits retain the pending evidence and refuse.
+Undo restores both original byte strings, including individual file absence; drift requires the
+existing explicit confirmation. The journal endpoint evaluates Undo against the same pair.
diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md
index a3c61e1f7d..27cc586f24 100644
--- a/structure/gui-and-management-api.md
+++ b/structure/gui-and-management-api.md
@@ -537,3 +537,7 @@ advances the observation clock, so a retained older row cannot defer evaluation
Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch)
privately to final dispatch; preliminary route selection does not inject Go-only headers.
+
+Cline journal Undo eligibility reads both native configuration files through the paired
+integration IO adapter. Its snapshot fingerprint cannot be checked against providers.json alone;
+[the integration contract](clients/integrations.md#cline-paired-files) defines recovery.
diff --git a/tests/clients/cline-client.test.ts b/tests/clients/cline-client.test.ts
new file mode 100644
index 0000000000..cb3fc64e4a
--- /dev/null
+++ b/tests/clients/cline-client.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test } from "bun:test";
+import { join } from "node:path";
+import { buildClientConfig, buildClientConfigText, buildClientContribution, clineConfigPath, EXPORT_CLIENTS, type ExportContext } from "../../src/clients/config-export";
+import type { ClineGeneratedConfig } from "../../src/clients/config-export/cline";
+import { INTEGRATION_CLIENTS } from "../../src/integrations/registry";
+import { decodeClinePair, encodeClinePair, parseClineDocument, serializeClineDocument } from "../../src/integrations/cline-document";
+import { PARSE_FAILED } from "../../src/integrations/config-io";
+
+const context: ExportContext = {
+ baseUrl: "http://127.0.0.1:10100/v1",
+ models: [
+ { namespaced: "mock/vision", provider: "mock", id: "vision", contextWindow: 200000, inputModalities: ["text", "image"] },
+ { namespaced: "mock/unknown", provider: "mock", id: "unknown" },
+ { namespaced: "mock/audio", provider: "mock", id: "audio", inputModalities: ["audio"] },
+ ],
+};
+
+describe("Cline shared SDK store contract", () => {
+ test("emits separate native documents with canonical selectors and no invented limits", () => {
+ // Oracle: cline/cline cfe9cadab996, core/types/provider-settings.ts and local-provider-registry.ts.
+ const doc = buildClientConfig("cline", context) as ClineGeneratedConfig;
+ expect(doc.settings.version).toBe(1);
+ expect(doc.settings.providers.opencodex!.settings).toEqual({ provider: "opencodex", protocol: "openai-responses", client: "openai", apiKey: "opencodex-loopback", baseUrl: context.baseUrl });
+ expect(doc.settings).not.toHaveProperty("lastUsedProvider");
+ expect(doc.catalog.providers.opencodex!.models).toEqual({
+ "mock/vision": { name: "vision (mock)", contextWindow: 200000, modalities: { input: ["text", "image"], output: ["text"] }, supportsVision: true },
+ "mock/unknown": { name: "unknown (mock)" },
+ });
+ expect(buildClientContribution("cline", context).fragments.map(f => f.path)).toEqual([
+ ["settings", "providers", "opencodex"], ["catalog", "providers", "opencodex"],
+ ]);
+ expect(EXPORT_CLIENTS.cline.summarize(doc)).toEqual({ modelCount: 2, modelsWithoutLimits: 1 });
+ expect(JSON.parse(buildClientConfigText("cline", context).text)).toEqual(doc);
+ });
+
+ test("honors the upstream path override order and refuses colliding/relative targets", () => {
+ const home = join(process.cwd(), "synthetic-home");
+ expect(clineConfigPath({}, home)).toBe(join(home, ".cline", "data", "settings", "providers.json"));
+ expect(clineConfigPath({ CLINE_DIR: "~/root" }, home)).toBe(join(home, "root", "data", "settings", "providers.json"));
+ expect(clineConfigPath({ CLINE_DIR: "~/root", CLINE_DATA_DIR: "~/data" }, home)).toBe(join(home, "data", "settings", "providers.json"));
+ const env = { CLINE_PROVIDER_SETTINGS_PATH: "~/custom/provider.json", CLINE_DATA_DIR: "~/data", CLINE_DIR: "~/root" };
+ expect(clineConfigPath(env, home)).toBe(join(home, "custom", "provider.json"));
+ expect(INTEGRATION_CLIENTS.cline.detectDir(env, home)).toBe(join(home, "custom"));
+ expect(() => clineConfigPath({ CLINE_DIR: "relative" }, home)).toThrow("CLINE_DIR");
+ expect(() => clineConfigPath({ CLINE_PROVIDER_SETTINGS_PATH: "~/MODELS.JSON" }, home)).toThrow("must differ");
+ });
+
+ test("raw snapshots preserve whitespace and absence, while unsafe native data refuses", () => {
+ const pair = { settings: '{ "version": 1, "providers": {} }\n', catalog: null };
+ expect(decodeClinePair(encodeClinePair(pair))).toEqual(pair);
+ expect(decodeClinePair(null)).toEqual({ settings: null, catalog: null });
+ for (const settings of ['{"version":2}', '{"providers":[]}', '{"x":1,"x":2}', 'null', '{"x":1e999}']) {
+ expect(parseClineDocument(encodeClinePair({ settings, catalog: null }))).toBe(PARSE_FAILED);
+ }
+ const doc = buildClientConfig("cline", context);
+ expect(parseClineDocument(serializeClineDocument(doc))).toEqual(doc);
+ });
+});
diff --git a/tests/clients/cline-writer.test.ts b/tests/clients/cline-writer.test.ts
new file mode 100644
index 0000000000..54eaba6ae1
--- /dev/null
+++ b/tests/clients/cline-writer.test.ts
@@ -0,0 +1,241 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { clineConfigPath } from "../../src/clients/config-export";
+import { createClineIO, clinePendingPath } from "../../src/integrations/cline-io";
+import { decodeClinePair } from "../../src/integrations/cline-document";
+import { applyIntegration, disableIntegration, overwriteIntegration, refreshIntegration, restoreIntegration, type IntegrationWriteInput } from "../../src/integrations/writer";
+import { createIntegrationStateStore } from "../../src/integrations/store";
+import { readIntegrationState } from "../../src/integrations/state";
+import { removeTreeWithRetry } from "../helpers/remove-tree";
+import type { IntegrationTransaction } from "../../src/integrations/config-io";
+import type { OcxConfig } from "../../src/types";
+
+let root: string;
+let input: IntegrationWriteInput;
+let settings: string;
+let catalog: string;
+const originalSettings = '{ "version": 1, "lastUsedProvider": "mine", "modes": {}, "providers": {"mine":{"settings":{"provider":"mine"},"updatedAt":"2026-01-01T00:00:00.000Z","tokenSource":"manual"}} }\n';
+const originalCatalog = '{\n "version":1, "providers":{"mine":{"models":{"keep":{"name":"Keep"}}}}\n}\n';
+const models = [{ namespaced: "mock/a", provider: "mock", id: "a", contextWindow: 120000 }, { namespaced: "mock/b", provider: "mock", id: "b" }];
+
+beforeEach(() => {
+ root = mkdtempSync(join(tmpdir(), "ocx-cline-"));
+ const home = join(root, "home");
+ settings = clineConfigPath({}, home);
+ catalog = join(dirname(settings), "models.json");
+ mkdirSync(dirname(settings), { recursive: true });
+ input = {
+ clientId: "cline", home, env: {}, models, port: 10100,
+ config: { hostname: "127.0.0.1", port: 10100, defaultProvider: "mock", providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } } } as OcxConfig,
+ store: createIntegrationStateStore(join(root, "store")),
+ };
+});
+afterEach(() => removeTreeWithRetry(root));
+
+function seed() { writeFileSync(settings, originalSettings); writeFileSync(catalog, originalCatalog); }
+function settingsDoc() { return JSON.parse(readFileSync(settings, "utf8")); }
+function catalogDoc() { return JSON.parse(readFileSync(catalog, "utf8")); }
+
+describe("Cline journaled pair", () => {
+ test("preserves foreign providers/default, journals both originals, and restores exact bytes", () => {
+ seed();
+ const applied = applyIntegration(input);
+ expect(applied.ok).toBe(true);
+ expect(settingsDoc().lastUsedProvider).toBe("mine");
+ expect(catalogDoc().providers.mine.models.keep.name).toBe("Keep");
+ expect(Object.keys(catalogDoc().providers.opencodex.models)).toEqual(["mock/a", "mock/b"]);
+ expect(readIntegrationState(input).state).toBe("current");
+ const op = input.store!.listOperations("cline")[0]!;
+ const snapshot = input.store!.readSnapshot(op);
+ expect(snapshot.kind).toBe("stored");
+ if (snapshot.kind === "stored") expect(decodeClinePair(snapshot.text)).toEqual({ settings: originalSettings, catalog: originalCatalog });
+ expect(restoreIntegration({ ...input, opId: op.opId }).ok).toBe(true);
+ expect(readFileSync(settings, "utf8")).toBe(originalSettings);
+ expect(readFileSync(catalog, "utf8")).toBe(originalCatalog);
+ });
+
+ test("restore preserves initially absent members, including both absent", () => {
+ for (const partial of [false, true]) {
+ if (partial) writeFileSync(settings, originalSettings);
+ const applied = applyIntegration(input);
+ expect(applied.ok).toBe(true);
+ if (!applied.ok || !applied.opId) throw new Error("missing operation");
+ expect(restoreIntegration({ ...input, opId: applied.opId }).ok).toBe(true);
+ expect(existsSync(settings)).toBe(partial);
+ expect(existsSync(catalog)).toBe(false);
+ }
+ });
+
+ test("refresh removes retired models and preserves only still-routed user selection", () => {
+ seed();
+ expect(applyIntegration(input).ok).toBe(true);
+ const edited = settingsDoc();
+ edited.providers.opencodex.settings.model = "mock/b";
+ edited.providers.opencodex.updatedAt = "2026-09-12T00:00:00.000Z";
+ writeFileSync(settings, JSON.stringify(edited));
+ expect(refreshIntegration({ ...input, port: 12100 }).ok).toBe(true);
+ expect(settingsDoc().providers.opencodex.settings.model).toBe("mock/b");
+ expect(settingsDoc().providers.opencodex.settings.baseUrl).toContain(":12100/");
+ expect(refreshIntegration({ ...input, models: [models[0]!] }).ok).toBe(true);
+ expect(Object.keys(catalogDoc().providers.opencodex.models)).toEqual(["mock/a"]);
+ expect(settingsDoc().providers.opencodex.settings.model).toBeUndefined();
+ expect(disableIntegration(input).ok).toBe(true);
+ expect(settingsDoc().providers.opencodex).toBeUndefined();
+ expect(catalogDoc().providers.opencodex).toBeUndefined();
+ expect(settingsDoc().version).toBe(1);
+ expect(settingsDoc().lastUsedProvider).toBe("mine");
+ });
+
+ test("protected edits refuse refresh and drifted pair requires explicit restore confirmation", () => {
+ seed();
+ const applied = applyIntegration(input);
+ if (!applied.ok || !applied.opId) throw new Error("apply failed");
+ const edited = settingsDoc();
+ edited.providers.opencodex.settings.baseUrl = "http://127.0.0.1:9999/v1";
+ writeFileSync(settings, JSON.stringify(edited));
+ expect(readIntegrationState(input).state).toBe("conflict");
+ expect(refreshIntegration(input).ok).toBe(false);
+ const refused = restoreIntegration({ ...input, opId: applied.opId });
+ expect(refused.ok).toBe(false);
+ if (!refused.ok) expect(refused.reason).toBe("drift_requires_confirm");
+ expect(restoreIntegration({ ...input, opId: applied.opId, confirmDrift: true }).ok).toBe(true);
+ expect(readFileSync(catalog, "utf8")).toBe(originalCatalog);
+ });
+
+ test("unsafe second file and remote admission refuse without replacing the first file", () => {
+ seed();
+ writeFileSync(catalog, '{"version":2}');
+ expect(applyIntegration(input).ok).toBe(false);
+ expect(readFileSync(settings, "utf8")).toBe(originalSettings);
+ seed();
+ expect(applyIntegration({ ...input, config: { ...input.config, hostname: "0.0.0.0" } }).ok).toBe(false);
+ expect(readFileSync(settings, "utf8")).toBe(originalSettings);
+ expect(input.store!.listOperations()).toHaveLength(0);
+ });
+
+ test("missing install and no-follow member refusals leave both files alone", () => {
+ const absentHome = join(root, "never-installed");
+ const absent = applyIntegration({ ...input, home: absentHome });
+ expect(absent.ok).toBe(false);
+ if (!absent.ok) expect(absent.reason).toBe("not_installed");
+ seed();
+ const io = input.store!.io();
+ const refused = applyIntegration({ ...input, io: { ...io, lstatKind: path => path === catalog ? "other" : io.statKind(path) } });
+ expect(refused.ok).toBe(false);
+ expect(readFileSync(settings, "utf8")).toBe(originalSettings);
+ expect(readFileSync(catalog, "utf8")).toBe(originalCatalog);
+ });
+
+ test("occupied custom provider requires explicit overwrite and remains reversible", () => {
+ seed();
+ const doc = settingsDoc();
+ doc.providers.opencodex = { settings: { provider: "opencodex", baseUrl: "http://127.0.0.1:9999/v1" }, updatedAt: "2026-01-01T00:00:00.000Z", tokenSource: "manual" };
+ const before = JSON.stringify(doc);
+ writeFileSync(settings, before);
+ expect(applyIntegration(input).ok).toBe(false);
+ const applied = overwriteIntegration(input);
+ expect(applied.ok).toBe(true);
+ if (!applied.ok || !applied.opId) throw new Error("overwrite failed");
+ expect(restoreIntegration({ ...input, opId: applied.opId }).ok).toBe(true);
+ expect(readFileSync(settings, "utf8")).toBe(before);
+ });
+
+ test("second-file and journal failures compensate both files and ownership", () => {
+ for (const point of ["catalog", "journal"] as const) {
+ seed();
+ const io = input.store!.io();
+ let failed = false;
+ const result = applyIntegration({ ...input, io: {
+ ...io,
+ writeText: (path, text) => {
+ if (point === "catalog" && path === catalog && !failed) { failed = true; throw new Error("injected"); }
+ io.writeText(path, text);
+ },
+ appendJournal: entry => {
+ if (point === "journal") throw new Error("injected");
+ io.appendJournal(entry);
+ },
+ } });
+ expect(result.ok).toBe(false);
+ expect(readFileSync(settings, "utf8")).toBe(originalSettings);
+ expect(readFileSync(catalog, "utf8")).toBe(originalCatalog);
+ expect(input.store!.readRecords().cline).toBeUndefined();
+ expect(existsSync(clinePendingPath(input.store!, settings))).toBe(false);
+ }
+ });
+
+ test("pending pair is read-only unsafe; an explicit mutation recovers interrupted files", () => {
+ seed();
+ const io = input.store!.io();
+ let failWrites = false;
+ const result = applyIntegration({ ...input, io: { ...io, writeText: (path, text) => {
+ if (path === catalog) failWrites = true;
+ if (failWrites && (path === settings || path === catalog)) throw new Error("disk offline");
+ io.writeText(path, text);
+ } } });
+ expect(result.ok).toBe(false);
+ if (!result.ok) expect(result.residual).toBe(true);
+ const intermediate = readFileSync(settings, "utf8");
+ expect(readIntegrationState(input).state).toBe("unsafe");
+ expect(readFileSync(settings, "utf8")).toBe(intermediate);
+ expect(applyIntegration(input).ok).toBe(true);
+ expect(readIntegrationState(input).state).toBe("current");
+ });
+
+ test("journaled mixed pair cannot be blessed by marker cleanup", () => {
+ seed();
+ const io = input.store!.io();
+ const marker = clinePendingPath(input.store!, settings);
+ expect(applyIntegration({ ...input, io: { ...io, removeFile: path => {
+ if (path === marker) throw new Error("cleanup unavailable");
+ io.removeFile(path);
+ } } }).ok).toBe(true);
+ const pending = JSON.parse(readFileSync(marker, "utf8")) as IntegrationTransaction;
+ writeFileSync(settings, decodeClinePair(pending.before).settings!);
+ expect(readIntegrationState(input).state).toBe("unsafe");
+ expect(applyIntegration(input).ok).toBe(false);
+ expect(existsSync(marker)).toBe(true);
+ });
+
+ test("an uncommitted marker never authorizes overwriting a foreign edit", () => {
+ seed();
+ const io = input.store!.io();
+ let offline = false;
+ expect(applyIntegration({ ...input, io: { ...io, writeText: (path, text) => {
+ if (path === catalog) offline = true;
+ if (offline && (path === settings || path === catalog)) throw new Error("offline");
+ io.writeText(path, text);
+ } } }).ok).toBe(false);
+ const foreign = '{"version":1,"providers":{"mine":{"changed":true}}}';
+ writeFileSync(catalog, foreign);
+ expect(applyIntegration(input).ok).toBe(false);
+ expect(readFileSync(catalog, "utf8")).toBe(foreign);
+ expect(existsSync(clinePendingPath(input.store!, settings))).toBe(true);
+ });
+
+ test("cleanup failure after append keeps success and permits safe later cleanup", () => {
+ seed();
+ const io = input.store!.io();
+ const marker = clinePendingPath(input.store!, settings);
+ expect(applyIntegration({ ...input, io: { ...io, removeFile: path => {
+ if (path === marker) throw new Error("cleanup unavailable");
+ io.removeFile(path);
+ } } }).ok).toBe(true);
+ expect(input.store!.listOperations("cline")).toHaveLength(1);
+ expect(readIntegrationState(input).state).toBe("current");
+ expect(applyIntegration(input).ok).toBe(true);
+ expect(existsSync(marker)).toBe(false);
+ expect(input.store!.listOperations("cline")).toHaveLength(1);
+ });
+
+ test("adapter keeps non-target IO unchanged and reads a pair as one snapshot", () => {
+ seed();
+ const adapter = createClineIO(input.store!.io(), settings, input.store!);
+ const result = adapter.readText(settings);
+ if (result.kind !== "text") throw new Error("pair missing");
+ expect(decodeClinePair(result.text)).toEqual({ settings: originalSettings, catalog: originalCatalog });
+ expect(adapter.statKind(dirname(settings))).toBe("dir");
+ });
+});
diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts
index 6b49ba79ee..f96cf134c5 100644
--- a/tests/clients/integrations-state.test.ts
+++ b/tests/clients/integrations-state.test.ts
@@ -796,7 +796,7 @@ describe("installation detection is independent of config state", () => {
describe("the loopback-only set is one fact, read through one seam", () => {
test("omp, pi, kimi, gajae, dsh, mcode, zcode, prime, aside, raycast and omo are loopback-only and nobody else is", () => {
const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id));
- expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo"]);
+ expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"]);
});
test("the registry restates nothing — it reads the export spec", () => {
diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts
index 025718dfb4..3a9c171238 100644
--- a/tests/config/client-config-export-new-clients.test.ts
+++ b/tests/config/client-config-export-new-clients.test.ts
@@ -65,7 +65,7 @@ describe("no secret reaches a client config", () => {
// credential wiring is deliberately deferred from those initial generated
// integrations -- omo reuses Pi's builder, which emits no headers at all.
const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly);
- expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo"]);
+ expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"]);
});
test("every client that is not loopback-only carries the header on a remote bind", () => {
diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts
index 438e19a31c..53e47c6276 100644
--- a/tests/config/client-config-export.test.ts
+++ b/tests/config/client-config-export.test.ts
@@ -838,7 +838,7 @@ describe("hub-resolved Fast exports", () => {
describe("EXPORT_CLIENTS registry", () => {
test("covers exactly the fourteen file-toggle clients", () => {
- expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo"]);
+ expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"]);
for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true);
// The exception clients keep their own surfaces and are not export clients.
expect(isExportClientId("claude-desktop")).toBe(false);
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index 9177ea21c9..ec0792d2c5 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -213,10 +213,12 @@
"client-injection-guard.test.ts": "codex-integration",
"client-lifecycle-lock.test.ts": "clients",
"client-machine-listener.test.ts": "clients",
+ "cline-client.test.ts": "clients",
"cline-pass-deepseek-v4-tool-replay.test.ts": "providers",
"cline-pass-provider.test.ts": "providers",
"cline-pass-reasoning-efforts.test.ts": "providers",
"cline-provider.test.ts": "providers",
+ "cline-writer.test.ts": "clients",
"closed-pr-branch-cleanup.test.ts": "ci-workflows",
"codebuddy-adapter.test.ts": "providers",
"codebuddy-protocol.test.ts": "providers",
diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts
index 3ab941e508..b897cfaca7 100644
--- a/tests/gui/integrations-invariants.test.ts
+++ b/tests/gui/integrations-invariants.test.ts
@@ -3,6 +3,8 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync,
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { EXPORT_CLIENTS, EXPORT_CLIENT_IDS, type ExportModel } from "../../src/clients/config-export";
+import { createClineIO } from "../../src/integrations/cline-io";
+import { parseClineDocument } from "../../src/integrations/cline-document";
import { parseConfig } from "../../src/integrations/config-io";
import { INTEGRATION_CLIENTS, INTEGRATION_CLIENT_IDS, type IntegrationClientId } from "../../src/integrations/registry";
import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store";
@@ -157,6 +159,7 @@ describe("the journal is metadata, never a copy of the file", () => {
describe("every client survives a full lifecycle", () => {
/** A pre-existing user document in each client's own format. */
const SEED: Record = {
+ cline: '{"version":1,"modes":{},"providers":{"mine":{"settings":{"provider":"mine"},"updatedAt":"2026-01-01T00:00:00.000Z","tokenSource":"manual"}}}\n',
opencode: '{\n "provider": {\n "mine": { "npm": "keep-me" }\n }\n}\n',
pi: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n',
omp: "providers:\n mine:\n api: http://keep-me\n",
@@ -190,7 +193,15 @@ describe("every client survives a full lifecycle", () => {
const seed = SEED[clientId];
writeFileSync(configPath, seed);
const format = EXPORT_CLIENTS[clientId].format;
- const original = parseConfig(seed, format);
+ // Paired Cline files are one logical ownership document, but remain native files on disk.
+ if (clientId === "cline") writeFileSync(join(dirname(configPath), "models.json"), '{"version":1,"providers":{}}\n');
+ const readDocument = () => {
+ if (clientId !== "cline") return parseConfig(readFileSync(configPath, "utf8"), format);
+ const read = createClineIO(store.io(), configPath, store).readText(configPath);
+ if (read.kind !== "text") throw new Error("missing Cline fixture pair");
+ return parseClineDocument(read.text);
+ };
+ const original = readDocument();
const applied = applyIntegration({
clientId, models: MODELS, config: CONFIG, port: 10100,
@@ -199,7 +210,7 @@ describe("every client survives a full lifecycle", () => {
expect(applied.ok).toBe(true);
// Our fragments are present…
- const afterApply = parseConfig(readFileSync(configPath, "utf8"), format);
+ const afterApply = readDocument();
const record = store.readRecords()[clientId]!;
expect(record.fragmentPaths.length).toBeGreaterThan(0);
// Read through the writer's own segment grammar: Raycast's path holds a
@@ -231,7 +242,7 @@ describe("every client survives a full lifecycle", () => {
* that is what the snapshot is for. What disable owes the user is that
* every value they had is still there and ours is gone.
*/
- const afterDisable = parseConfig(readFileSync(configPath, "utf8"), format);
+ const afterDisable = readDocument();
expect(afterDisable).toEqual(original);
});
}
From 1feec1bdc05d98cd56f9124aff3df8357a3fecdc Mon Sep 17 00:00:00 2001
From: JUN
Date: Sat, 12 Sep 2026 14:01:17 +0900
Subject: [PATCH 3/6] feat: expose Cline CLI in integration dashboard and
explicit catalog sync
---
.../_plan/260912_cline_client/020_surfaces.md | 2 +
.../029_surfaces_result.md | 5 ++
.../content/docs/fr/guides/integrations.md | 15 ++++-
.../src/content/docs/guides/integrations.md | 55 ++++++++++++++++++-
.../content/docs/tr/guides/integrations.md | 15 ++++-
.../content/docs/zh-tw/guides/integrations.md | 15 ++++-
gui/src/app-routing.ts | 1 +
.../client-config-clients.ts | 4 +-
gui/src/components/integration-marks.ts | 1 +
gui/src/i18n/de.ts | 3 +
gui/src/i18n/en.ts | 3 +
gui/src/i18n/fr.ts | 3 +
gui/src/i18n/ja.ts | 3 +
gui/src/i18n/ko.ts | 3 +
gui/src/i18n/ru.ts | 3 +
gui/src/i18n/tr.ts | 3 +
gui/src/i18n/zh-TW.ts | 3 +
gui/src/i18n/zh.ts | 3 +
.../integrations/FileIntegrationPage.tsx | 2 +
gui/src/pages/integrations/integration-api.ts | 1 +
.../pages/integrations/integration-tabs.ts | 2 +
.../pages/integrations/overview-clients.ts | 1 +
gui/tests/client-config-panel.test.tsx | 2 +-
src/cli/dispatch.ts | 2 +-
src/cli/registry.ts | 5 +-
src/server/management/config-routes.ts | 2 +-
structure/clients/claude-desktop.md | 2 +
structure/config.md | 2 +
structure/design-methodology.md | 2 +
structure/gui-and-management-api.md | 2 +
structure/ops/docs-and-release.md | 2 +
structure/overview.md | 2 +
structure/runtime.md | 2 +
tests/clients/cline-writer.test.ts | 12 ++++
.../clients/sync-client-integrations.test.ts | 4 +-
35 files changed, 174 insertions(+), 13 deletions(-)
create mode 100644 devlog/_plan/260912_cline_client/029_surfaces_result.md
diff --git a/devlog/_plan/260912_cline_client/020_surfaces.md b/devlog/_plan/260912_cline_client/020_surfaces.md
index 3cee18b85c..6114503acf 100644
--- a/devlog/_plan/260912_cline_client/020_surfaces.md
+++ b/devlog/_plan/260912_cline_client/020_surfaces.md
@@ -10,3 +10,5 @@ MODIFY all gui/src/i18n locale modules: append integrations.tab.cline, integrati
MODIFY docs-site/src/content/docs/guides/integrations.md: documented installation contract, env precedence, --client verbs, conflict opt-in, --op restore/drift, two-file export format and running-client limitation. Update structure/runtime.md and structure GUI/CLI ownership docs where applicable with factual links to canonical integration contract.
Verification: existing GUI client-list/route/i18n assertions extended for cline. No local GUI tests/build. Final hosted GUI build/lint/tests; obtain hosted screenshot artifact where available and inspect it. If unavailable report missing visual evidence rather than fabricate screenshot. No real user server configuration is used for capture.
+
+Surfaces P resumes 019: "next cycle wires existing CLI/dashboard surfaces and explicit catalog sync." Source lists still end in omo; append Cline only. Existing cline-color.svg is reused. Public guide path verified as guides/integrations.md. Independent core audit corrections remain mandatory in the final verification cycle; these list/copy changes do not depend on its implementation details.
diff --git a/devlog/_plan/260912_cline_client/029_surfaces_result.md b/devlog/_plan/260912_cline_client/029_surfaces_result.md
new file mode 100644
index 0000000000..eecf696e87
--- /dev/null
+++ b/devlog/_plan/260912_cline_client/029_surfaces_result.md
@@ -0,0 +1,5 @@
+# Surface wiring checkpoint
+
+Cline CLI appears in existing integration, export, route and mark registries. Nine dashboard locales explain the paired-file stop/restart and Undo contract. English and existing translated integration guides identify the current CLI schema. Explicit sync includes previously owned Cline files; unattended catalog refresh excludes Cline.
+
+The existing committed Cline color mark is reused. No layout or new UI component was introduced. Local GUI tests/build/typecheck and product suites are NOT RUN. git diff --check is text-only evidence. Runtime and rendered verification remain on the final cumulative hosted tip; core recovery review corrections remain mandatory in the next cycle. Next direction: inspect the two concrete recovery findings, repair with regression source, then publish and track CI.
diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md
index b803c9f2c5..831a7dbaef 100644
--- a/docs-site/src/content/docs/fr/guides/integrations.md
+++ b/docs-site/src/content/docs/fr/guides/integrations.md
@@ -4,7 +4,7 @@ description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Co
---
L'onglet **Intégrations** écrit le bloc fournisseur d'opencodex dans le fichier de configuration du client,
-puis peut le retirer. Quatorze clients fonctionnent ainsi, chacun avec son propre commutateur :
+puis peut le retirer. Quinze clients fonctionnent ainsi, chacun avec son propre commutateur :
| Client | Fichier de configuration | Format | Prise d'effet de la modification | Identifiant |
|---|---|---|---|---|
@@ -22,6 +22,7 @@ puis peut le retirer. Quatorze clients fonctionnent ainsi, chacun avec son propr
| Aside | `~/.aside/u//models.json` | JSON | après avoir quitté complètement puis rouvert Aside | valeur fictive de bouclage |
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immédiatement à l'enregistrement — Raycast surveille le fichier | aucun — bouclage uniquement |
| omo | `~/.omo/agent/models.json` | JSON | nouvelles sessions | espace réservé de bouclage |
+| Cline CLI | `~/.cline/data/settings/providers.json` + `models.json` | JSON | après arrêt et redémarrage | bouclage uniquement |
La prise en charge gérée de DSH exige au minimum **DSH 0.1.0-rc.6**. OpenCodex ne possède que le fragment
`llm-pi-ai.providers.opencodex` : **Appliquer** et **Actualiser** remplacent ce fragment, **Désactiver** ne
@@ -234,3 +235,15 @@ commande refuse et vous l'indique : remplacer vos modifications plus récentes r
Les détails des clients ont été vérifiés par rapport au format de configuration propre à chaque projet ;
consultez les notes de recherche dans
`devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` pour savoir ce qui a été contrôlé et quand.
+
+## Cline CLI
+
+Cline CLI utilise providers.json et models.json. Quittez Cline avant toute modification ou synchronisation, puis redémarrez-le. Annuler restaure les deux originaux. Le fournisseur par défaut reste inchangé. Cette intégration ne migre pas le stockage des anciennes extensions VS Code.
+
+```bash
+ocx integration client enable --client cline
+ocx integration client history --client cline
+ocx integration client restore --op
+```
+
+[CLI / rollback / CLINE_PROVIDER_SETTINGS_PATH](/guides/integrations/#cline-cli).
diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md
index f5d6dfa6a4..60dd516a9e 100644
--- a/docs-site/src/content/docs/guides/integrations.md
+++ b/docs-site/src/content/docs/guides/integrations.md
@@ -1,10 +1,10 @@
---
title: Integrations
-description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast and omo from the dashboard — one switch per client, with a backup taken before every write.
+description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo and Cline CLI from the dashboard — one switch per client, with a backup taken before every write.
---
The **Integrations** tab writes opencodex's provider block into a client's own config
-file, and removes it again. Fourteen clients work this way, each with a switch:
+file, and removes it again. Fifteen clients work this way, each with a switch:
| Client | Config file | Format | When the change takes effect | Credential |
|---|---|---|---|---|
@@ -22,6 +22,7 @@ file, and removes it again. Fourteen clients work this way, each with a switch:
| Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder |
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only |
| omo | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder |
+| Cline CLI | `~/.cline/data/settings/providers.json` and sibling `models.json` | JSON pair | after stopping and restarting Cline | loopback placeholder |
Generated catalogs include only enabled models from each provider selection. This applies to both
downloads and managed integrations, including Pi and Aside. The management model list still shows
@@ -344,3 +345,53 @@ the entire change failed. If saving those settings fails, no profile files are c
Each profile has separate ownership and history. Existing user edits, unsafe paths and linked
catalogs are refused; the existing explicit overwrite and drift-confirmation controls remain
available. Fully quit and reopen Aside to load changed model files.
+
+
+## Cline CLI
+
+This integration targets Cline's current CLI/shared SDK provider store, whose native schema has
+`version: 1`. Legacy VS Code extension `globalState`/secret storage is not migrated or detected
+as this integration. Run Cline once to initialize its settings directory.
+
+**Stop Cline before enabling, syncing, disabling or restoring the integration.** OpenCodex writes
+`providers.opencodex` into both `providers.json` and sibling `models.json`. The first file holds
+the OpenAI Responses connection with a non-secret loopback placeholder; the second holds the
+filtered routed model catalog, including available context and image metadata. Existing provider
+entries and the default provider selection remain unchanged.
+
+```bash
+ocx integration client list --json
+ocx integration client enable --client cline
+ocx integration client history --client cline
+ocx integration client restore --op
+```
+
+After enabling, restart Cline and select OpenCodex, or launch with
+`cline --provider opencodex --model `. External catalog changes are read when
+Cline restarts. Cline is excluded from unattended catalog refresh; after changing the routed
+model selection, stop Cline and run `ocx sync` or enable the integration again to refresh it.
+A selected model is preserved while still routed and cleared if removed from the exported catalog.
+
+`CLINE_PROVIDER_SETTINGS_PATH` overrides the primary file. Otherwise `CLINE_DATA_DIR` selects the
+data directory, then `CLINE_DIR` selects the root, then `~/.cline` is used. The model file is always
+`models.json` beside the selected provider file. Overrides must be absolute or start with `~`.
+Mirror command-local Cline `--config` paths with `CLINE_PROVIDER_SETTINGS_PATH` when starting
+OpenCodex. A primary path named `models.json` is refused because the files must be distinct.
+
+Each file replacement is atomic, but no filesystem operation replaces both simultaneously.
+One journal operation snapshots both original files; a write or bookkeeping failure compensates
+both. An interrupted operation retains a private recovery record. Status reports incomplete
+recovery as unsafe, and the next explicit mutation recovers only if neither file nor its
+ownership has an unrelated edit. If recovery refuses, preserve the files and the recovery path
+reported by the operation; resolve the conflict before retrying.
+
+Undo restores **both original byte strings**, including a file that originally did not exist.
+Edits after the operation require the existing explicit `--confirm-drift`; the edited pair is
+backed up first. An occupied OpenCodex entry requires the existing `--overwrite-conflict` opt-in.
+Disable removes the two managed entries; it does not restore a prior foreign entry. Use Undo
+for that. Snapshot retention and expiration follow the same rules as other integrations.
+
+The download `cline-config-bundle.json` contains two native document members: `settings` for
+`providers.json`, and `catalog` for `models.json`. It is not itself a Cline settings file. Prefer
+the integration command for a journaled merge and rollback. Remote admission wiring is not
+supported by this generated integration; it requires unauthenticated loopback access.
diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md
index 03f64377f2..62ec067bb2 100644
--- a/docs-site/src/content/docs/tr/guides/integrations.md
+++ b/docs-site/src/content/docs/tr/guides/integrations.md
@@ -4,7 +4,7 @@ description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code,
---
**Entegrasyonlar** sekmesi, opencodex'in sağlayıcı bloğunu istemcinin kendi
-yapılandırma dosyasına yazar ve tekrar kaldırır. On dört istemci bu şekilde
+yapılandırma dosyasına yazar ve tekrar kaldırır. On beş istemci bu şekilde
çalışır, her biri bir anahtarla:
| İstemci | Yapılandırma dosyası | Format | Değişiklik ne zaman geçerli olur? | Kimlik bilgisi |
@@ -23,6 +23,7 @@ yapılandırma dosyasına yazar ve tekrar kaldırır. On dört istemci bu şekil
| Aside | `~/.aside/u//models.json` | JSON | Aside tamamen kapatılıp yeniden açıldıktan sonra | geri döngü yer tutucusu |
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | kaydedildiği anda — Raycast dosyayı izler | yok — yalnızca geri döngü |
| omo | `~/.omo/agent/models.json` | JSON | yeni oturumlarda | geri döngü yer tutucusu |
+| Cline CLI | `~/.cline/data/settings/providers.json` + `models.json` | JSON | kapatıp yeniden başlattıktan sonra | yalnızca loopback |
Yönetilen DSH desteğinin en düşük uyumlu sürümü **DSH 0.1.0-rc.6**'dır. OpenCodex yalnızca
`llm-pi-ai.providers.opencodex` bölümünü yönetir: Uygula ve Yenile bu bölümü değiştirir, Devre Dışı
@@ -261,3 +262,15 @@ değiştiyse, komut reddeder ve size bildirir; çünkü daha yeni düzenlemeleri
doğrulanmıştır; neyin ne zaman denetlendiğine ilişkin
`devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` içindeki
araştırma notlarına bakın.
+
+## Cline CLI
+
+Cline CLI providers.json ve models.json kullanır. Değişiklik veya eşitleme öncesinde Cline’ı kapatın, sonra yeniden başlatın. Geri al iki özgün dosyayı geri yükler. Varsayılan sağlayıcı değişmez. Eski VS Code uzantısının depolaması taşınmaz.
+
+```bash
+ocx integration client enable --client cline
+ocx integration client history --client cline
+ocx integration client restore --op
+```
+
+[CLI / rollback / CLINE_PROVIDER_SETTINGS_PATH](/guides/integrations/#cline-cli).
diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md
index bc9277656f..9d8252b279 100644
--- a/docs-site/src/content/docs/zh-tw/guides/integrations.md
+++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md
@@ -3,7 +3,7 @@ title: 整合
description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、gjc、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside、Raycast 與 omo——每個客戶端一個開關,每次寫入前都會先備份。
---
-**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十四個客戶端以這種方式運作,每個都有一個開關:
+**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十五個客戶端以這種方式運作,每個都有一個開關:
| 客戶端 | 設定檔 | 格式 | 變更生效時機 | 憑證 |
|---|---|---|---|---|
@@ -21,6 +21,7 @@ description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、
| Aside | `~/.aside/u//models.json` | JSON | 完全結束並重新開啟 Aside 後 | loopback 佔位符 |
| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | 儲存後立即生效——Raycast 會監看該檔案 | 無——僅限 loopback |
| omo | `~/.omo/agent/models.json` | JSON | 新工作階段 | loopback 佔位符 |
+| Cline CLI | `~/.cline/data/settings/providers.json` + `models.json` | JSON | 結束並重新啟動後 | 僅限 loopback |
受管理 DSH 支援的相容性下限是 **DSH 0.1.0-rc.6**。OpenCodex 只擁有
`llm-pi-ai.providers.opencodex`:Apply 與 Refresh 會取代該片段,Disable 只移除該片段,
@@ -148,3 +149,15 @@ OAuth 或 API key,並拒絕 `--api-key`、`--base-url` 與 `--region` 覆寫
`--confirm-drift` 永遠不會被擅自假設。如果檔案在你正要回復的操作之後有變更,指令會拒絕並告訴你,因為覆蓋你較新的編輯是你的決定。
客戶端細節是針對各專案自己的設定格式驗證過的;檢查了什麼、何時檢查,請見 `devlog/_fin/260802_client_toggle_api/002_client_toggle_matrix.md` 中的研究筆記。
+
+## Cline CLI
+
+Cline CLI 使用 providers.json 與 models.json。修改或同步前請結束 Cline,完成後重新啟動。復原會還原兩個原始檔案,預設供應商保持不變。此整合不會遷移舊版 VS Code 擴充功能的儲存資料。
+
+```bash
+ocx integration client enable --client cline
+ocx integration client history --client cline
+ocx integration client restore --op
+```
+
+[CLI / rollback / CLINE_PROVIDER_SETTINGS_PATH](/guides/integrations/#cline-cli).
diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts
index cf9762ab71..e2184af3d2 100644
--- a/gui/src/app-routing.ts
+++ b/gui/src/app-routing.ts
@@ -102,6 +102,7 @@ export const INTEGRATION_TAB_HASHES = [
"integrations/aside",
"integrations/raycast",
"integrations/omo",
+ "integrations/cline",
] as const;
export function hashBelongsToPage(rawHash: string, page: Page): boolean {
diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts
index 2c6e198600..e164810fbb 100644
--- a/gui/src/components/apikeys-workspace/client-config-clients.ts
+++ b/gui/src/components/apikeys-workspace/client-config-clients.ts
@@ -8,7 +8,7 @@
* with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row
* until this tuple changes.
*/
-export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo"] as const;
+export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"] as const;
export type ExportClientId = (typeof CLIENTS)[number];
export const CLIENT_LABEL_KEYS = {
@@ -26,6 +26,7 @@ export const CLIENT_LABEL_KEYS = {
aside: "api.clientConfig.clientAside",
raycast: "api.clientConfig.clientRaycast",
omo: "api.clientConfig.clientOmo",
+ cline: "api.clientConfig.clientCline",
} as const;
/**
@@ -77,6 +78,7 @@ export const CLIENT_MARKS: Partial> = {
// Two inks: an #F4F4F4 plate carrying an #041617 face. Masking reads alpha,
// so it would paint the plate and throw the face away — see the README.
omo: "/provider-icons/omo.svg",
+ cline: "/provider-icons/cline-color.svg",
};
/**
diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts
index f345866336..58873cb1cb 100644
--- a/gui/src/components/integration-marks.ts
+++ b/gui/src/components/integration-marks.ts
@@ -59,6 +59,7 @@ export const INTEGRATION_MARKS: Record = {
aside: CLIENT_MARKS.aside ?? null,
raycast: CLIENT_MARKS.raycast ?? null,
omo: CLIENT_MARKS.omo ?? null,
+ cline: CLIENT_MARKS.cline ?? null,
};
/**
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index fdb18eb6ed..b90f586392 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -1105,6 +1105,7 @@ export const de: Record = {
"integrations.tab.aside": "Aside",
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
+ "integrations.tab.cline": "Cline CLI",
"integrations.aside.profilesTitle": "Aside-Profile",
"integrations.aside.profilesHint": "Wähle, welche Profile die ausgewählten Modelle erhalten. Das aktive Aside-Profil bleibt unverändert.",
"integrations.aside.all": "Alle Profile synchronisieren",
@@ -1266,6 +1267,7 @@ export const de: Record = {
"integrations.semantics.aside": "Verwaltet nur providers.opencodex in der ~/.aside/u//models.json dieses Profils. Andere Provider bleiben unverändert. Beende Aside nach dem Anwenden vollständig und öffne es erneut.",
"integrations.semantics.raycast": "Fügt einen OpenCodex-Provider-Eintrag in die providers.yaml von Raycast ein, damit jedes geroutete Modell in der Modellauswahl von Raycast AI erscheint. Raycast Pro erforderlich.",
"integrations.semantics.omo": "Verwaltet ausschließlich providers.opencodex in der models.json von omo — ~/.omo/agent, sofern nicht OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR oder PI_CODING_AGENT_DIR sie umleitet. Ihre übrigen Provider bleiben unverändert. Gilt ab neuen Sitzungen.",
+ "integrations.semantics.cline": "Verwaltet OpenCodex in providers.json und models.json der Cline CLI. Beenden Sie Cline vor Änderungen oder Synchronisierung und starten Sie es danach neu. Rückgängig stellt beide Originaldateien wieder her. Ihr Standardanbieter bleibt unverändert; wählen Sie OpenCodex in Cline.",
"integrations.raycast.proRequired": "Custom Providers ist eine Funktion von Raycast Pro. Die Datei wird geschrieben, aber Raycast ignoriert sie, bis ein Pro-Abonnement aktiv ist.",
"integrations.raycast.planUnknown": "Es konnte nicht festgestellt werden, ob Raycast Pro aktiv ist; Custom Providers erfordert Raycast Pro.",
"integrations.raycast.revealConfig": "Öffnen Sie Raycast → Einstellungen → AI und klicken Sie einmal auf „Reveal Providers Config“, damit der Providers-Ordner existiert.",
@@ -1638,6 +1640,7 @@ export const de: Record = {
"api.clientConfig.clientAside": "Aside",
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
+ "api.clientConfig.clientCline": "Cline CLI",
"api.clientConfig.copy": "Konfiguration kopieren",
"api.clientConfig.download": "Herunterladen",
"api.clientConfig.loading": "Client-Konfiguration wird erstellt…",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index 1847a7af7e..9c490e9d21 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -1641,6 +1641,7 @@ export const en = {
"integrations.tab.aside": "Aside",
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
+ "integrations.tab.cline": "Cline CLI",
"integrations.aside.profilesTitle": "Aside profiles",
"integrations.aside.profilesHint": "Choose which profiles receive the selected models. Aside’s active profile stays unchanged.",
"integrations.aside.all": "Sync all profiles",
@@ -1842,6 +1843,7 @@ export const en = {
"integrations.semantics.aside": "Manages only providers.opencodex in this profile’s ~/.aside/u//models.json. Your other providers stay unchanged. Fully quit and reopen Aside after applying.",
"integrations.semantics.raycast": "Adds an OpenCodex provider entry to Raycast's providers.yaml so every routed model appears in the Raycast AI model picker. Raycast Pro required.",
"integrations.semantics.omo": "Manages only providers.opencodex in omo's models.json — ~/.omo/agent unless OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR or PI_CODING_AGENT_DIR redirects it. Your other providers stay unchanged. Applies to new sessions.",
+ "integrations.semantics.cline": "Manages OpenCodex in Cline CLI providers.json and models.json. Stop Cline before changing or syncing these files, then restart. Undo restores both originals. Your default provider stays unchanged; select OpenCodex in Cline.",
"integrations.raycast.proRequired": "Custom Providers is a Raycast Pro feature. The file will be written, but Raycast ignores it until a Pro subscription is active.",
"integrations.raycast.planUnknown": "Could not determine whether Raycast Pro is active; Custom Providers requires Raycast Pro.",
"integrations.raycast.revealConfig": "Open Raycast → Settings → AI and click Reveal Providers Config once so the providers folder exists.",
@@ -2225,6 +2227,7 @@ export const en = {
"api.clientConfig.clientAside": "Aside",
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
+ "api.clientConfig.clientCline": "Cline CLI",
"api.clientConfig.copy": "Copy config",
"api.clientConfig.download": "Download",
"api.clientConfig.loading": "Building client config…",
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index e465adbb10..e1f6893a3c 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -1613,6 +1613,7 @@ export const fr: Record = {
"integrations.tab.aside": "Aside",
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
+ "integrations.tab.cline": "Cline CLI",
"integrations.aside.profilesTitle": "Profils Aside",
"integrations.aside.profilesHint": "Choisissez les profils qui recevront les modèles sélectionnés. Le profil actif dans Aside reste inchangé.",
"integrations.aside.all": "Synchroniser tous les profils",
@@ -1774,6 +1775,7 @@ export const fr: Record = {
"integrations.semantics.aside": "Gère uniquement providers.opencodex dans le fichier ~/.aside/u//models.json de ce profil. Vos autres fournisseurs restent inchangés. Quittez complètement Aside et relancez-le après application.",
"integrations.semantics.raycast": "Ajoute une entrée de fournisseur OpenCodex dans le providers.yaml de Raycast afin que chaque modèle routé apparaisse dans le sélecteur de modèles de Raycast AI. Raycast Pro requis.",
"integrations.semantics.omo": "Gère uniquement providers.opencodex dans le models.json d'omo — ~/.omo/agent, sauf redirection par OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR ou PI_CODING_AGENT_DIR. Vos autres fournisseurs restent inchangés. S'applique aux nouvelles sessions.",
+ "integrations.semantics.cline": "Gère OpenCodex dans providers.json et models.json de Cline CLI. Quittez Cline avant toute modification ou synchronisation, puis redémarrez-le. Annuler restaure les deux fichiers originaux. Votre fournisseur par défaut reste inchangé ; sélectionnez OpenCodex dans Cline.",
"integrations.raycast.proRequired": "Custom Providers est une fonctionnalité Raycast Pro. Le fichier sera écrit, mais Raycast l'ignore tant qu'un abonnement Pro n'est pas actif.",
"integrations.raycast.planUnknown": "Impossible de déterminer si Raycast Pro est actif ; Custom Providers nécessite Raycast Pro.",
"integrations.raycast.revealConfig": "Ouvrez Raycast → Réglages → AI et cliquez une fois sur « Reveal Providers Config » pour que le dossier des fournisseurs existe.",
@@ -2145,6 +2147,7 @@ export const fr: Record = {
"api.clientConfig.clientAside": "Aside",
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
+ "api.clientConfig.clientCline": "Cline CLI",
"api.clientConfig.copy": "Copier la configuration",
"api.clientConfig.download": "Télécharger",
"api.clientConfig.loading": "Génération de la configuration du client…",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index e9a3d9f58b..123c782004 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -1538,6 +1538,7 @@ export const ja: Record = {
"integrations.tab.aside": "Aside",
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
+ "integrations.tab.cline": "Cline CLI",
"integrations.aside.profilesTitle": "Asideのプロファイル",
"integrations.aside.profilesHint": "選択したモデルを同期するプロファイルを選んでください。Asideで使用中のプロファイルは変わりません。",
"integrations.aside.all": "すべてのプロファイルを同期",
@@ -1699,6 +1700,7 @@ export const ja: Record = {
"integrations.semantics.aside": "このプロファイルの ~/.aside/u//models.json 内の providers.opencodex のみを管理します。他のプロバイダーは変更しません。適用後は Aside を完全に終了してから開き直してください。",
"integrations.semantics.raycast": "Raycast の providers.yaml に OpenCodex のプロバイダーエントリを追加し、ルーティングされたすべてのモデルを Raycast AI のモデル選択に表示します。Raycast Pro が必要です。",
"integrations.semantics.omo": "omo の models.json にある providers.opencodex のみを管理します。場所は ~/.omo/agent で、OMO_CODING_AGENT_DIR・SENPI_CODING_AGENT_DIR・PI_CODING_AGENT_DIR のいずれかが設定されている場合はそちらが優先されます。他のプロバイダーは変更しません。新しいセッションから適用されます。",
+ "integrations.semantics.cline": "Cline CLI の providers.json と models.json の OpenCodex 項目を管理します。変更・同期前に Cline を終了し、完了後に再起動してください。元に戻すと両方の元ファイルが復元されます。既定のプロバイダーは変わりません。Cline で OpenCodex を選択してください。",
"integrations.raycast.proRequired": "Custom Providers は Raycast Pro の機能です。ファイルは書き込まれますが、Pro サブスクリプションが有効になるまで Raycast はこれを無視します。",
"integrations.raycast.planUnknown": "Raycast Pro が有効かどうか確認できませんでした。Custom Providers には Raycast Pro が必要です。",
"integrations.raycast.revealConfig": "Raycast → 設定 → AI を開き、「Reveal Providers Config」を一度クリックして providers フォルダを作成してください。",
@@ -2076,6 +2078,7 @@ export const ja: Record = {
"api.clientConfig.clientAside": "Aside",
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
+ "api.clientConfig.clientCline": "Cline CLI",
"api.clientConfig.copy": "設定をコピー",
"api.clientConfig.download": "ダウンロード",
"api.clientConfig.loading": "クライアント設定を生成中…",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index 67ee251970..a5d602a97e 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -1141,6 +1141,7 @@ export const ko: Record = {
"integrations.tab.aside": "Aside",
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
+ "integrations.tab.cline": "Cline CLI",
"integrations.aside.profilesTitle": "Aside 프로필",
"integrations.aside.profilesHint": "선택한 모델을 동기화할 프로필을 고르세요. Aside에서 사용 중인 프로필은 바뀌지 않습니다.",
"integrations.aside.all": "모든 프로필 동기화",
@@ -1302,6 +1303,7 @@ export const ko: Record = {
"integrations.semantics.aside": "이 프로필의 ~/.aside/u//models.json에서 providers.opencodex만 관리합니다. 다른 프로바이더는 그대로 유지됩니다. 적용 후 Aside를 완전히 종료하고 다시 여세요.",
"integrations.semantics.raycast": "Raycast의 providers.yaml에 OpenCodex 프로바이더 항목을 추가해 라우팅된 모든 모델이 Raycast AI 모델 선택기에 표시되도록 합니다. Raycast Pro가 필요합니다.",
"integrations.semantics.omo": "omo의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.omo/agent이며 OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR, PI_CODING_AGENT_DIR 중 설정된 값이 있으면 그쪽이 우선합니다. 다른 프로바이더는 그대로 유지됩니다. 새 세션부터 적용됩니다.",
+ "integrations.semantics.cline": "Cline CLI의 providers.json과 models.json에서 OpenCodex 항목을 관리합니다. 변경·동기화 전에 Cline을 종료하고 완료 후 다시 실행하세요. 되돌리기는 두 파일의 원본을 복원합니다. 기본 프로바이더는 유지되므로 Cline에서 OpenCodex를 선택하세요.",
"integrations.raycast.proRequired": "Custom Providers는 Raycast Pro 기능입니다. 파일은 기록되지만 Pro 구독이 활성화될 때까지 Raycast는 이를 무시합니다.",
"integrations.raycast.planUnknown": "Raycast Pro 활성 여부를 확인할 수 없습니다. Custom Providers에는 Raycast Pro가 필요합니다.",
"integrations.raycast.revealConfig": "Raycast → 설정 → AI를 열고 「Reveal Providers Config」를 한 번 클릭해 providers 폴더를 만드세요.",
@@ -1677,6 +1679,7 @@ export const ko: Record = {
"api.clientConfig.clientAside": "Aside",
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
+ "api.clientConfig.clientCline": "Cline CLI",
"api.clientConfig.copy": "설정 복사",
"api.clientConfig.download": "다운로드",
"api.clientConfig.loading": "클라이언트 설정 생성 중…",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 56d43fc301..bd804e5f45 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -1608,6 +1608,7 @@ export const ru: Record = {
"integrations.tab.aside": "Aside",
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
+ "integrations.tab.cline": "Cline CLI",
"integrations.aside.profilesTitle": "Профили Aside",
"integrations.aside.profilesHint": "Выберите профили, в которые будут добавлены выбранные модели. Активный профиль Aside не изменится.",
"integrations.aside.all": "Синхронизировать все профили",
@@ -1769,6 +1770,7 @@ export const ru: Record = {
"integrations.semantics.aside": "Управляет только providers.opencodex в файле ~/.aside/u//models.json этого профиля. Другие провайдеры остаются без изменений. После применения полностью закройте и снова откройте Aside.",
"integrations.semantics.raycast": "Добавляет запись провайдера OpenCodex в providers.yaml Raycast, чтобы каждая маршрутизируемая модель появилась в выборе моделей Raycast AI. Требуется Raycast Pro.",
"integrations.semantics.omo": "Управляет только providers.opencodex в models.json omo — ~/.omo/agent, если только OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR или PI_CODING_AGENT_DIR не перенаправляет путь. Остальные провайдеры остаются без изменений. Применяется к новым сессиям.",
+ "integrations.semantics.cline": "Управляет OpenCodex в файлах providers.json и models.json Cline CLI. Закройте Cline перед изменением или синхронизацией и запустите снова после завершения. Отмена восстанавливает оба исходных файла. Провайдер по умолчанию не меняется; выберите OpenCodex в Cline.",
"integrations.raycast.proRequired": "Custom Providers — функция Raycast Pro. Файл будет записан, но Raycast игнорирует его, пока не активна подписка Pro.",
"integrations.raycast.planUnknown": "Не удалось определить, активен ли Raycast Pro; для Custom Providers требуется Raycast Pro.",
"integrations.raycast.revealConfig": "Откройте Raycast → Настройки → AI и один раз нажмите «Reveal Providers Config», чтобы папка провайдеров появилась.",
@@ -2146,6 +2148,7 @@ export const ru: Record = {
"api.clientConfig.clientAside": "Aside",
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
+ "api.clientConfig.clientCline": "Cline CLI",
"api.clientConfig.copy": "Копировать конфигурацию",
"api.clientConfig.download": "Скачать",
"api.clientConfig.loading": "Формируется конфигурация клиента…",
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index b627813bb9..18bf213816 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -1627,6 +1627,7 @@ export const tr: Record = {
"integrations.tab.aside": "Aside",
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
+ "integrations.tab.cline": "Cline CLI",
"integrations.aside.profilesTitle": "Aside profilleri",
"integrations.aside.profilesHint": "Seçili modellerin hangi profillere aktarılacağını seçin. Aside’ın etkin profili değişmez.",
"integrations.aside.all": "Tüm profilleri eşitle",
@@ -1787,6 +1788,7 @@ export const tr: Record = {
"integrations.semantics.aside": "Yalnızca bu profilin ~/.aside/u//models.json dosyasındaki providers.opencodex bölümünü yönetir. Diğer sağlayıcılarınız değişmez. Uyguladıktan sonra Aside’ı tamamen kapatıp yeniden açın.",
"integrations.semantics.raycast": "Raycast'in providers.yaml dosyasına bir OpenCodex sağlayıcı girdisi ekler; böylece yönlendirilen her model Raycast AI model seçicisinde görünür. Raycast Pro gerekir.",
"integrations.semantics.omo": "Yalnızca omo'nun models.json dosyasındaki providers.opencodex girdisini yönetir — OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR veya PI_CODING_AGENT_DIR yönlendirmediği sürece ~/.omo/agent. Diğer sağlayıcılarınız değişmeden kalır. Yeni oturumlardan itibaren geçerlidir.",
+ "integrations.semantics.cline": "Cline CLI providers.json ve models.json dosyalarındaki OpenCodex girdilerini yönetir. Değişiklik veya eşitleme öncesinde Cline’ı kapatın, ardından yeniden başlatın. Geri al iki özgün dosyayı da geri yükler. Varsayılan sağlayıcınız değişmez; Cline içinde OpenCodex’i seçin.",
"integrations.raycast.proRequired": "Custom Providers bir Raycast Pro özelliğidir. Dosya yazılır, ancak bir Pro aboneliği etkin olana kadar Raycast bunu yok sayar.",
"integrations.raycast.planUnknown": "Raycast Pro’nun etkin olup olmadığı belirlenemedi; Custom Providers için Raycast Pro gerekir.",
"integrations.raycast.revealConfig": "Raycast → Ayarlar → AI bölümünü açıp sağlayıcı klasörünün oluşması için „Reveal Providers Config“ seçeneğine bir kez tıklayın.",
@@ -2165,6 +2167,7 @@ export const tr: Record = {
"api.clientConfig.clientAside": "Aside",
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
+ "api.clientConfig.clientCline": "Cline CLI",
"api.clientConfig.copy": "JSON Kopyala",
"api.clientConfig.download": "İndir",
"api.clientConfig.loading": "İstemci konfigürasyonu oluşturuluyor…",
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index ce06556fd4..e8feb4081b 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -2255,6 +2255,7 @@ export const zhTW: Record = {
"integrations.tab.aside": "Aside",
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
+ "integrations.tab.cline": "Cline CLI",
"integrations.aside.profilesTitle": "Aside 設定檔",
"integrations.aside.profilesHint": "選擇要接收所選模型的設定檔。Aside 目前使用的設定檔不會改變。",
"integrations.aside.all": "同步所有設定檔",
@@ -2416,6 +2417,7 @@ export const zhTW: Record = {
"integrations.semantics.aside": "僅管理此設定檔的 ~/.aside/u//models.json 中的 providers.opencodex。其他供應商維持不變。套用後請完全結束並重新開啟 Aside。",
"integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中新增一個 OpenCodex 供應商項目,讓所有已路由的模型出現在 Raycast AI 模型選擇器中。需要 Raycast Pro。",
"integrations.semantics.omo": "僅管理 omo 的 models.json 中的 providers.opencodex,路徑為 ~/.omo/agent,若設定了 OMO_CODING_AGENT_DIR、SENPI_CODING_AGENT_DIR 或 PI_CODING_AGENT_DIR 則以其為準。你的其他供應商維持不變。對新工作階段生效。",
+ "integrations.semantics.cline": "管理 Cline CLI 的 providers.json 和 models.json 中的 OpenCodex 項目。修改或同步前請結束 Cline,完成後重新啟動。復原會還原兩個檔案的原始內容。預設供應商保持不變,請在 Cline 中選擇 OpenCodex。",
"integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。檔案會被寫入,但在 Pro 訂閱生效之前 Raycast 會忽略它。",
"integrations.raycast.planUnknown": "無法確認 Raycast Pro 是否已啟用;Custom Providers 需要 Raycast Pro。",
"integrations.raycast.revealConfig": "開啟 Raycast → 設定 → AI,點一次「Reveal Providers Config」,以便建立 providers 資料夾。",
@@ -2462,6 +2464,7 @@ export const zhTW: Record = {
"api.clientConfig.clientAside": "Aside",
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
+ "api.clientConfig.clientCline": "Cline CLI",
"cws.tabsLabel": "Combo 詳細區段",
"cws.field.nativeAlias": "原生 OpenAI 別名",
"cws.field.nativeAliasHint": "讓此 combo 擁有受支援的未限定原生 OpenAI 模型 ID。帶有帳號或供應商限定的 OpenAI 路由仍保持獨立。",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index ae2fdfec92..0084100380 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -1122,6 +1122,7 @@ export const zh: Record = {
"integrations.tab.aside": "Aside",
"integrations.tab.raycast": "Raycast",
"integrations.tab.omo": "omo",
+ "integrations.tab.cline": "Cline CLI",
"integrations.aside.profilesTitle": "Aside 配置文件",
"integrations.aside.profilesHint": "选择要接收所选模型的配置文件。Aside 当前使用的配置文件不会改变。",
"integrations.aside.all": "同步所有配置文件",
@@ -1283,6 +1284,7 @@ export const zh: Record = {
"integrations.semantics.aside": "仅管理此配置文件的 ~/.aside/u//models.json 中的 providers.opencodex。其他提供商保持不变。应用后请完全退出并重新打开 Aside。",
"integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中添加一个 OpenCodex 提供商条目,让所有已路由的模型出现在 Raycast AI 模型选择器中。需要 Raycast Pro。",
"integrations.semantics.omo": "仅管理 omo 的 models.json 中的 providers.opencodex,路径为 ~/.omo/agent,若设置了 OMO_CODING_AGENT_DIR、SENPI_CODING_AGENT_DIR 或 PI_CODING_AGENT_DIR 则以其为准。你的其他提供商保持不变。对新会话生效。",
+ "integrations.semantics.cline": "管理 Cline CLI 的 providers.json 和 models.json 中的 OpenCodex 项目。修改或同步前请退出 Cline,完成后重新启动。撤销会恢复两个文件的原始内容。默认提供商保持不变,请在 Cline 中选择 OpenCodex。",
"integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。文件会被写入,但在 Pro 订阅生效之前 Raycast 会忽略它。",
"integrations.raycast.planUnknown": "无法确定 Raycast Pro 是否已激活;Custom Providers 需要 Raycast Pro。",
"integrations.raycast.revealConfig": "打开 Raycast → 设置 → AI,点击一次“Reveal Providers Config”,以便创建 providers 文件夹。",
@@ -1658,6 +1660,7 @@ export const zh: Record = {
"api.clientConfig.clientAside": "Aside",
"api.clientConfig.clientRaycast": "Raycast",
"api.clientConfig.clientOmo": "omo",
+ "api.clientConfig.clientCline": "Cline CLI",
"api.clientConfig.copy": "复制配置",
"api.clientConfig.download": "下载",
"api.clientConfig.loading": "正在生成客户端配置…",
diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx
index 52e1c97733..b1f8ec3737 100644
--- a/gui/src/pages/integrations/FileIntegrationPage.tsx
+++ b/gui/src/pages/integrations/FileIntegrationPage.tsx
@@ -60,6 +60,7 @@ const SEMANTICS_KEY: Record = {
aside: "integrations.semantics.aside",
raycast: "integrations.semantics.raycast",
omo: "integrations.semantics.omo",
+ cline: "integrations.semantics.cline",
};
const TAB_LABEL_KEY: Record = {
@@ -77,6 +78,7 @@ const TAB_LABEL_KEY: Record = {
aside: "integrations.tab.aside",
raycast: "integrations.tab.raycast",
omo: "integrations.tab.omo",
+ cline: "integrations.tab.cline",
};
export default function FileIntegrationPage({
diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts
index bd72c61dfe..ad50a781fd 100644
--- a/gui/src/pages/integrations/integration-api.ts
+++ b/gui/src/pages/integrations/integration-api.ts
@@ -16,6 +16,7 @@ export const FILE_INTEGRATION_CLIENTS = [
"aside",
"raycast",
"omo",
+ "cline",
] as const;
export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number];
diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts
index b37ceab85f..a1810763dd 100644
--- a/gui/src/pages/integrations/integration-tabs.ts
+++ b/gui/src/pages/integrations/integration-tabs.ts
@@ -48,6 +48,7 @@ export const TABS: readonly TabDefinition[] = [
{ id: "aside", hash: "integrations/aside", labelKey: "integrations.tab.aside" },
{ id: "raycast", hash: "integrations/raycast", labelKey: "integrations.tab.raycast" },
{ id: "omo", hash: "integrations/omo", labelKey: "integrations.tab.omo" },
+ { id: "cline", hash: "integrations/cline", labelKey: "integrations.tab.cline" },
] as const;
export const FILE_CLIENTS = new Set([
@@ -65,4 +66,5 @@ export const FILE_CLIENTS = new Set([
"aside",
"raycast",
"omo",
+ "cline",
]);
diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts
index 2d4770f3d5..d54d99d005 100644
--- a/gui/src/pages/integrations/overview-clients.ts
+++ b/gui/src/pages/integrations/overview-clients.ts
@@ -154,6 +154,7 @@ const FILE_LABEL_KEY: Record = {
aside: "integrations.tab.aside",
raycast: "integrations.tab.raycast",
omo: "integrations.tab.omo",
+ cline: "integrations.tab.cline",
};
/** A file client's block is in the file for both `current` and `stale`. */
diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx
index c04edc1bae..08d2b7e085 100644
--- a/gui/tests/client-config-panel.test.tsx
+++ b/gui/tests/client-config-panel.test.tsx
@@ -171,7 +171,7 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut
}
test("the API download surface includes DSH, MiniMax Code, Aside, Raycast and omo as clients", () => {
- expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo"]);
+ expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline"]);
expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh");
expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode");
expect(CLIENT_LABEL_KEYS.zcode).toBe("api.clientConfig.clientZcode");
diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts
index 94e6fe7b3f..bf124af303 100644
--- a/src/cli/dispatch.ts
+++ b/src/cli/dispatch.ts
@@ -452,7 +452,7 @@ const commandRunners: Record = {
},
config,
port: live.port,
- }, ["mcode", "pi", "raycast", "omo"]));
+ }, ["mcode", "pi", "raycast", "omo", "cline"]));
} catch (error) {
console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`);
}
diff --git a/src/cli/registry.ts b/src/cli/registry.ts
index 274ea2cf7c..91eab8723b 100644
--- a/src/cli/registry.ts
+++ b/src/cli/registry.ts
@@ -337,11 +337,12 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
{ name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." },
{
name: "export",
- usage: "ocx export --client [--json] [--out ] [--force]",
- summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo) wired to the running proxy.",
+ usage: "ocx export --client [--json] [--out ] [--force]",
+ summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast, omo, Cline) wired to the running proxy.",
details: [
"--json prints the generated document as JSON on stdout; use --out for the client's native format.",
"--out writes the native config there and refuses to replace an existing file without --force.",
+ "Cline exports a two-document bundle: settings for providers.json and catalog for sibling models.json. Use integration client enable --client cline for a journaled write.",
"The config never contains a real key; it carries a documented env reference or a non-secret loopback placeholder.",
"The destination path is printed for merging by hand — ocx never writes your real client config.",
],
diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts
index dcc63490b7..0390ea8d76 100644
--- a/src/server/management/config-routes.ts
+++ b/src/server/management/config-routes.ts
@@ -237,7 +237,7 @@ export async function syncEnabledClientIntegrations(
},
config,
port,
- }, ["mcode", "pi", "aside", "raycast", "omo"]));
+ }, ["mcode", "pi", "aside", "raycast", "omo", "cline"]));
return out;
}
diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md
index 1957d058db..f3e5d5f196 100644
--- a/structure/clients/claude-desktop.md
+++ b/structure/clients/claude-desktop.md
@@ -85,3 +85,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi
Claude replay carries [Go conversation affinity](../data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch)
privately to final dispatch; preliminary route selection does not inject Go-only headers.
+
+The explicit sync coordinator also accepts Cline CLI as a separate file integration. Its [paired-file recovery](integrations.md#cline-paired-files) is owned by the generic integration journal, independently of Desktop profile snapshots.
diff --git a/structure/config.md b/structure/config.md
index 825811d9b3..2b76e1361a 100644
--- a/structure/config.md
+++ b/structure/config.md
@@ -199,3 +199,5 @@ Codex display-cache expiry, retained main-policy evidence, and reset history fol
## Paginated history writer boundary
`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits.
+
+The Cline client keeps connection settings and models in a separate native file pair; client path overrides and reversible writes follow [Cline paired files](clients/integrations.md#cline-paired-files).
diff --git a/structure/design-methodology.md b/structure/design-methodology.md
index 51a2f158bc..690b3b1287 100644
--- a/structure/design-methodology.md
+++ b/structure/design-methodology.md
@@ -39,3 +39,5 @@ surfaces, run through all 3 stages in order.
The management quota DTO keeps Combo editing aligned with scoped inference evidence;
see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota).
+
+Cline uses the existing file-integration page, tabs, status badge and rollback dialogs. Its localized semantics identify both files and the required stop/restart boundary before users mutate them.
diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md
index 27cc586f24..73090d646e 100644
--- a/structure/gui-and-management-api.md
+++ b/structure/gui-and-management-api.md
@@ -541,3 +541,5 @@ privately to final dispatch; preliminary route selection does not inject Go-only
Cline journal Undo eligibility reads both native configuration files through the paired
integration IO adapter. Its snapshot fingerprint cannot be checked against providers.json alone;
[the integration contract](clients/integrations.md#cline-paired-files) defines recovery.
+
+The existing dashboard file-client maps include Cline CLI and reuse its committed color mark. The export panel labels its download as a settings/catalog bundle; all locales explain that Undo restores both original files.
diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md
index 78d0e038ec..827540194a 100644
--- a/structure/ops/docs-and-release.md
+++ b/structure/ops/docs-and-release.md
@@ -312,3 +312,5 @@ see [Combo editor routing quota](../gui-and-management-api.md#combo-editor-routi
## Paginated history writer boundary
`src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before artifact changes and compensates detected migration. Failed config restore stops later catalog/history work. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits.
+
+The integrations guide documents Cline CLI as a two-file, loopback-only integration. Hosted CI validates its source-backed fixtures; the packaged dashboard exposes it through the existing client list.
diff --git a/structure/overview.md b/structure/overview.md
index da3f2dc473..d5d1a2207a 100644
--- a/structure/overview.md
+++ b/structure/overview.md
@@ -106,3 +106,5 @@ would pass while the rule was violated.
The management quota DTO keeps Combo editing aligned with scoped inference evidence;
see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-quota).
+
+Cline CLI is a managed file integration: its provider settings and catalog share one recoverable journal operation. The [paired-file contract](clients/integrations.md#cline-paired-files) defines its stop/restart requirement.
diff --git a/structure/runtime.md b/structure/runtime.md
index 5abf80774d..833d2fe66b 100644
--- a/structure/runtime.md
+++ b/structure/runtime.md
@@ -219,3 +219,5 @@ see [Combo editor routing quota](gui-and-management-api.md#combo-editor-routing-
Claude replay carries [Go conversation affinity](data-planes/inbound-compat.md#claude-affinity-at-final-go-dispatch)
privately to final dispatch; preliminary route selection does not inject Go-only headers.
+
+Cline CLI joins the existing export/client integration registries. Explicit CLI sync and POST /api/sync refresh its owned pair; unattended catalog refresh excludes it. See [Cline paired files](clients/integrations.md#cline-paired-files).
diff --git a/tests/clients/cline-writer.test.ts b/tests/clients/cline-writer.test.ts
index 54eaba6ae1..f70df4d99f 100644
--- a/tests/clients/cline-writer.test.ts
+++ b/tests/clients/cline-writer.test.ts
@@ -11,6 +11,7 @@ import { readIntegrationState } from "../../src/integrations/state";
import { removeTreeWithRetry } from "../helpers/remove-tree";
import type { IntegrationTransaction } from "../../src/integrations/config-io";
import type { OcxConfig } from "../../src/types";
+import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh";
let root: string;
let input: IntegrationWriteInput;
@@ -39,6 +40,17 @@ function settingsDoc() { return JSON.parse(readFileSync(settings, "utf8")); }
function catalogDoc() { return JSON.parse(readFileSync(catalog, "utf8")); }
describe("Cline journaled pair", () => {
+ test("explicit catalog sync leaves unowned clients alone and refreshes owned pairs", async () => {
+ let loads = 0;
+ const request = { ...input, models: async () => { loads += 1; return [models[0]!]; } };
+ expect(await refreshOwnedCatalogIntegrations(request, ["cline"])).toEqual([]);
+ expect(loads).toBe(0);
+ expect(existsSync(settings)).toBe(false);
+ expect(applyIntegration(input).ok).toBe(true);
+ expect(await refreshOwnedCatalogIntegrations(request, ["cline"])).toEqual([{ client: "cline", ok: true, changed: true }]);
+ expect(loads).toBe(1);
+ expect(Object.keys(catalogDoc().providers.opencodex.models)).toEqual(["mock/a"]);
+ });
test("preserves foreign providers/default, journals both originals, and restores exact bytes", () => {
seed();
const applied = applyIntegration(input);
diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts
index 3a8cd9123d..219a148082 100644
--- a/tests/clients/sync-client-integrations.test.ts
+++ b/tests/clients/sync-client-integrations.test.ts
@@ -65,7 +65,7 @@ describe("ocx sync fans out to enabled native clients and owned file integration
expect(fn).toContain("grokIntegrationEnabled(config)");
expect(fn).toContain("claudeDesktopIntegrationEnabled(config)");
- expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo"]');
+ expect(fn).toContain('["mcode", "pi", "aside", "raycast", "omo", "cline"]');
expect(fn).toContain("refreshOwnedCatalogIntegrations");
// Native clients keep their catches; the owned catalog helper isolates file clients.
expect(fn.match(/catch \(error\)/g)?.length).toBe(2);
@@ -656,7 +656,7 @@ test("the direct ocx sync command refreshes MCode, Pi, Raycast, omo and server-o
const start = src.indexOf("sync: async deps =>");
const command = src.slice(start, src.indexOf("v2: async deps =>", start));
expect(command).toContain("refreshOwnedCatalogIntegrations");
- expect(command).toContain('["mcode", "pi", "raycast", "omo"]');
+ expect(command).toContain('["mcode", "pi", "raycast", "omo", "cline"]');
expect(command).toContain("refreshAsideProfilesThroughServer");
expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations"));
expect(command).toContain('synced.status !== "refused"');
From 75a8ec8f78718f25343344506e8a5536ca0245a4 Mon Sep 17 00:00:00 2001
From: JUN
Date: Sat, 12 Sep 2026 14:11:30 +0900
Subject: [PATCH 4/6] fix: validate Cline recovery authority and explain paired
exports
---
.../260912_cline_client/030_verification.md | 4 +
.../apikeys-workspace/ClientConfigDialog.tsx | 6 +-
.../apikeys-workspace/ClientConfigPanel.tsx | 4 +-
gui/src/i18n/de.ts | 2 +
gui/src/i18n/en.ts | 2 +
gui/src/i18n/fr.ts | 2 +
gui/src/i18n/ja.ts | 2 +
gui/src/i18n/ko.ts | 2 +
gui/src/i18n/ru.ts | 2 +
gui/src/i18n/tr.ts | 2 +
gui/src/i18n/zh-TW.ts | 2 +
gui/src/i18n/zh.ts | 2 +
gui/tests/client-config-panel.test.tsx | 20 +++++
gui/tests/integrations-overview-rows.test.ts | 3 +-
src/integrations/cline-io.ts | 35 ++++-----
src/integrations/cline-transaction.ts | 42 ++++++++++
src/integrations/journal.ts | 44 ++++++++++-
src/integrations/ownership.ts | 36 +++++++++
src/integrations/store.ts | 6 ++
structure/clients/integrations.md | 4 +
tests/clients/cline-writer.test.ts | 77 +++++++++++++++++++
tests/gui/integrations-invariants.test.ts | 2 +-
22 files changed, 274 insertions(+), 27 deletions(-)
create mode 100644 src/integrations/cline-transaction.ts
diff --git a/devlog/_plan/260912_cline_client/030_verification.md b/devlog/_plan/260912_cline_client/030_verification.md
index e05266d7a3..30abc74225 100644
--- a/devlog/_plan/260912_cline_client/030_verification.md
+++ b/devlog/_plan/260912_cline_client/030_verification.md
@@ -7,3 +7,7 @@ MODIFY only files implicated by concrete findings. git diff --check is whitespac
Read current head/base/reviews and CI workflow event/ref. Track final cumulative SHA through hosted Cross-platform CI; record run URL/ID, actual head and check results. Do not cancel runs or modify workflow/protection. Fix attributable final failures and repeat at the resulting head. No suite can be hidden in a receipt helper; receipt can capture permitted source checks or read-only GitHub CI verification, labeled accurately.
MODIFY .tmp/cline/handoff.md immediately after each deliverable: worktree/branch, PABCD cycle/phase, PR URL/full head SHA/order, issue disposition, remaining acceptance, attribution, hosted run evidence, NOT RUN local checks and unresolved reviewer findings. Final completion requires all recorded criteria; CI pending is not success. Parent performs all merges and decides issue closure.
+
+Verification P resumes 029's next direction. Concrete private audit repair plan is .tmp/cline/repair-plan.md; final source proof must include strict recovery metadata and history reads. Two accepted independent findings are being corrected before publication. This cycle retains all original CI, source, rollback and handoff criteria.
+
+Source review also found the shared export dialog described every download as a single native file. The Cline branch now uses localized two-document merge instructions and download announcement, and suppresses the irrelevant missing-key hint. Other clients retain their existing copy. A rendered component regression in client-config-panel.test.tsx covers this conditional branch on hosted CI. This is a correctness fix to the existing export surface, not a new export mechanism.
diff --git a/gui/src/components/apikeys-workspace/ClientConfigDialog.tsx b/gui/src/components/apikeys-workspace/ClientConfigDialog.tsx
index 0d315825f1..ba1ea3d1cb 100644
--- a/gui/src/components/apikeys-workspace/ClientConfigDialog.tsx
+++ b/gui/src/components/apikeys-workspace/ClientConfigDialog.tsx
@@ -78,7 +78,7 @@ export default function ClientConfigDialog({
{t("api.clientConfig.missingLimits", { count: envelope.modelsWithoutLimits, total: envelope.modelCount })}
)}
- {!hasKeys && (
+ {!hasKeys && client !== "cline" && (
// Informational, never blocking: an agent may legitimately want the shape
// first, so both actions stay enabled.
@@ -94,11 +94,11 @@ export default function ClientConfigDialog({
{t("api.clientConfig.envHint")}
-
{t("api.clientConfig.mergeWarning")}
+ {t(client === "cline" ? "api.clientConfig.clineBundle" : "api.clientConfig.mergeWarning")}
{/* The old is gone — this is the one place the answer lives now,
so it is a labelled paragraph rather than a fold. */}
{t("api.clientConfig.whereDisclosure")}
- {t("api.clientConfig.whereBody")}
+ {t(client === "cline" ? "integrations.semantics.cline" : "api.clientConfig.whereBody")}