pi-ui-extend 0.1.72 → 0.1.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,7 @@ npx pi-ui-extend install
22
22
  npx pi-ui-extend --cwd .
23
23
  ```
24
24
 
25
- The setup command checks the icon font, the `pi` CLI, and clipboard support. The second command opens the current project in Pix without requiring a global Pix install.
25
+ The setup command checks runtime helpers, creates non-secret Pix and tools configuration templates when missing, and prints a credential-aware checklist. It never imports credentials. The second command opens the current project in Pix without requiring a global Pix install.
26
26
 
27
27
  > Already installed globally? Run `pix --cwd .`.
28
28
 
@@ -152,6 +152,19 @@ npx pi-ui-extend install
152
152
  npx pi-ui-extend --cwd /path/to/project
153
153
  ```
154
154
 
155
+ ### First-run checklist
156
+
157
+ 1. Run `npx pi-ui-extend install`. It preserves existing files and creates these non-secret templates when missing:
158
+ - `~/.config/pi/pix.jsonc`
159
+ - `~/.config/pi/pi-tools-suite.jsonc`
160
+ 2. Configure at least one model provider:
161
+ - With an existing OpenCode setup, start Pix and run `/opencode-import`; review [OpenCode migration](#opencode-migration) before using `--force`.
162
+ - Otherwise run `npx @earendil-works/pi-coding-agent`, use `/login` in the stock Pi TUI, then exit. Environment-based provider API keys also work.
163
+ 3. Start Pix, use `/model` to select an available model, and follow any startup authentication diagnostic.
164
+ 4. Configure only the optional integrations you need; none are required for normal model conversations.
165
+
166
+ The installer installs JetBrainsMono Nerd Font when it is missing. Pix also checks it on startup and starts a background installation when needed so the icon UI remains readable even when setup was skipped. Installation failures are reported while Pix keeps its fallback icons. Use `--check` to inspect the machine without creating files, installing helpers, or changing credentials.
167
+
155
168
  ### Global install
156
169
 
157
170
  ```bash
@@ -190,8 +203,37 @@ pix --cwd . --no-session
190
203
 
191
204
  Pix uses Pi's model and authentication stores. Environment-based API keys and credentials already configured for Pi are available to Pix.
192
205
 
193
- - Run the stock `pi` TUI when you need its provider login/logout dialogs, then use `/reload` in Pix.
194
- - Run `/opencode-import` to import supported credentials from OpenCode.
206
+ | Credential or integration | Required? | Setup | Notes |
207
+ | --- | --- | --- | --- |
208
+ | Model provider | **Yes**, unless the selected model needs no credentials | Run the stock Pi TUI with `npx @earendil-works/pi-coding-agent`, use `/login`, then `/reload` in Pix; or set a provider-supported environment key | Pix does not yet implement Pi's interactive `/login` and `/logout` dialogs. |
209
+ | Existing OpenCode accounts | No; migration shortcut | Run `/opencode-import` in Pix | Imports only the credential types listed below and preserves existing Pi entries by default. |
210
+ | Model-backed helpers | No | Review `promptEnhancer`, `autocomplete`, and `sessionTitle` in `pix.jsonc` | Each configured helper model needs credentials for its own provider; it need not use the main session provider. |
211
+ | Ollama/Tavily web access | No | Use local Ollama without a cloud key, set `OLLAMA_API_KEY`/`TAVILY_API_KEY`, or run `/web-credentials` | Stored keys live in `~/.config/pi/pi-tools-suite-credentials.json` with mode `0600`. |
212
+ | Context7 documentation skill | No | Export `CONTEXT7_API_KEY` | The skill fails before making a network request when the variable is absent. |
213
+ | Telegram terminal bell | No | Configure `terminalBell.telegram` or `PI_TERMINAL_BELL_TELEGRAM_BOT_TOKEN` plus `PI_TERMINAL_BELL_TELEGRAM_CHAT_ID` | Used only for the optional terminal notification integration. |
214
+
215
+ `pix install` reports whether these sources appear configured, but never displays, copies, or overwrites token values.
216
+
217
+ ### OpenCode migration
218
+
219
+ Run this inside Pix after the bundled tools suite loads:
220
+
221
+ ```text
222
+ /opencode-import
223
+ ```
224
+
225
+ Supported mappings are deliberately narrow:
226
+
227
+ - OpenAI OAuth → Pi's `openai-codex` provider
228
+ - OpenAI API key → Pi's `openai` provider
229
+ - GitHub Copilot OAuth → `github-copilot`
230
+ - Z.ai/Zhipu-compatible credentials → `zai`
231
+ - an OpenCode Antigravity account → Pi's Antigravity provider
232
+
233
+ By default the command reads `$OPENCODE_AUTH_CONTENT`, `$OPENCODE_DATA_DIR/auth.json`, or the XDG/default OpenCode data path, plus the corresponding OpenCode Antigravity account file. It writes to Pi's active agent directory (`$PI_CODING_AGENT_DIR` when set), preserves existing provider entries, and reloads Pix only after a successful write. Use `/opencode-import --force` only when you intentionally want supported existing entries replaced. Explicit source and destination paths are available through `/opencode-import --path <file>`, `--auth-path <file>`, and `--antigravity-path <file>`.
234
+
235
+ OpenCode model/provider definitions, default-model choices, MCP servers, plugins, instructions, and tool settings are **not** migrated because their formats and security assumptions do not map safely. Recreate those settings in Pix/Pi and `pi-tools-suite.jsonc` as needed. No secret is imported during `pix install` itself.
236
+
195
237
  - Run `/antigravity-add-account` to add an Antigravity OAuth account, then use `/antigravity-account` and `/antigravity-status` to manage it.
196
238
  - Use `/model`, `/scoped-models`, and `/thinking` to control the active runtime.
197
239
  - Use `/default-model`, `/default-thinking`, and `/autocomplete` to change defaults for new sessions.
@@ -1,9 +1,12 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { installJetBrainsNerdFont, isJetBrainsNerdFontInstalled } from "../terminal/nerd-font-controller.js";
4
4
  import { clipboardInstallHint, clipboardSupportAvailable } from "../screen/clipboard.js";
5
5
  type PixInstallTestDeps = {
6
6
  existsSync: typeof existsSync;
7
+ readFileSync: typeof readFileSync;
8
+ mkdirSync: typeof mkdirSync;
9
+ writeFileSync: typeof writeFileSync;
7
10
  spawn: typeof spawn;
8
11
  isJetBrainsNerdFontInstalled: typeof isJetBrainsNerdFontInstalled;
9
12
  installJetBrainsNerdFont: typeof installJetBrainsNerdFont;
@@ -19,7 +22,22 @@ export type PixInstallCliContext = {
19
22
  env?: NodeJS.ProcessEnv;
20
23
  homeDir?: string;
21
24
  };
22
- export declare function formatPixInstallNextSteps(homeDir?: string): string;
25
+ export type PixOnboardingState = {
26
+ pixConfigExists: boolean;
27
+ toolsConfigExists: boolean;
28
+ providerAuthConfigured: boolean;
29
+ opencodeAuthExists: boolean;
30
+ opencodeAntigravityExists: boolean;
31
+ webCredentialsConfigured: boolean;
32
+ context7Configured: boolean;
33
+ telegramConfigured: boolean;
34
+ };
35
+ export declare function inspectPixOnboarding(homeDir?: string, env?: NodeJS.ProcessEnv): PixOnboardingState;
36
+ export declare function ensurePixSetupFiles(homeDir?: string): {
37
+ created: string[];
38
+ existing: string[];
39
+ };
40
+ export declare function formatPixInstallNextSteps(homeDir?: string, state?: PixOnboardingState): string;
23
41
  export declare function pixInstallUsage(): string;
24
42
  export declare function parsePixInstallArgs(argv: readonly string[]): PixInstallCliOptions;
25
43
  export declare function runPixInstallCli(argv?: readonly string[], context?: PixInstallCliContext): Promise<number>;
@@ -1,12 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { join } from "node:path";
4
+ import { dirname, join } from "node:path";
5
5
  import { FONT_FAMILY_NAME, installJetBrainsNerdFont, isJetBrainsNerdFontInstalled, } from "../terminal/nerd-font-controller.js";
6
6
  import { clipboardInstallHint, clipboardSupportAvailable } from "../screen/clipboard.js";
7
7
  import { getPixConfigPath } from "../../config.js";
8
+ import { DEFAULT_PIX_CONFIG_JSONC } from "../../default-pix-config.js";
8
9
  const defaultPixInstallDeps = {
9
10
  existsSync,
11
+ readFileSync,
12
+ mkdirSync,
13
+ writeFileSync,
10
14
  spawn,
11
15
  isJetBrainsNerdFontInstalled,
12
16
  installJetBrainsNerdFont,
@@ -17,24 +21,114 @@ let pixInstallDeps = defaultPixInstallDeps;
17
21
  export function setPixInstallTestDeps(overrides) {
18
22
  pixInstallDeps = overrides ? { ...defaultPixInstallDeps, ...overrides } : defaultPixInstallDeps;
19
23
  }
20
- export function formatPixInstallNextSteps(homeDir = homedir()) {
24
+ const PROVIDER_API_KEY_ENV_NAMES = [
25
+ "ANTHROPIC_API_KEY",
26
+ "AZURE_OPENAI_API_KEY",
27
+ "GEMINI_API_KEY",
28
+ "GOOGLE_API_KEY",
29
+ "GROQ_API_KEY",
30
+ "MISTRAL_API_KEY",
31
+ "OPENAI_API_KEY",
32
+ "OPENROUTER_API_KEY",
33
+ "XAI_API_KEY",
34
+ "ZAI_API_KEY",
35
+ ];
36
+ const DEFAULT_TOOLS_CONFIG_JSONC = `{
37
+ "$schema": "https://unpkg.com/pi-ui-extend/schemas/pi-tools-suite.json",
38
+ "todoThinking": true,
39
+ "disabledModules": []
40
+ }
41
+ `;
42
+ export function inspectPixOnboarding(homeDir = homedir(), env = process.env) {
43
+ const pixConfigPath = getPixConfigPath(homeDir);
44
+ const toolsConfigPath = join(homeDir, ".config", "pi", "pi-tools-suite.jsonc");
45
+ const agentDir = env.PI_CODING_AGENT_DIR?.trim() || join(homeDir, ".pi", "agent");
46
+ const piAuthPath = join(agentDir, "auth.json");
47
+ const opencodeDataDir = env.OPENCODE_DATA_DIR?.trim()
48
+ || join(env.XDG_DATA_HOME?.trim() || join(homeDir, ".local", "share"), "opencode");
49
+ const opencodeConfigDir = env.OPENCODE_CONFIG_DIR?.trim()
50
+ || join(env.XDG_CONFIG_HOME?.trim() || join(homeDir, ".config"), "opencode");
51
+ const webCredentialPath = env.PI_TOOLS_SUITE_WEB_CREDENTIALS_PATH?.trim()
52
+ || join(homeDir, ".config", "pi", "pi-tools-suite-credentials.json");
53
+ const providerEnvConfigured = PROVIDER_API_KEY_ENV_NAMES.some((name) => Boolean(env[name]?.trim()));
54
+ return {
55
+ pixConfigExists: pixInstallDeps.existsSync(pixConfigPath),
56
+ toolsConfigExists: pixInstallDeps.existsSync(toolsConfigPath),
57
+ providerAuthConfigured: providerEnvConfigured || jsonObjectHasEntries(piAuthPath),
58
+ opencodeAuthExists: Boolean(env.OPENCODE_AUTH_CONTENT?.trim()) || pixInstallDeps.existsSync(join(opencodeDataDir, "auth.json")),
59
+ opencodeAntigravityExists: pixInstallDeps.existsSync(join(opencodeConfigDir, "antigravity-accounts.json")),
60
+ webCredentialsConfigured: Boolean(env.OLLAMA_API_KEY?.trim() || env.TAVILY_API_KEY?.trim()) || jsonObjectHasEntries(webCredentialPath),
61
+ context7Configured: Boolean(env.CONTEXT7_API_KEY?.trim()),
62
+ telegramConfigured: Boolean(env.PI_TERMINAL_BELL_TELEGRAM_BOT_TOKEN?.trim() && env.PI_TERMINAL_BELL_TELEGRAM_CHAT_ID?.trim()),
63
+ };
64
+ }
65
+ export function ensurePixSetupFiles(homeDir = homedir()) {
66
+ const files = [
67
+ { path: getPixConfigPath(homeDir), content: DEFAULT_PIX_CONFIG_JSONC },
68
+ { path: join(homeDir, ".config", "pi", "pi-tools-suite.jsonc"), content: DEFAULT_TOOLS_CONFIG_JSONC },
69
+ ];
70
+ const created = [];
71
+ const existing = [];
72
+ for (const file of files) {
73
+ if (pixInstallDeps.existsSync(file.path)) {
74
+ existing.push(file.path);
75
+ continue;
76
+ }
77
+ pixInstallDeps.mkdirSync(dirname(file.path), { recursive: true });
78
+ try {
79
+ pixInstallDeps.writeFileSync(file.path, file.content, { encoding: "utf8", flag: "wx" });
80
+ created.push(file.path);
81
+ }
82
+ catch (error) {
83
+ if (error.code !== "EEXIST")
84
+ throw error;
85
+ existing.push(file.path);
86
+ }
87
+ }
88
+ return { created, existing };
89
+ }
90
+ export function formatPixInstallNextSteps(homeDir = homedir(), state = inspectPixOnboarding(homeDir)) {
21
91
  const pixConfigPath = getPixConfigPath(homeDir);
22
92
  const toolsConfigPath = join(homeDir, ".config", "pi", "pi-tools-suite.jsonc");
93
+ const opencodeDetected = state.opencodeAuthExists || state.opencodeAntigravityExists;
94
+ const providerStep = formatProviderSetupStep(state.providerAuthConfigured, opencodeDetected);
23
95
  return [
24
96
  "",
25
- "Next steps:",
26
- ` 1. Edit ${pixConfigPath} and set dictation.language / dictation.languages for voice input.`,
27
- ` 2. Edit ${toolsConfigPath} and enable the LSP servers you use under lsp.servers.`,
28
- " 3. Start pix, then run /opencode-import to import opencode accounts.",
29
- " For Antigravity accounts, run /antigravity-import or /antigravity-add-account.",
97
+ "First-run setup:",
98
+ providerStep,
99
+ ...(opencodeDetected ? [
100
+ " /opencode-import safely preserves existing Pi credentials unless --force is used.",
101
+ " Supported migration: OpenAI/Codex, GitHub Copilot, Z.ai, and Antigravity credentials; OpenCode models, MCP, plugins, and tool settings are reported as manual work.",
102
+ ] : []),
103
+ ` ${state.pixConfigExists ? "✓" : "→"} Pix config: ${pixConfigPath}`,
104
+ ` ${state.toolsConfigExists ? "✓" : "→"} Tools config: ${toolsConfigPath}`,
105
+ "",
106
+ "Optional integrations:",
107
+ ` ${state.webCredentialsConfigured ? "✓ Web credentials detected." : "○ Web search: use local Ollama without a key, or run /web-credentials for Ollama Cloud/Tavily."}`,
108
+ ` ${state.context7Configured ? "✓ Context7 API key detected." : "○ Context7 docs skill: export CONTEXT7_API_KEY."}`,
109
+ ` ${state.telegramConfigured ? "✓ Telegram terminal-bell credentials detected in the environment." : "○ Telegram bell: set terminalBell.telegram in Pix config or PI_TERMINAL_BELL_TELEGRAM_* env vars."}`,
110
+ " ○ Model-backed helpers: review promptEnhancer, autocomplete, and sessionTitle model refs; each provider needs its own credentials.",
111
+ " ○ Voice: set dictation.language in Pix config. LSP: configure and trust servers under lsp.servers in tools config.",
112
+ "",
113
+ "Pix never imports or overwrites credentials during install. Optional tokens remain opt-in.",
30
114
  ].join("\n");
31
115
  }
116
+ function formatProviderSetupStep(providerAuthConfigured, opencodeDetected) {
117
+ if (providerAuthConfigured)
118
+ return " ✓ Model provider credentials detected. Start Pix and use /model to choose a model.";
119
+ if (opencodeDetected)
120
+ return " → OpenCode credentials detected. Start Pix, run /opencode-import, then /model.";
121
+ return " → Configure a model provider first: run `npx @earendil-works/pi-coding-agent`, use /login, then start Pix and use /model.";
122
+ }
32
123
  export function pixInstallUsage() {
33
124
  return `Usage: pix install [--check]
34
125
  pix setup [--check]
35
126
 
36
127
  Check and install Pix runtime helpers for this user.
37
128
 
129
+ It also creates non-secret Pix and pi-tools-suite config templates and prints a
130
+ credential-aware first-run checklist. It never imports credentials automatically.
131
+
38
132
  What it checks:
39
133
  - ${FONT_FAMILY_NAME} icon font for Pix glyphs
40
134
  - pi CLI availability, including Pix's bundled Pi dependency
@@ -81,7 +175,6 @@ export async function runPixInstallCli(argv = process.argv.slice(2), context = {
81
175
  }
82
176
  else if (options.checkOnly) {
83
177
  console.log(`! ${FONT_FAMILY_NAME} is missing`);
84
- failures += 1;
85
178
  }
86
179
  else {
87
180
  try {
@@ -89,8 +182,7 @@ export async function runPixInstallCli(argv = process.argv.slice(2), context = {
89
182
  console.log(`✓ Installed ${FONT_FAMILY_NAME}`);
90
183
  }
91
184
  catch (error) {
92
- console.error(`✗ Failed to install ${FONT_FAMILY_NAME}: ${errorMessage(error)}`);
93
- failures += 1;
185
+ console.warn(`! Failed to install optional ${FONT_FAMILY_NAME}: ${errorMessage(error)}`);
94
186
  }
95
187
  }
96
188
  const piCli = await resolvePiCliStatus(env);
@@ -117,12 +209,35 @@ export async function runPixInstallCli(argv = process.argv.slice(2), context = {
117
209
  }
118
210
  else {
119
211
  console.log(`! Clipboard support is missing. ${pixInstallDeps.clipboardInstallHint()}`);
120
- if (process.platform === "linux")
212
+ }
213
+ const homeDir = context.homeDir ?? homedir();
214
+ if (!options.checkOnly) {
215
+ try {
216
+ const setupFiles = ensurePixSetupFiles(homeDir);
217
+ for (const path of setupFiles.created)
218
+ console.log(`✓ Created ${path}`);
219
+ for (const path of setupFiles.existing)
220
+ console.log(`✓ Kept existing ${path}`);
221
+ }
222
+ catch (error) {
223
+ console.error(`✗ Failed to create setup config: ${errorMessage(error)}`);
121
224
  failures += 1;
225
+ }
122
226
  }
123
- console.log(formatPixInstallNextSteps(context.homeDir));
227
+ console.log(formatPixInstallNextSteps(homeDir, inspectPixOnboarding(homeDir, env)));
124
228
  return failures === 0 ? 0 : 1;
125
229
  }
230
+ function jsonObjectHasEntries(path) {
231
+ if (!pixInstallDeps.existsSync(path))
232
+ return false;
233
+ try {
234
+ const parsed = JSON.parse(pixInstallDeps.readFileSync(path, "utf8"));
235
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && Object.keys(parsed).length > 0;
236
+ }
237
+ catch {
238
+ return false;
239
+ }
240
+ }
126
241
  async function resolvePiCliStatus(env) {
127
242
  const bundledBin = env.PIX_BUNDLED_PI_BIN;
128
243
  if (bundledBin && (pixInstallDeps.existsSync(join(bundledBin, process.platform === "win32" ? "pi.cmd" : "pi")) || pixInstallDeps.existsSync(join(bundledBin, "pi")))) {
@@ -4,5 +4,6 @@ export type StartupAvailabilityIssue = {
4
4
  message: string;
5
5
  };
6
6
  export declare function collectStartupAvailabilityIssues(runtime: AgentSessionRuntime): Promise<StartupAvailabilityIssue[]>;
7
+ export declare function checkSelectedModelAuthAvailability(runtime: AgentSessionRuntime): StartupAvailabilityIssue[];
7
8
  export declare function checkPiCliAvailability(pathValue?: string): Promise<StartupAvailabilityIssue[]>;
8
9
  export declare function checkPiToolsSuiteExtensionAvailability(extensionsResult: LoadExtensionsResult): StartupAvailabilityIssue[];
@@ -7,8 +7,24 @@ export async function collectStartupAvailabilityIssues(runtime) {
7
7
  return [
8
8
  ...(await checkPiCliAvailability()),
9
9
  ...checkPiToolsSuiteExtensionAvailability(runtime.services.resourceLoader.getExtensions()),
10
+ ...checkSelectedModelAuthAvailability(runtime),
10
11
  ];
11
12
  }
13
+ export function checkSelectedModelAuthAvailability(runtime) {
14
+ const model = runtime.session.model;
15
+ if (!model) {
16
+ return [{
17
+ kind: "error",
18
+ message: "No model is selected. Configure a provider with `npx @earendil-works/pi-coding-agent` and /login (or run /opencode-import), then choose /model in Pix.",
19
+ }];
20
+ }
21
+ if (runtime.services.modelRuntime.getProviderAuthStatus(model.provider).configured)
22
+ return [];
23
+ return [{
24
+ kind: "error",
25
+ message: `Selected model ${model.provider}/${model.id} has no configured credentials. Use \`npx @earendil-works/pi-coding-agent\` and /login, or run /opencode-import for supported OpenCode accounts, then choose /model.`,
26
+ }];
27
+ }
12
28
  export async function checkPiCliAvailability(pathValue = process.env.PATH ?? "") {
13
29
  if (await executableExistsOnPath(PI_CLI_COMMAND, pathValue))
14
30
  return [];
@@ -67,7 +67,7 @@ export class ModelCommandActions {
67
67
  const parsed = parseScopedModelRef(modelRef);
68
68
  if (!parsed)
69
69
  throw new Error("Model must use provider/model[:thinking] format");
70
- await runtime.services.modelRuntime.reloadConfig();
70
+ await runtime.services.modelRuntime.refresh();
71
71
  if (!isCommandScopeActive(this.host, scope))
72
72
  return;
73
73
  const model = runtime.services.modelRuntime.getModel(parsed.provider, parsed.modelId);
@@ -108,7 +108,7 @@ export class ModelCommandActions {
108
108
  const parsed = parseScopedModelRef(modelRef);
109
109
  if (!parsed)
110
110
  throw new Error("Model must use provider/model[:thinking] format");
111
- await runtime.services.modelRuntime.reloadConfig();
111
+ await runtime.services.modelRuntime.refresh();
112
112
  if (!isCommandScopeActive(this.host, scope))
113
113
  return;
114
114
  const model = runtime.services.modelRuntime.getModel(parsed.provider, parsed.modelId);
@@ -132,7 +132,7 @@ export class ModelCommandActions {
132
132
  const parsed = parseScopedModelRef(modelRef);
133
133
  if (!parsed)
134
134
  throw new Error("Model must use provider/model[:thinking] format, or run /autocomplete with no arguments to disable");
135
- await runtime.services.modelRuntime.reloadConfig();
135
+ await runtime.services.modelRuntime.refresh();
136
136
  if (!isCommandScopeActive(this.host, scope))
137
137
  return;
138
138
  const model = runtime.services.modelRuntime.getModel(parsed.provider, parsed.modelId);
@@ -216,7 +216,7 @@ export class ModelCommandActions {
216
216
  const refs = value.split(/[,\s]+/).map((ref) => ref.trim()).filter(Boolean);
217
217
  const scopedModels = [];
218
218
  const invalidRefs = [];
219
- await runtime.services.modelRuntime.reloadConfig();
219
+ await runtime.services.modelRuntime.refresh();
220
220
  if (!isCommandScopeActive(this.host, scope))
221
221
  return;
222
222
  for (const ref of refs) {
@@ -142,7 +142,7 @@ export async function completeInputWithPi(runtime, draft, config, signal) {
142
142
  const modelRuntime = runtime.services.modelRuntime;
143
143
  let model = modelRuntime.getModel(parsedModel.provider, parsedModel.modelId);
144
144
  if (!model) {
145
- await modelRuntime.reloadConfig();
145
+ await modelRuntime.refresh();
146
146
  model = modelRuntime.getModel(parsedModel.provider, parsedModel.modelId);
147
147
  }
148
148
  if (!model)
@@ -144,7 +144,7 @@ async function enhancePromptWithPi(runtime, draft, config) {
144
144
  systemPrompt: PROMPT_ENHANCER_SYSTEM_PROMPT,
145
145
  },
146
146
  });
147
- await services.modelRuntime.reloadConfig();
147
+ await services.modelRuntime.refresh();
148
148
  const model = services.modelRuntime.getModel(parsedModel.provider, parsedModel.modelId);
149
149
  if (!model) {
150
150
  throw new Error(modelNotFoundMessage(parsedModel.provider, parsedModel.modelId, services.modelRuntime.getModels()));
@@ -12,11 +12,13 @@ export type PiToolsSuiteInstallOptions = {
12
12
  sourcePath?: string;
13
13
  targetPath?: string;
14
14
  };
15
- export type BundledSkillsInstallAction = "installed" | "already-installed" | "missing-source";
15
+ export type BundledSkillsInstallAction = "installed" | "already-installed" | "existing-kept" | "missing-source";
16
16
  export type BundledSkillsInstallResult = {
17
17
  action: BundledSkillsInstallAction;
18
18
  sourcePath: string;
19
19
  targetPath: string;
20
+ installedSkills: string[];
21
+ preservedSkills: string[];
20
22
  };
21
23
  export type BundledSkillsInstallOptions = {
22
24
  homeDir?: string;
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { access, cp, lstat, mkdir, readlink, realpath, rm, symlink } from "node:fs/promises";
2
+ import { access, cp, lstat, mkdir, readdir, readlink, realpath, rm, symlink } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -68,14 +68,31 @@ export async function ensureBundledSkillsInstalled(options = {}) {
68
68
  const targetPath = resolve(options.targetPath ?? bundledSkillsInstallPath(options.homeDir));
69
69
  const sourceStat = await lstat(sourcePath).catch(() => undefined);
70
70
  if (!sourceStat?.isDirectory()) {
71
- return { action: "missing-source", sourcePath, targetPath };
71
+ return { action: "missing-source", sourcePath, targetPath, installedSkills: [], preservedSkills: [] };
72
72
  }
73
73
  if (await pathsReferToSameEntry(sourcePath, targetPath)) {
74
- return { action: "already-installed", sourcePath, targetPath };
74
+ return { action: "already-installed", sourcePath, targetPath, installedSkills: [], preservedSkills: [] };
75
75
  }
76
- await mkdir(dirname(targetPath), { recursive: true });
77
- await cp(sourcePath, targetPath, { recursive: true, force: true });
78
- return { action: "installed", sourcePath, targetPath };
76
+ await mkdir(targetPath, { recursive: true });
77
+ const installedSkills = [];
78
+ const preservedSkills = [];
79
+ for (const entry of await readdir(sourcePath, { withFileTypes: true })) {
80
+ if (!entry.isDirectory())
81
+ continue;
82
+ const skillTarget = join(targetPath, entry.name);
83
+ if (await lstat(skillTarget).catch(() => undefined)) {
84
+ preservedSkills.push(entry.name);
85
+ continue;
86
+ }
87
+ await cp(join(sourcePath, entry.name), skillTarget, { recursive: true, errorOnExist: true, force: false });
88
+ installedSkills.push(entry.name);
89
+ }
90
+ let action = "already-installed";
91
+ if (installedSkills.length > 0)
92
+ action = "installed";
93
+ else if (preservedSkills.length > 0)
94
+ action = "existing-kept";
95
+ return { action, sourcePath, targetPath, installedSkills, preservedSkills };
79
96
  }
80
97
  export function getBundledExtensionPaths() {
81
98
  return [
@@ -239,7 +256,7 @@ export async function createPixRuntime(options, runtimeOptions = {}) {
239
256
  config,
240
257
  ...(runtimeOptions.eventBus === undefined ? {} : { eventBus: runtimeOptions.eventBus }),
241
258
  });
242
- await services.modelRuntime.reloadConfig();
259
+ await services.modelRuntime.refresh();
243
260
  const model = parsedModel ? services.modelRuntime.getModel(parsedModel.provider, parsedModel.modelId) : undefined;
244
261
  if (parsedModel && !model) {
245
262
  throw new Error(`Model not found: ${parsedModel.provider}/${parsedModel.modelId}`);
@@ -12,17 +12,18 @@ This package keeps shared Pi tools as ordinary source folders under `src/` and r
12
12
  - `src/session-name` — `session_name` tool for reading or setting the current session title directly from tool calls, without relying on slash-command parsing
13
13
  - `src/repo-discovery` — `/idx-init`, `/idx-update`, and indexed-only `repo_architecture` / `repo_structure` / `repo_ast` / `repo_search` / `repo_explain` / `repo_deps`; tools register only when the launch project has `.indexer-cli`
14
14
  - `src/antigravity-auth` — `antigravity` custom provider with Google Antigravity OAuth login, startup account list, auth.json-only runtime account loading, `/antigravity-add-account` OAuth append into rotation, `/antigravity-account` status display, account rotation/failover, Antigravity plus Gemini CLI model registration, and streaming through the Cloud Code Assist unified gateway
15
+ - `src/opencode-import` — `/opencode-import` for bounded migration of supported OpenCode OpenAI/Codex, GitHub Copilot, Z.ai, and Antigravity credentials into Pi; existing entries are preserved unless `--force` is passed
15
16
  - `src/todo` — `todo` tool, `/todos`, `/todos-persist`, and `/todos-scope`; supports parent/subtask hierarchy, blockers, ready-task filtering, deferred out-of-scope items, batch operations, JSON/Markdown import/export, automatic clearing when all visible todos are completed, and optional project persistence via `/todos persist on` or `/todos-persist on`; localization/i18n has been removed
16
17
  - `src/model-tools` — model-specific tool aliases such as Claude/GLM-style `Read` / `Edit` / `Write` / `Bash` / `Grep` / `Glob` / `LS`, GPT/Codex-style `shell`, and model-gated `apply_patch`
17
18
  - `src/usage` — `/usage` command and startup hint for read-only AI quota checks across OpenAI, Zhipu AI, Z.ai, and Google Antigravity, including Antigravity quota by model
18
- - `src/web-search` — `web_search` and `web_fetch` tools migrated from `@ollama/pi-web-search`; calls the local Ollama experimental web search/fetch APIs, honors `OLLAMA_HOST`, supports request timeouts via `timeout_ms` / `PI_WEB_SEARCH_TIMEOUT_MS`, and reports targeted `ollama signin`, unsupported-endpoint, invalid-response, timeout, DNS, and Ollama-not-running errors
19
+ - `src/web-search` — `web_search` and `web_fetch` tools migrated from `@ollama/pi-web-search`; uses local Ollama by default or the official Ollama cloud API when an API key is configured, supports Tavily Search/Extract fallback, provides `/web-credentials` for secure user-level key storage, honors `OLLAMA_HOST`, supports request timeouts via `timeout_ms` / `PI_WEB_SEARCH_TIMEOUT_MS`, and reports provider-specific errors
19
20
  - `src/dcp` — headless Dynamic Context Pruning ported from `opencode-dynamic-context-pruning` for the Pi SDK: explicit `compress` tool with range and message modes, `/dcp` commands (context, stats, sweep, manual, decompress, recompress, compress), same-call overlap validation, recoverable compressed-block rollups, grouped message-mode skip diagnostics, stable raw-message anchors when available, protected user/tool preservation, deduplication, error purging, and context nudges; visualization is left to `compress` tool responses and the renderer-owned context-percent click dialog
20
21
  - `src/prompt-commands` — user slash-command builder: `/prompt-commands` opens a CRUD menu for saved prompt-backed slash commands, stores them under `promptCommands` in `~/.config/pi/pi-tools-suite.jsonc`, reloads after edits, and runs each saved prompt as a normal user message
21
22
  - `src/skill-installer` — `/install-skill [name]` installs a personal skill folder from `~/.agents/local_skills` into the current project's `.pi/skills/` so it activates as a project-local skill, then automatically runs `/reload` so the new skill is picked up without a manual step; `/export-skill [name]` does the reverse, copying a project-local skill back to `~/.agents/local_skills/` for reuse in other projects (no reload, since the library lives outside the project); with no argument either command shows an interactive menu of available skills (folders containing `SKILL.md`), and the `<name>` form installs/exports it directly (headless-safe); existing destinations prompt to overwrite in the UI and are refused in headless mode; `.DS_Store` files are skipped
22
23
 
23
24
  `index.ts` is intentionally only a thin auto-discovery shim that re-exports `src/index.ts`. There is no `pi.extensions` manifest here, so local Pi auto-discovery loads the suite once via `~/.pi/agent/extensions/pi-tools-suite/index.ts` and does not double-register tools.
24
25
 
25
- Registration order is preserved in `src/index.ts`: coding-discipline, ast-grep, async-subagents, lsp, comment-checker, session-name, repo-discovery command/tool gate, antigravity-auth provider, todo, model-tools, usage, web-search, dcp, prompt-commands, then skill-installer. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
26
+ Registration order is preserved in `src/index.ts`: coding-discipline, ast-grep, async-subagents, lsp, comment-checker, session-name, repo-discovery command/tool gate, antigravity-auth provider, OpenCode import, todo, model-tools, usage, web-search, dcp, prompt-commands, then skill-installer. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
26
27
 
27
28
  ## Disabling modules
28
29
 
@@ -303,24 +304,57 @@ Runtime logs are minimized by default: successful agents do not keep `events.jso
303
304
 
304
305
  `asyncSubagents` config also supports `maxConcurrent` (default 5, project-wide; `0` means unlimited), global/per-type `retry` with exponential backoff, global/per-type `maxResultBytes` for bounding `result.json.resultText` while keeping raw `result.md` intact, and global/per-type/preset `timeoutMs` for wall-clock agent watchdogs. Spawn calls and individual task objects can pass `timeoutSeconds` to shorten the watchdog for synthetic tests or bounded probes. Stop requests mark running, queued planned, and retry-pending agents as `stopped` so queued work is not launched later. Completed agents write `result.json` with status/duration/model/retry metadata plus best-effort `summary`, `findings`, `files`, `risks`, `nextActions`, and `confidence` fields for parent-agent chaining.
305
306
 
307
+ ## OpenCode credential import
308
+
309
+ `/opencode-import` migrates only credential formats with an explicit Pi mapping: OpenAI OAuth to `openai-codex`, OpenAI API keys to `openai`, GitHub Copilot OAuth, Z.ai aliases, and one matching OpenCode Antigravity account. It does not migrate OpenCode model definitions, defaults, MCP servers, plugins, instructions, or tool settings.
310
+
311
+ The default source is `OPENCODE_AUTH_CONTENT`, `OPENCODE_DATA_DIR/auth.json`, or the XDG/default OpenCode data directory. The destination is the active Pi agent directory, including `PI_CODING_AGENT_DIR`. Existing Pi credentials are preserved; pass `--force` only to replace supported entries intentionally. Successful writes are atomic, use restrictive file permissions, and trigger a runtime reload. The command also accepts `--path`, `--auth-path`, `--antigravity-path`, `--skip-auth-json`, and `--skip-antigravity` for controlled migrations.
312
+
306
313
  ## Web search
307
314
 
308
- `src/web-search` registers two Ollama-backed tools:
315
+ `src/web-search` registers two Ollama-first tools and the `/web-credentials` setup command:
316
+
317
+ - `web_search` posts `{ query, max_results }` and returns formatted title/URL/snippet results plus structured `details.results`.
318
+ - `web_fetch` posts `{ url }` and returns extracted page text plus title/link metadata.
319
+ - `/web-credentials` can set, inspect, or clear stored Ollama and Tavily API keys. Notifications and status output never display key values.
320
+
321
+ Without an Ollama API key, both tools default to `http://localhost:11434/api/experimental/...`; set `OLLAMA_HOST` to point at another Ollama instance. With an Ollama API key and no explicit `OLLAMA_HOST`, they use the official `https://ollama.com/api/web_search` and `/api/web_fetch` endpoints with bearer authentication. Requests time out after 30 seconds by default. Override globally with `PI_WEB_SEARCH_TIMEOUT_MS` or per call with `timeout_ms` (maximum 120000 ms). Tool results include `host`, `timeoutMs`, and truncation metadata in `details`.
322
+
323
+ Configure a Tavily key to enable automatic fallback for both tools. Ollama remains the primary provider; if any Ollama request or response fails, `web_search` retries through `https://api.tavily.com/search` and `web_fetch` retries through `https://api.tavily.com/extract`. The Tavily key is sent only in Tavily's bearer authorization header and is never accepted as a tool parameter or included in result details. Fallback results set `details.provider` to `tavily` and include the primary Ollama error under `details.fallbackFrom`; ordinary results set `details.provider` to `ollama`.
324
+
325
+ The recommended interactive setup is:
326
+
327
+ ```text
328
+ /web-credentials
329
+ ```
330
+
331
+ Choose Ollama or Tavily, paste the key, and it becomes active for later calls without a reload. Keys are stored in `~/.config/pi/pi-tools-suite-credentials.json` with mode `0600`. The same command can show configuration sources or clear stored keys without displaying them.
332
+
333
+ The command displays the official key pages before opening its menu:
309
334
 
310
- - `web_search` posts `{ query, max_results }` to `/api/experimental/web_search` and returns formatted title/URL/snippet results plus structured `details.results`.
311
- - `web_fetch` posts `{ url }` to `/api/experimental/web_fetch` and returns extracted page text plus title/link metadata.
335
+ - Ollama: <https://ollama.com/settings/keys>
336
+ - Tavily: <https://app.tavily.com/home>
337
+
338
+ Environment variables remain supported and take precedence over stored keys:
339
+
340
+ ```bash
341
+ export OLLAMA_API_KEY="..."
342
+ export TAVILY_API_KEY="tvly-..."
343
+ ```
312
344
 
313
- Both tools default to `http://localhost:11434`; set `OLLAMA_HOST` to point at another Ollama instance. Requests time out after 30 seconds by default. Override globally with `PI_WEB_SEARCH_TIMEOUT_MS` or per call with `timeout_ms` (maximum 120000 ms). Tool results include `host`, `timeoutMs`, and truncation metadata in `details`.
345
+ The timeout applies independently to each provider attempt, so a failed Ollama request followed by Tavily can take up to roughly twice the configured timeout. Tavily Search fallback uses basic search and clamps `max_results` to Tavily's documented maximum of 20. Tavily Extract does not return page title/link metadata, so fallback uses the requested URL as the title and reports no links.
314
346
 
315
347
  Troubleshooting:
316
348
 
317
349
  | Symptom | Fix |
318
350
  | --- | --- |
319
351
  | `Could not connect to Ollama` | Start Ollama and check `OLLAMA_HOST`. |
320
- | `Unauthorized by Ollama ... Run ollama signin` | Run `ollama signin`, then retry. |
352
+ | `Unauthorized by Ollama` | Run `ollama signin` for local Ollama, or update the Ollama key through `/web-credentials` / `OLLAMA_API_KEY`. |
321
353
  | `endpoint is not available` | Update Ollama and make sure the experimental web search/fetch feature is enabled for that install. |
322
354
  | `timed out after ...` | Increase per-call `timeout_ms` or `PI_WEB_SEARCH_TIMEOUT_MS` if the local web endpoint is slow. |
323
355
  | `invalid JSON` / `unexpected response` | Check the Ollama version and the raw endpoint behavior; the tool reports the bad response shape instead of failing with a generic parser error. |
356
+ | `Tavily ... rejected TAVILY_API_KEY` | Update the Tavily key through `/web-credentials` or `TAVILY_API_KEY`. |
357
+ | `Tavily ... limit was exceeded` | Check Tavily usage/plan limits in the Tavily dashboard. |
324
358
 
325
359
  Do not send secrets, tokens, private repository text, or credential-bearing URLs through these tools; Ollama may query external web services to satisfy the request.
326
360
 
@@ -43,9 +43,9 @@
43
43
  "vscode-languageserver-protocol": "^3.17.5"
44
44
  },
45
45
  "peerDependencies": {
46
- "@earendil-works/pi-ai": "0.81.1",
47
- "@earendil-works/pi-coding-agent": "0.81.1",
48
- "@earendil-works/pi-tui": "0.81.1",
46
+ "@earendil-works/pi-ai": "0.82.1",
47
+ "@earendil-works/pi-coding-agent": "0.82.1",
48
+ "@earendil-works/pi-tui": "0.82.1",
49
49
  "typebox": "*"
50
50
  },
51
51
  "devDependencies": {
@@ -1,10 +1,12 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { promises as fs } from "node:fs";
2
3
  import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
6
  import { DEFAULT_PROJECT_ID, PROVIDER_ID } from "./constants";
5
7
  import type { GoogleOAuthClientCredentials, OpencodeAntigravityAccount, OpencodeAntigravityImportResult, OpencodeAntigravityStorage, PiAuthCredential, PiAuthData } from "./types";
6
8
 
7
- export const PI_AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
9
+ export const PI_AUTH_PATH = join(getAgentDir(), "auth.json");
8
10
 
9
11
  function testPiAuthPath(): string | undefined {
10
12
  return process.env.NODE_ENV === "test" ? process.env.PI_TOOLS_SUITE_TEST_AUTH_PATH : undefined;
@@ -48,7 +50,7 @@ export async function importDefaultOpencodeAntigravityAccount(options: { overwri
48
50
  }
49
51
 
50
52
  export function getPiAuthPath(): string {
51
- return testPiAuthPath() ?? PI_AUTH_PATH;
53
+ return testPiAuthPath() ?? join(getAgentDir(), "auth.json");
52
54
  }
53
55
 
54
56
  export async function readJsonFile<T>(path: string, fallback: T): Promise<T> {
@@ -61,9 +63,21 @@ export async function readJsonFile<T>(path: string, fallback: T): Promise<T> {
61
63
  }
62
64
 
63
65
  export async function writeJsonFileSecure(path: string, data: unknown): Promise<void> {
64
- await fs.mkdir(dirname(path), { recursive: true });
65
- await fs.writeFile(path, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
66
- await fs.chmod(path, 0o600).catch(() => undefined);
66
+ const directory = dirname(path);
67
+ const temporaryPath = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
68
+ await fs.mkdir(directory, { recursive: true });
69
+ try {
70
+ await fs.writeFile(temporaryPath, `${JSON.stringify(data, null, 2)}\n`, {
71
+ encoding: "utf8",
72
+ flag: "wx",
73
+ mode: 0o600,
74
+ });
75
+ await fs.rename(temporaryPath, path);
76
+ await fs.chmod(path, 0o600).catch(() => undefined);
77
+ } catch (error) {
78
+ await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
79
+ throw error;
80
+ }
67
81
  }
68
82
 
69
83
  export function getStoredAccounts(credential?: PiAuthCredential): OpencodeAntigravityAccount[] {