Merge pull request #151 from dbachelder/fix/pi-provider-reuse-and-extension-loading

fix: reuse Pi provider config and load Pi extensions correctly
This commit is contained in:
TÂCHES 2026-03-12 22:25:15 -06:00 committed by GitHub
commit 2a3c2b5194
6 changed files with 181 additions and 22 deletions

View file

@ -15,7 +15,7 @@ import { agentDir, sessionsDir, authFilePath } from './app-paths.js'
import { initResources, buildResourceLoader } from './resource-loader.js'
import { ensureManagedTools } from './tool-bootstrap.js'
import { loadStoredEnvKeys } from './wizard.js'
import { migratePiCredentials } from './pi-migration.js'
import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migration.js'
import { shouldRunOnboarding, runOnboarding } from './onboarding.js'
// ---------------------------------------------------------------------------
@ -115,22 +115,30 @@ const settingsManager = SettingsManager.create(agentDir)
const configuredProvider = settingsManager.getDefaultProvider()
const configuredModel = settingsManager.getDefaultModel()
const allModels = modelRegistry.getAll()
const availableModels = modelRegistry.getAvailable()
const configuredExists = configuredProvider && configuredModel &&
allModels.some((m) => m.provider === configuredProvider && m.id === configuredModel)
const configuredAvailable = configuredProvider && configuredModel &&
availableModels.some((m) => m.provider === configuredProvider && m.id === configuredModel)
if (!configuredModel || !configuredExists) {
// Fallback: pick the best available Anthropic model
if (!configuredModel || !configuredExists || !configuredAvailable) {
const piDefault = getPiDefaultModelAndProvider()
const preferred =
allModels.find((m) => m.provider === 'anthropic' && m.id === 'claude-opus-4-6') ||
allModels.find((m) => m.provider === 'anthropic' && m.id.includes('opus')) ||
allModels.find((m) => m.provider === 'anthropic')
(piDefault
? availableModels.find((m) => m.provider === piDefault.provider && m.id === piDefault.model)
: undefined) ||
availableModels.find((m) => m.provider === 'openai' && m.id === 'gpt-5.4') ||
availableModels.find((m) => m.provider === 'openai') ||
availableModels.find((m) => m.provider === 'anthropic' && m.id === 'claude-opus-4-6') ||
availableModels.find((m) => m.provider === 'anthropic' && m.id.includes('opus')) ||
availableModels.find((m) => m.provider === 'anthropic') ||
availableModels[0]
if (preferred) {
settingsManager.setDefaultModelAndProvider(preferred.provider, preferred.id)
}
}
// Default thinking level: off (always reset if not explicitly set)
if (settingsManager.getDefaultThinkingLevel() !== 'off' && !configuredExists) {
if (settingsManager.getDefaultThinkingLevel() !== 'off' && (!configuredExists || !configuredAvailable)) {
settingsManager.setDefaultThinkingLevel('off')
}

View file

@ -152,18 +152,17 @@ function isCancelError(p: ClackModule, err: unknown): boolean {
* Determine if the onboarding wizard should run.
*
* Returns true when:
* - No LLM provider has credentials in authStorage
* - No LLM provider auth is available
* - We're on a TTY (interactive terminal)
*
* Returns false (skip wizard) when:
* - Any LLM provider is already authed (returning user)
* - Any LLM provider is already available via auth.json, env vars, runtime overrides, or fallback auth
* - Not a TTY (piped input, subagent, CI)
*/
export function shouldRunOnboarding(authStorage: AuthStorage): boolean {
if (!process.stdin.isTTY) return false
// Check if any LLM provider has credentials
const authedProviders = authStorage.list()
const hasLlmAuth = authedProviders.some(id => LLM_PROVIDER_IDS.includes(id))
const hasLlmAuth = LLM_PROVIDER_IDS.some(id => authStorage.hasAuth(id))
return !hasLlmAuth
}

View file

@ -10,6 +10,7 @@ import { join } from 'node:path'
import type { AuthStorage, AuthCredential } from '@gsd/pi-coding-agent'
const PI_AUTH_PATH = join(homedir(), '.pi', 'agent', 'auth.json')
const PI_SETTINGS_PATH = join(homedir(), '.pi', 'agent', 'settings.json')
const LLM_PROVIDER_IDS = [
'anthropic',
@ -34,7 +35,6 @@ const LLM_PROVIDER_IDS = [
*/
export function migratePiCredentials(authStorage: AuthStorage): boolean {
try {
// Only migrate when GSD has no LLM providers
const existing = authStorage.list()
const hasLlm = existing.some(id => LLM_PROVIDER_IDS.includes(id))
if (hasLlm) return false
@ -55,7 +55,25 @@ export function migratePiCredentials(authStorage: AuthStorage): boolean {
return migratedLlm
} catch {
// Non-fatal — don't block startup
return false
}
}
export function getPiDefaultModelAndProvider(): { provider: string; model: string } | null {
try {
if (!existsSync(PI_SETTINGS_PATH)) return null
const raw = readFileSync(PI_SETTINGS_PATH, 'utf-8')
const data = JSON.parse(raw) as { defaultProvider?: unknown; defaultModel?: unknown }
if (typeof data.defaultProvider !== 'string' || typeof data.defaultModel !== 'string') {
return null
}
return {
provider: data.defaultProvider,
model: data.defaultModel,
}
} catch {
return null
}
}

View file

@ -1,7 +1,7 @@
import { DefaultResourceLoader } from '@gsd/pi-coding-agent'
import { homedir } from 'node:os'
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
// Resolves to the bundled src/resources/ inside the npm package at runtime:
@ -9,6 +9,70 @@ import { fileURLToPath } from 'node:url'
const resourcesDir = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'resources')
const bundledExtensionsDir = join(resourcesDir, 'extensions')
function isExtensionFile(name: string): boolean {
return name.endsWith('.ts') || name.endsWith('.js')
}
function resolveExtensionEntries(dir: string): string[] {
const packageJsonPath = join(dir, 'package.json')
if (existsSync(packageJsonPath)) {
try {
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8'))
const declared = pkg?.pi?.extensions
if (Array.isArray(declared)) {
const resolved = declared
.filter((entry: unknown): entry is string => typeof entry === 'string')
.map((entry: string) => resolve(dir, entry))
.filter((entry: string) => existsSync(entry))
if (resolved.length > 0) {
return resolved
}
}
} catch {
// Ignore malformed manifests and fall back to index.ts/index.js discovery.
}
}
const indexTs = join(dir, 'index.ts')
if (existsSync(indexTs)) {
return [indexTs]
}
const indexJs = join(dir, 'index.js')
if (existsSync(indexJs)) {
return [indexJs]
}
return []
}
export function discoverExtensionEntryPaths(extensionsDir: string): string[] {
if (!existsSync(extensionsDir)) {
return []
}
const discovered: string[] = []
for (const entry of readdirSync(extensionsDir, { withFileTypes: true })) {
const entryPath = join(extensionsDir, entry.name)
if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) {
discovered.push(entryPath)
continue
}
if (entry.isDirectory() || entry.isSymbolicLink()) {
discovered.push(...resolveExtensionEntries(entryPath))
}
}
return discovered
}
function getExtensionKey(entryPath: string, extensionsDir: string): string {
const relPath = relative(extensionsDir, entryPath)
return relPath.split(/[\\/]/)[0]
}
/**
* Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
*
@ -60,9 +124,15 @@ export function initResources(agentDir: string): void {
export function buildResourceLoader(agentDir: string): DefaultResourceLoader {
const piAgentDir = join(homedir(), '.pi', 'agent')
const piExtensionsDir = join(piAgentDir, 'extensions')
const bundledKeys = new Set(
discoverExtensionEntryPaths(bundledExtensionsDir).map((entryPath) => getExtensionKey(entryPath, bundledExtensionsDir)),
)
const piExtensionPaths = discoverExtensionEntryPaths(piExtensionsDir).filter(
(entryPath) => !bundledKeys.has(getExtensionKey(entryPath, piExtensionsDir)),
)
return new DefaultResourceLoader({
agentDir,
additionalExtensionPaths: [piExtensionsDir],
additionalExtensionPaths: piExtensionPaths,
})
}

View file

@ -19,7 +19,6 @@ import {
} from "@gsd/pi-coding-agent";
import { Text } from "@gsd/pi-tui";
import { Type } from "@sinclair/typebox";
import { GoogleGenAI } from "@google/genai";
// ── Types ────────────────────────────────────────────────────────────────────
@ -46,10 +45,24 @@ interface SearchDetails {
// ── Lazy singleton client ────────────────────────────────────────────────────
let client: GoogleGenAI | null = null;
type GoogleGenAIClient = {
models: {
generateContent: (args: {
model: string;
contents: string;
config?: {
tools?: Array<{ googleSearch: Record<string, never> }>;
abortSignal?: AbortSignal;
};
}) => Promise<any>;
};
};
function getClient(): GoogleGenAI {
let client: GoogleGenAIClient | null = null;
async function getClient(): Promise<GoogleGenAIClient> {
if (!client) {
const { GoogleGenAI } = await import("@google/genai");
client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
}
return client;
@ -139,7 +152,7 @@ export default function (pi: ExtensionAPI) {
// Call Gemini with Google Search grounding
let result: SearchResult;
try {
const ai = getClient();
const ai = await getClient();
const response = await ai.models.generateContent({
model: process.env.GEMINI_SEARCH_MODEL || "gemini-2.5-flash",
contents: params.query,

View file

@ -152,6 +152,57 @@ test("initResources syncs extensions, agents, and AGENTS.md to target dir", asyn
// 4. wizard loadStoredEnvKeys hydration
// ═══════════════════════════════════════════════════════════════════════════
test("buildResourceLoader expands ~/.pi extension directories into entry files", async () => {
const originalHome = process.env.HOME;
const tmp = mkdtempSync(join(tmpdir(), "gsd-pi-ext-test-"));
const fakeHome = join(tmp, "home");
const fakeAgentDir = join(tmp, "agent");
const piExtensionsDir = join(fakeHome, ".pi", "agent", "extensions");
mkdirSync(piExtensionsDir, { recursive: true });
mkdirSync(fakeAgentDir, { recursive: true });
writeFileSync(
join(piExtensionsDir, "top-level.ts"),
"export default function(pi){ pi.on('agent_start', () => {}); }\n",
);
const packagedDir = join(piExtensionsDir, "packaged-ext");
mkdirSync(packagedDir, { recursive: true });
writeFileSync(
join(packagedDir, "package.json"),
JSON.stringify({ pi: { extensions: ["./custom-entry.ts"] } }, null, 2),
);
writeFileSync(
join(packagedDir, "custom-entry.ts"),
"export default function(pi){ pi.on('agent_start', () => {}); }\n",
);
process.env.HOME = fakeHome;
try {
const { buildResourceLoader } = await import("../resource-loader.ts");
const loader = buildResourceLoader(fakeAgentDir);
await loader.reload();
const { extensions, errors } = loader.getExtensions();
assert.ok(
extensions.some((ext) => ext.path.endsWith("top-level.ts")),
"loads top-level ~/.pi extension files",
);
assert.ok(
extensions.some((ext) => ext.path.endsWith("packaged-ext/custom-entry.ts")),
"loads packaged ~/.pi extensions via pi.extensions manifest",
);
assert.ok(
!errors.some((err) => err.path === piExtensionsDir),
"does not try to load the ~/.pi/agent/extensions directory itself as a module",
);
} finally {
if (originalHome) process.env.HOME = originalHome; else delete process.env.HOME;
rmSync(tmp, { recursive: true, force: true });
}
});
test("loadStoredEnvKeys hydrates process.env from auth.json", async () => {
const { loadStoredEnvKeys } = await import("../wizard.ts");
const { AuthStorage } = await import("@gsd/pi-coding-agent");