Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions apps/desktop/electron/main/importers/agent-mcp-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type McpSourceKind =
| "cursor-project"
| "codex"
| "opencode"
| "pi"
| "chatgpt-desktop";

export interface McpCandidate {
Expand Down Expand Up @@ -536,26 +537,37 @@ async function scanChatgptDesktop(): Promise<{
};
}

async function scanPi(
home: string,
): Promise<{ report: McpSourceReport; candidates: McpCandidate[] }> {
return scanJsonMcpServers("pi", path.join(home, ".pi", "agent", "mcp.json"), (root) =>
isRecord(root.mcpServers) ? root.mcpServers : null,
);
}

export async function scanExternalMcp(opts: McpScanOptions = {}): Promise<McpScanResult> {
const home = opts.homeDir ?? os.homedir();
const platform = opts.platform ?? process.platform;
const env = opts.env ?? process.env;

const [claudeDesktop, claudeCode, cursorGlobal, opencode, codex, chatgpt] = await Promise.all([
scanClaudeDesktop(home, platform, env),
scanClaudeCode(home),
scanCursorGlobal(home),
scanOpencode(home, env),
scanCodex(home),
scanChatgptDesktop(),
]);
const [claudeDesktop, claudeCode, cursorGlobal, opencode, codex, pi, chatgpt] =
await Promise.all([
scanClaudeDesktop(home, platform, env),
scanClaudeCode(home),
scanCursorGlobal(home),
scanOpencode(home, env),
scanCodex(home),
scanPi(home),
scanChatgptDesktop(),
]);

const parts: Array<{ reports: McpSourceReport[]; candidates: McpCandidate[] }> = [
{ reports: [claudeDesktop.report], candidates: claudeDesktop.candidates },
{ reports: claudeCode.reports, candidates: claudeCode.candidates },
{ reports: [cursorGlobal.report], candidates: cursorGlobal.candidates },
{ reports: [opencode.report], candidates: opencode.candidates },
{ reports: [codex.report], candidates: codex.candidates },
{ reports: [pi.report], candidates: pi.candidates },
{ reports: [chatgpt.report], candidates: chatgpt.candidates },
];

Expand Down
34 changes: 30 additions & 4 deletions apps/desktop/electron/main/importers/model-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
parseCodexModelConfig,
parseJsonDocument,
parseOpenCodeModelConfig,
parsePiAuthModelConfig,
parsePiModelConfig,
type CcSwitchProviderRow,
type ModelConfigImportDraft,
Expand Down Expand Up @@ -85,10 +86,35 @@ async function scanPi(
home: string,
env: ModelConfigImportEnv,
): Promise<ModelConfigImportDraft[]> {
const models =
(await readJson(path.join(home, ".pi", "agent", "models.json"))) ??
(await readJson(path.join(home, ".pi", "models.json")));
return parsePiModelConfig(models, env);
const agentDir = path.join(home, ".pi", "agent");
const fallbackDir = path.join(home, ".pi");
const [models, auth, modelsStore] = await Promise.all([
readJson(path.join(agentDir, "models.json")) ??
readJson(path.join(fallbackDir, "models.json")),
readJson(path.join(agentDir, "auth.json")) ??
readJson(path.join(fallbackDir, "auth.json")),
// Pi caches the resolved catalog (including built-in providers) beside
// models.json. It is the only place an auth-only provider's base URL and
// model limits are available.
readJson(path.join(agentDir, "models-store.json")) ??
readJson(path.join(fallbackDir, "models-store.json")),
]);
const drafts = parsePiModelConfig(models, env);
const authConfig = parsePiAuthModelConfig(auth, modelsStore, env);
const byProvider = new Map(drafts.map((draft) => [draft.externalId, draft]));
for (const draft of authConfig.drafts) {
// models.json stays authoritative when it already describes the provider.
if (byProvider.has(draft.externalId)) continue;
drafts.push(draft);
byProvider.set(draft.externalId, draft);
}
return drafts.map((draft) => {
if (draft.hasSecret) return draft;
const secret = authConfig.apiKeys[draft.externalId];
// A models.json `env:`/`$VAR` reference can resolve to nothing in the
// Desktop process; auth.json still holds the key Pi actually uses.
return secret ? { ...draft, secretValue: secret, hasSecret: true } : draft;
});
}

async function scanCcSwitch(
Expand Down
68 changes: 68 additions & 0 deletions apps/desktop/electron/test/agent-mcp-scan.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,74 @@ test("chatgpt-desktop always reports as not detected until a stable path exists"
}
});

