pi-ui-extend 0.1.74 → 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 [];
@@ -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 [
@@ -12,6 +12,7 @@ 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
@@ -22,7 +23,7 @@ This package keeps shared Pi tools as ordinary source folders under `src/` and r
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,6 +304,12 @@ 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
315
  `src/web-search` registers two Ollama-first tools and the `/web-credentials` setup command:
@@ -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[] {
@@ -72,15 +72,18 @@ export function formatOpencodeImportResult(result: OpencodeImportResult): string
72
72
  if (antigravity) providerLines.push(`- ${antigravity}`);
73
73
 
74
74
  if (providerLines.length === 0) {
75
- return `No opencode credentials were imported. Checked ${result.sourcePath}${result.antigravitySourcePath ? ` and ${result.antigravitySourcePath}` : ""}.`;
75
+ return `No OpenCode credentials were imported. Checked ${result.sourcePath}${result.antigravitySourcePath ? ` and ${result.antigravitySourcePath}` : ""}.`;
76
76
  }
77
77
 
78
78
  const suffix = missingCount > 0 ? `\nMissing ${missingCount} known opencode provider entr${missingCount === 1 ? "y" : "ies"}.` : "";
79
- return `Opencode import wrote to ${result.authPath}:\n${providerLines.join("\n")}${suffix}`;
79
+ const heading = result.wroteAuth ? `Imported OpenCode credentials into ${result.authPath}:` : `OpenCode credential check for ${result.authPath}:`;
80
+ return `${heading}\n${providerLines.join("\n")}${suffix}`;
80
81
  }
81
82
 
82
- export function notificationLevel(result: OpencodeImportResult): "info" | "warn" | "error" {
83
+ export function notificationLevel(result: OpencodeImportResult): "info" | "warning" | "error" {
83
84
  if (result.wroteAuth) return "info";
84
- if (result.providers.some((provider) => provider.status === "auth-exists-use-force") || result.antigravity?.reason === "auth-exists-use-force") return "warn";
85
- return "error";
85
+ if (result.providers.some((provider) => provider.status === "auth-exists-use-force") || result.antigravity?.reason === "auth-exists-use-force") return "warning";
86
+ if (result.providers.some((provider) => provider.status === "invalid-source") || result.antigravity?.reason === "matching-account-not-found") return "error";
87
+ if (result.providers.some((provider) => provider.status === "already-imported") || result.antigravity?.reason === "already-imported") return "info";
88
+ return "warning";
86
89
  }
@@ -55,16 +55,16 @@ export type OpencodeImportOptions = {
55
55
  type Mapping = {
56
56
  label: string;
57
57
  sourceProvider: string;
58
- targetProvider: string;
58
+ targetProvider: string | ((credential: OpencodeAuthCredential | undefined) => string);
59
59
  transform: (credential: OpencodeAuthCredential) => PiAuthCredential | undefined;
60
60
  };
61
61
 
62
62
  const AUTH_JSON_MAPPINGS: Mapping[] = [
63
63
  {
64
- label: "OpenAI Codex",
64
+ label: "OpenAI",
65
65
  sourceProvider: "openai",
66
- targetProvider: "openai-codex",
67
- transform: transformOAuthCredential,
66
+ targetProvider: (credential) => (isOAuthCredential(credential) ? "openai-codex" : "openai"),
67
+ transform: transformOpenAiCredential,
68
68
  },
69
69
  {
70
70
  label: "GitHub Copilot",
@@ -92,24 +92,45 @@ export function getDefaultOpencodeAuthPath(): string {
92
92
  }
93
93
 
94
94
  function transformOAuthCredential(credential: OpencodeAuthCredential): PiAuthCredential | undefined {
95
- if (!credential.access && !credential.refresh) return undefined;
95
+ if (
96
+ typeof credential.access !== "string" ||
97
+ !credential.access ||
98
+ typeof credential.refresh !== "string" ||
99
+ !credential.refresh ||
100
+ typeof credential.expires !== "number" ||
101
+ !Number.isFinite(credential.expires)
102
+ ) {
103
+ return undefined;
104
+ }
96
105
  return { ...credential, type: "oauth" };
97
106
  }
98
107
 
99
108
  function transformApiKeyCredential(credential: OpencodeAuthCredential): PiAuthCredential | undefined {
100
- if (!credential.key) return undefined;
109
+ if (typeof credential.key !== "string" || !credential.key) return undefined;
101
110
  return { ...credential, type: "api_key", key: credential.key };
102
111
  }
103
112
 
113
+ function isOAuthCredential(credential: OpencodeAuthCredential | undefined): boolean {
114
+ return credential?.type === "oauth" || typeof credential?.access === "string" || typeof credential?.refresh === "string";
115
+ }
116
+
117
+ function transformOpenAiCredential(credential: OpencodeAuthCredential): PiAuthCredential | undefined {
118
+ return isOAuthCredential(credential) ? transformOAuthCredential(credential) : transformApiKeyCredential(credential);
119
+ }
120
+
104
121
  function sameCredential(a: PiAuthCredential | undefined, b: PiAuthCredential | undefined): boolean {
105
122
  return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
106
123
  }
107
124
 
108
- function providerResult(mapping: Mapping, status: OpencodeProviderImportStatus): OpencodeProviderImportResult {
125
+ function targetProvider(mapping: Mapping, credential: OpencodeAuthCredential | undefined): string {
126
+ return typeof mapping.targetProvider === "function" ? mapping.targetProvider(credential) : mapping.targetProvider;
127
+ }
128
+
129
+ function providerResult(mapping: Mapping, status: OpencodeProviderImportStatus, credential?: OpencodeAuthCredential): OpencodeProviderImportResult {
109
130
  return {
110
131
  label: mapping.label,
111
132
  sourceProvider: mapping.sourceProvider,
112
- targetProvider: mapping.targetProvider,
133
+ targetProvider: targetProvider(mapping, credential),
113
134
  status,
114
135
  };
115
136
  }
@@ -123,8 +144,15 @@ async function pathExists(path: string): Promise<boolean> {
123
144
  }
124
145
  }
125
146
 
147
+ async function readOpencodeAuth(sourcePath: string, environmentSource: string | undefined): Promise<OpencodeAuthData> {
148
+ if (environmentSource !== undefined) return parseOpencodeAuthContent(environmentSource);
149
+ if (!(await pathExists(sourcePath))) return {};
150
+ return readJsonFile<OpencodeAuthData>(sourcePath, {});
151
+ }
152
+
126
153
  export async function importOpencodeAccounts(options: OpencodeImportOptions = {}): Promise<OpencodeImportResult> {
127
- const sourcePath = options.sourcePath ?? getDefaultOpencodeAuthPath();
154
+ const environmentSource = options.sourcePath === undefined ? process.env.OPENCODE_AUTH_CONTENT : undefined;
155
+ const sourcePath = options.sourcePath ?? (environmentSource !== undefined ? "OPENCODE_AUTH_CONTENT" : getDefaultOpencodeAuthPath());
128
156
  const authPath = options.authPath ?? getPiAuthPath();
129
157
  const result: OpencodeImportResult = {
130
158
  sourcePath,
@@ -138,8 +166,7 @@ export async function importOpencodeAccounts(options: OpencodeImportOptions = {}
138
166
  const changedTargets = new Set<string>();
139
167
 
140
168
  if (!options.skipAuthJson) {
141
- const sourceExists = await pathExists(sourcePath);
142
- const opencodeAuth = sourceExists ? await readJsonFile<OpencodeAuthData>(sourcePath, {}) : {};
169
+ const opencodeAuth = await readOpencodeAuth(sourcePath, environmentSource);
143
170
 
144
171
  for (const mapping of AUTH_JSON_MAPPINGS) {
145
172
  const sourceCredential = opencodeAuth[mapping.sourceProvider];
@@ -147,34 +174,35 @@ export async function importOpencodeAccounts(options: OpencodeImportOptions = {}
147
174
  result.providers.push(providerResult(mapping, "source-missing"));
148
175
  continue;
149
176
  }
177
+ const resolvedTargetProvider = targetProvider(mapping, sourceCredential);
150
178
 
151
- if (changedTargets.has(mapping.targetProvider)) {
152
- result.providers.push(providerResult(mapping, "target-set-from-other-source"));
179
+ if (changedTargets.has(resolvedTargetProvider)) {
180
+ result.providers.push(providerResult(mapping, "target-set-from-other-source", sourceCredential));
153
181
  continue;
154
182
  }
155
183
 
156
184
  const nextCredential = mapping.transform(sourceCredential);
157
185
  if (!nextCredential) {
158
- result.providers.push(providerResult(mapping, "invalid-source"));
186
+ result.providers.push(providerResult(mapping, "invalid-source", sourceCredential));
159
187
  continue;
160
188
  }
161
189
 
162
- const existingCredential = piAuth[mapping.targetProvider];
190
+ const existingCredential = piAuth[resolvedTargetProvider];
163
191
  if (sameCredential(existingCredential, nextCredential)) {
164
- result.providers.push(providerResult(mapping, "already-imported"));
165
- changedTargets.add(mapping.targetProvider);
192
+ result.providers.push(providerResult(mapping, "already-imported", sourceCredential));
193
+ changedTargets.add(resolvedTargetProvider);
166
194
  continue;
167
195
  }
168
196
 
169
197
  if (existingCredential && !options.overwrite) {
170
- result.providers.push(providerResult(mapping, "auth-exists-use-force"));
171
- changedTargets.add(mapping.targetProvider);
198
+ result.providers.push(providerResult(mapping, "auth-exists-use-force", sourceCredential));
199
+ changedTargets.add(resolvedTargetProvider);
172
200
  continue;
173
201
  }
174
202
 
175
- piAuth = { ...piAuth, [mapping.targetProvider]: nextCredential };
176
- changedTargets.add(mapping.targetProvider);
177
- result.providers.push(providerResult(mapping, "imported"));
203
+ piAuth = { ...piAuth, [resolvedTargetProvider]: nextCredential };
204
+ changedTargets.add(resolvedTargetProvider);
205
+ result.providers.push(providerResult(mapping, "imported", sourceCredential));
178
206
  }
179
207
 
180
208
  if (result.providers.some((provider) => provider.status === "imported")) {
@@ -206,3 +234,16 @@ export async function importOpencodeAccounts(options: OpencodeImportOptions = {}
206
234
 
207
235
  return result;
208
236
  }
237
+
238
+ function parseOpencodeAuthContent(content: string): OpencodeAuthData {
239
+ let parsed: unknown;
240
+ try {
241
+ parsed = JSON.parse(content);
242
+ } catch {
243
+ throw new Error("OPENCODE_AUTH_CONTENT must contain a valid JSON object.");
244
+ }
245
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
246
+ throw new Error("OPENCODE_AUTH_CONTENT must contain a JSON object.");
247
+ }
248
+ return parsed as OpencodeAuthData;
249
+ }
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { formatOpencodeImportResult, notificationLevel, parseOpencodeImportCommandArgs } from "./commands";
3
3
  import { importOpencodeAccounts } from "./importer";
4
4
 
@@ -8,10 +8,12 @@ export type { OpencodeImportOptions, OpencodeImportResult } from "./importer";
8
8
 
9
9
  export default function opencodeImport(pi: ExtensionAPI): void {
10
10
  pi.registerCommand("opencode-import", {
11
- description: "Import opencode auth.json credentials and Antigravity accounts into Pi/Pix auth.json",
12
- handler: async (args: string, ctx: any) => {
11
+ description: "Import supported OpenCode credentials and Antigravity accounts into Pi/Pix auth.json",
12
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
13
+ let wroteAuth = false;
13
14
  try {
14
15
  const result = await importOpencodeAccounts(parseOpencodeImportCommandArgs(args));
16
+ wroteAuth = result.wroteAuth;
15
17
  const message = formatOpencodeImportResult(result);
16
18
  if (ctx.ui?.notify) ctx.ui.notify(message, notificationLevel(result));
17
19
  else console.log(message);
@@ -19,6 +21,15 @@ export default function opencodeImport(pi: ExtensionAPI): void {
19
21
  const message = error instanceof Error ? error.message : String(error);
20
22
  if (ctx.ui?.notify) ctx.ui.notify(message, "error");
21
23
  else console.error(message);
24
+ return;
25
+ }
26
+
27
+ if (!wroteAuth) return;
28
+ try {
29
+ await ctx.reload();
30
+ } catch (error) {
31
+ const message = error instanceof Error ? error.message : String(error);
32
+ console.error(`OpenCode credentials were imported, but resource reload failed: ${message}`);
22
33
  }
23
34
  },
24
35
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "0.1.74",
3
+ "version": "0.1.77",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {
@@ -9,6 +9,16 @@ This skill fetches current documentation and code examples for any programming l
9
9
 
10
10
  Context7 indexes documentation from official sources and provides version-specific, relevant excerpts — much more reliable than relying on potentially outdated training data.
11
11
 
12
+ ## Setup
13
+
14
+ Create a Context7 API key in the Context7 dashboard and export it before using the skill:
15
+
16
+ ```bash
17
+ export CONTEXT7_API_KEY="..."
18
+ ```
19
+
20
+ The script does not contain a fallback key and exits before making a network request when the variable is missing. It also requires `curl` and `jq` on `PATH`.
21
+
12
22
  ## Two-step workflow
13
23
 
14
24
  ### Step 1: Resolve the library ID
@@ -10,11 +10,12 @@
10
10
  # 1. resolve — find the Context7 library ID for a package
11
11
  # 2. docs — query documentation for a resolved library ID
12
12
  #
13
- # Requires CONTEXT7_API_KEY env var (falls back to hardcoded default).
13
+ # Requires CONTEXT7_API_KEY env var.
14
14
 
15
15
  set -euo pipefail
16
16
 
17
- API_KEY="${CONTEXT7_API_KEY:-ctx7sk-d3b58838-f015-48a3-8059-93562f78837d}"
17
+ : "${CONTEXT7_API_KEY:?Context7 requires CONTEXT7_API_KEY. Create a key at https://context7.com/dashboard and export it before using this skill.}"
18
+ API_KEY="$CONTEXT7_API_KEY"
18
19
  BASE_URL="https://mcp.context7.com/mcp"
19
20
 
20
21
  call_mcp() {