test("pi agent mcp.json is scanned from the user-level agent directory", async () => {
const { root, home } = await makeHome();
try {
const cfg = join(home, ".pi", "agent", "mcp.json");
await mkdir(dirname(cfg), { recursive: true });
await writeFile(
cfg,
JSON.stringify({
mcpServers: {
serena: {
command: "uvx",
args: ["--from", "git+https://github.com/oraios/serena", "serena"],
env: { SERENA_HOME: "/tmp/serena" },
description: "Code intelligence",
},
docs: {
url: "https://mcp.example.com/mcp",
headers: { Authorization: "Bearer literal" },
},
paused: { command: "node", enabled: false },
},
}),
);

const result = await scanExternalMcp({ homeDir: home, platform: "linux", env: {} });
const found = result.candidates.filter((candidate) => candidate.source === "pi");
assert.equal(found.length, 3);

const serena = found.find((candidate) => candidate.id === "serena");
assert.equal(serena.transport, "stdio");
assert.equal(serena.command, "uvx");
assert.deepEqual(serena.args, ["--from", "git+https://github.com/oraios/serena", "serena"]);
assert.deepEqual(serena.env, { SERENA_HOME: "/tmp/serena" });
assert.equal(serena.description, "Code intelligence");

const docs = found.find((candidate) => candidate.id === "docs");
assert.equal(docs.transport, "http");
assert.equal(docs.url, "https://mcp.example.com/mcp");
assert.deepEqual(docs.headers, { Authorization: "Bearer literal" });

const paused = found.find((candidate) => candidate.id === "paused");
assert.equal(paused.disabled, true);

const source = result.sources.find((item) => item.kind === "pi");
assert.equal(source.exists, true);
assert.equal(source.count, 3);
assert.ok(source.path.endsWith("home/.pi/agent/mcp.json"));
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("a broken pi agent mcp.json surfaces on its source row", async () => {
const { root, home } = await makeHome();
try {
const cfg = join(home, ".pi", "agent", "mcp.json");
await mkdir(dirname(cfg), { recursive: true });
await writeFile(cfg, "{ not json");
const result = await scanExternalMcp({ homeDir: home, platform: "linux", env: {} });
const source = result.sources.find((item) => item.kind === "pi");
assert.equal(source.exists, true);
assert.match(source.error, /invalid json/);
assert.equal(result.candidates.filter((candidate) => candidate.source === "pi").length, 0);
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("parseCodexMcpToml ignores unrelated sections and comments", () => {
const parsed = parseCodexMcpToml(
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const MCP_SOURCE_KEY: Record<ExternalMcpSourceKind, string> = {
"cursor-project": "settings.importAgentScanSourceCursorProject",
codex: "settings.importAgentScanSourceCodex",
opencode: "settings.importAgentScanSourceOpenCode",
pi: "settings.importAgentScanSourcePi",
"chatgpt-desktop": "settings.importAgentScanSourceChatgpt",
};

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export type ExternalMcpSourceKind =
| "cursor-project"
| "codex"
| "opencode"
| "pi"
| "chatgpt-desktop";

export interface ExternalMcpCandidate {
Expand Down
81 changes: 81 additions & 0 deletions apps/desktop/test/model-config-import.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs")));
const { scanModelConfigs } = await import("../electron/main/importers/model-config.ts");
const { publicModelConfigCandidate } = await import("@pi-desktop/shared");

test("scanModelConfigs reads Claude Code, OpenCode, Codex, and Pi stores", async () => {
const home = await mkdtemp(join(tmpdir(), "pi-model-import-"));
Expand Down Expand Up @@ -222,3 +223,83 @@ test("scanModelConfigs keeps the pi native config authoritative when a cc-switch
assert.equal(piDrafts[0].modelIds.length, 10);
assert.ok(piDrafts[0].modelIds.includes("m10"));
});

// Regression: Settings → Models only read models.json, so providers connected
// through `pi /login` (auth.json only) never appeared in the Pi group. The scan
// now pairs auth.json API keys with Pi's cached catalog and fills keys that
// models.json references through an unresolved environment variable.
test("scanModelConfigs imports Pi auth-only providers and fills unresolved model keys", async () => {
const home = await mkdtemp(join(tmpdir(), "pi-agent-auth-import-"));
const agentDir = join(home, ".pi", "agent");
await mkdir(agentDir, { recursive: true });

await writeFile(
join(agentDir, "models.json"),
JSON.stringify({
providers: {
custom: {
baseUrl: "https://custom.example/v1",
api: "openai-completions",
apiKey: "env:CUSTOM_KEY",
models: [{ id: "custom-model" }],
},
},
}),
);
await writeFile(
join(agentDir, "auth.json"),
JSON.stringify({
custom: { type: "api_key", key: "sk-custom-auth" },
deepseek: { type: "api_key", key: "sk-deepseek" },
command: { type: "api_key", key: "!security find-generic-password" },
codex: { type: "oauth", access: "oauth-access" },
}),
);
await writeFile(
join(agentDir, "models-store.json"),
JSON.stringify({
deepseek: {
models: [
{
id: "deepseek-flash",
api: "openai-completions",
baseUrl: "https://api.deepseek.com",
reasoning: true,
contextWindow: 1000000,
maxTokens: 384000,
},
],
},
codex: {
models: [
{
id: "gpt-6-sol",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
},
],
},
}),
);

const drafts = await scanModelConfigs({ homeDir: home, env: {} });
const piDrafts = drafts.filter((draft) => draft.source === "pi");
assert.deepEqual(
piDrafts.map((draft) => draft.externalId).sort(),
["custom", "deepseek"],
);
assert.equal(piDrafts.find((draft) => draft.externalId === "custom")?.secretValue, "sk-custom-auth");
assert.equal(piDrafts.find((draft) => draft.externalId === "deepseek")?.secretValue, "sk-deepseek");
assert.ok(!piDrafts.some((draft) => draft.externalId === "codex"));
assert.ok(!piDrafts.some((draft) => draft.externalId === "command"));
assert.ok(
!JSON.stringify(piDrafts.map((draft) => publicModelConfigCandidate(draft))).includes(
"sk-custom-auth",
),
);
assert.ok(
!JSON.stringify(piDrafts.map((draft) => publicModelConfigCandidate(draft))).includes(
"sk-deepseek",
),
);
});
27 changes: 22 additions & 5 deletions docs/adr/0179-import-model-configuration.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# ADR 0179: Import model configuration from local agent stores

- Status: Accepted for model import semantics; original Settings placement
superseded by ADR 0319 / D645
superseded by ADR 0319 / D645 (amended 2026-10-18: Pi auth.json and cached
catalog)
- Date: 2026-09-08
- Deciders: PI-Desktop core
- Related: D007, D342, ADR 0012,
Expand Down Expand Up @@ -41,7 +42,12 @@ credentials. Copying refresh tokens would be the wrong security boundary.
- Codex: `~/.codex/config.toml` `[model_providers.<id>]`
- OpenCode: `~/.config/opencode/opencode.json` `provider` map plus
`~/.local/share/opencode/auth.json` API keys
- Pi: `~/.pi/agent/models.json` (fallback `~/.pi/models.json`)
- Pi: `~/.pi/agent/models.json` (fallback `~/.pi/models.json`).
Providers connected through Pi's `~/.pi/agent/auth.json` API keys (fallback
`~/.pi/auth.json`) are paired with Pi's cached
`~/.pi/agent/models-store.json` (fallback `~/.pi/models-store.json`) so an
auth-only provider still gets its endpoint, model list, and limits. When
both files describe one provider, `models.json` stays authoritative.
- CC Switch: `~/.cc-switch/cc-switch.db` `providers` table (legacy
`config.json`). Each row's `settings_config` is converted by app type.
Empty official seeds and OAuth-only rows are omitted. A live Claude /
Expand All @@ -58,9 +64,11 @@ credentials. Copying refresh tokens would be the wrong security boundary.
`Authorization: Bearer` header is copied into the host secret store.
Placeholder values (`YOUR_API_KEY`, `${VAR}`) are treated as missing.
A pi `apiKey` or auth header written as a `!<shell command>` is never
executed or stored; that provider is imported without a secret.
OAuth auth.json entries and Codex `requires_openai_auth` tables without
a key are omitted or imported without a secret.
executed or stored; that provider is imported without a secret. Pi
`auth.json` entries of type `oauth` are skipped entirely; plain
`type: "api_key"` entries are copied. An unresolved `env:` / `$VAR`
reference in `models.json` is filled from the matching `auth.json` key
before import.

5. **Idempotence.** A candidate whose normalized base URL, API style, and
credential match an existing provider is skipped. Different credentials
Expand All @@ -87,3 +95,12 @@ credentials. Copying refresh tokens would be the wrong security boundary.
row. Skip is safer; the user can edit the Models page.
- Renderer-side `providers.create` with the key in the IPC payload: the
scan cache already exists for sessions; keep secrets off the renderer.

## Amendment — 2026-10-18: discover Pi auth-only providers

Settings → Models only scanned `models.json`, so providers connected through
`pi /login` (for example DeepSeek or a Z.AI coding plan) never appeared in the
Pi group. The explicit scan now reads `auth.json` API keys and pairs them with
the catalog Pi already cached in `models-store.json`. OAuth/subscription grants
still stay in Pi, `!command` values are never executed or stored, and a
`models.json` provider remains authoritative when both files describe it.
3 changes: 2 additions & 1 deletion docs/spec/03-runtime/01-ipc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -1589,7 +1589,8 @@ Desktop-only channels scan configuration written by other agent tools on the
same machine — Claude Desktop (`claude_desktop_config.json` on macOS, Windows
and Linux), Claude Code (`~/.claude.json` and `~/.claude/settings.json` merged),
Cursor global and per-project `mcp.json`, Codex (`~/.codex/config.toml`
`[mcp_servers.*]`), opencode (`~/.config/opencode/opencode.json` `mcp` map) —
`[mcp_servers.*]`), opencode (`~/.config/opencode/opencode.json` `mcp` map),
Pi (`~/.pi/agent/mcp.json` `mcpServers`) —
so the user can review and batch-import into this app's MCP list. ChatGPT
desktop is listed as a placeholder because it has no public configuration path
yet.
Expand Down
13 changes: 8 additions & 5 deletions docs/spec/03-runtime/11-provider-model-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,11 +577,14 @@ type ModelDescriptor = {
and keep dialog actions outside the independently scrollable content
- do not expose raw catalog compatibility internals or provider secrets
- Settings → Models → Providers can scan and copy provider/model rows from
Claude Code, Codex, OpenCode, Pi, and CC Switch. The inline scan is explicit.
Stored API keys are copied into the host secret store; OAuth/subscription
grants are not. An equivalent provider (normalized URL + API style + same
credential) is skipped on re-import. Different credentials at one endpoint
remain independent providers. No protocol or schema version bump
Claude Code, Codex, OpenCode, Pi, and CC Switch. The Pi scan also reads
`auth.json` API keys and Pi's cached `models-store.json` catalog, so
providers connected through `pi /login` are listed even when `models.json`
has no block for them. The inline scan is explicit. Stored API keys are
copied into the host secret store; OAuth/subscription grants are not. An
equivalent provider (normalized URL + API style + same credential) is
skipped on re-import. Different credentials at one endpoint remain
independent providers. No protocol or schema version bump
(D342 / ADR 0179 / ADR 0188 / D645).

### Model selector
Expand Down
Loading