diff --git a/packages/pi-coding-agent/src/core/agent-session.ts b/packages/pi-coding-agent/src/core/agent-session.ts index c300fc20f..f6fbbfc1c 100644 --- a/packages/pi-coding-agent/src/core/agent-session.ts +++ b/packages/pi-coding-agent/src/core/agent-session.ts @@ -1054,9 +1054,8 @@ export class AgentSession { }); } - // Validate API key - const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId); - if (!apiKey) { + // Validate provider readiness + if (!this._modelRegistry.isProviderRequestReady(this.model.provider)) { const isOAuth = this._modelRegistry.isUsingOAuth(this.model); if (isOAuth) { throw new Error( @@ -1614,12 +1613,11 @@ export class AgentSession { /** * Set model directly. - * Validates API key, saves to session and settings. - * @throws Error if no API key available for the model + * Validates provider readiness, saves to session and settings. + * @throws Error if provider is not ready (missing credentials for apiKey/oauth providers) */ async setModel(model: Model, options?: { persist?: boolean }): Promise { - const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId); - if (!apiKey) { + if (!this._modelRegistry.isProviderRequestReady(model.provider)) { throw new Error(`No API key for ${model.provider}/${model.id}`); } @@ -1640,30 +1638,14 @@ export class AgentSession { return this._cycleAvailableModel(direction, options); } - private async _getScopedModelsWithApiKey(): Promise; thinkingLevel?: ThinkingLevel }>> { - const apiKeysByProvider = new Map(); - const result: Array<{ model: Model; thinkingLevel?: ThinkingLevel }> = []; - - for (const scoped of this._scopedModels) { - const provider = scoped.model.provider; - let apiKey: string | undefined; - if (apiKeysByProvider.has(provider)) { - apiKey = apiKeysByProvider.get(provider); - } else { - apiKey = await this._modelRegistry.getApiKeyForProvider(provider, this.sessionId); - apiKeysByProvider.set(provider, apiKey); - } - - if (apiKey) { - result.push(scoped); - } - } - - return result; + private _getReadyScopedModels(): Array<{ model: Model; thinkingLevel?: ThinkingLevel }> { + return this._scopedModels.filter((scoped) => + this._modelRegistry.isProviderRequestReady(scoped.model.provider), + ); } private async _cycleScopedModel(direction: "forward" | "backward", options?: { persist?: boolean }): Promise { - const scopedModels = await this._getScopedModelsWithApiKey(); + const scopedModels = this._getReadyScopedModels(); if (scopedModels.length <= 1) return undefined; const currentModel = this.model; @@ -1694,11 +1676,6 @@ export class AgentSession { const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const nextModel = availableModels[nextIndex]; - const apiKey = await this._modelRegistry.getApiKey(nextModel, this.sessionId); - if (!apiKey) { - throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`); - } - const thinkingLevel = this._getThinkingLevelForModelSwitch(); await this._applyModelChange(nextModel, thinkingLevel, "cycle", options); @@ -2037,8 +2014,7 @@ export class AgentSession { refreshTools: () => this._refreshToolRegistry(), getCommands, setModel: async (model, options) => { - const key = await this.modelRegistry.getApiKey(model, this.sessionId); - if (!key) return false; + if (!this.modelRegistry.isProviderRequestReady(model.provider)) return false; await this.setModel(model, options); return true; }, @@ -2608,10 +2584,10 @@ export class AgentSession { let summaryDetails: unknown; if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { const model = this.model!; - const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId); - if (!apiKey) { + if (!this._modelRegistry.isProviderRequestReady(model.provider)) { throw new Error(`No API key for ${model.provider}`); } + const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId); const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); const result = await generateBranchSummary(entriesToSummarize, { model, diff --git a/packages/pi-coding-agent/src/core/compaction-orchestrator.ts b/packages/pi-coding-agent/src/core/compaction-orchestrator.ts index 6415f8098..dccf3c0f7 100644 --- a/packages/pi-coding-agent/src/core/compaction-orchestrator.ts +++ b/packages/pi-coding-agent/src/core/compaction-orchestrator.ts @@ -94,10 +94,10 @@ export class CompactionOrchestrator { throw new Error("No model selected"); } - const apiKey = await this._deps.modelRegistry.getApiKey(model, this._deps.getSessionId()); - if (!apiKey) { + if (!this._deps.modelRegistry.isProviderRequestReady(model.provider)) { throw new Error(`No API key for ${model.provider}`); } + const apiKey = await this._deps.modelRegistry.getApiKey(model, this._deps.getSessionId()); const pathEntries = this._deps.sessionManager.getBranch(); const settings = this._deps.settingsManager.getCompactionSettings(); @@ -299,11 +299,11 @@ export class CompactionOrchestrator { return; } - const apiKey = await this._deps.modelRegistry.getApiKey(model, this._deps.getSessionId()); - if (!apiKey) { + if (!this._deps.modelRegistry.isProviderRequestReady(model.provider)) { this._deps.emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); return; } + const apiKey = await this._deps.modelRegistry.getApiKey(model, this._deps.getSessionId()); const pathEntries = this._deps.sessionManager.getBranch(); const preparation = prepareCompaction(pathEntries, settings); diff --git a/packages/pi-coding-agent/src/core/compaction/branch-summarization.ts b/packages/pi-coding-agent/src/core/compaction/branch-summarization.ts index c028dbbd8..cf9c8bc01 100644 --- a/packages/pi-coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/pi-coding-agent/src/core/compaction/branch-summarization.ts @@ -64,8 +64,8 @@ export interface CollectEntriesResult { export interface GenerateBranchSummaryOptions { /** Model to use for summarization */ model: Model; - /** API key for the model */ - apiKey: string; + /** API key for the model. Undefined for externalCli/none providers. */ + apiKey: string | undefined; /** Abort signal for cancellation */ signal: AbortSignal; /** Optional custom instructions for summarization */ diff --git a/packages/pi-coding-agent/src/core/compaction/compaction.ts b/packages/pi-coding-agent/src/core/compaction/compaction.ts index 13e00a6d1..66cdbcfb3 100644 --- a/packages/pi-coding-agent/src/core/compaction/compaction.ts +++ b/packages/pi-coding-agent/src/core/compaction/compaction.ts @@ -497,7 +497,7 @@ export async function generateSummary( currentMessages: AgentMessage[], model: Model, reserveTokens: number, - apiKey: string, + apiKey: string | undefined, signal?: AbortSignal, customInstructions?: string, previousSummary?: string, @@ -660,7 +660,7 @@ Be concise. Focus on what's needed to understand the kept suffix.`; export async function compact( preparation: CompactionPreparation, model: Model, - apiKey: string, + apiKey: string | undefined, customInstructions?: string, signal?: AbortSignal, ): Promise { @@ -732,7 +732,7 @@ async function generateTurnPrefixSummary( messages: AgentMessage[], model: Model, reserveTokens: number, - apiKey: string, + apiKey: string | undefined, signal?: AbortSignal, ): Promise { const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix diff --git a/packages/pi-coding-agent/src/core/extensions/index.ts b/packages/pi-coding-agent/src/core/extensions/index.ts index 0c86d2d72..5726741a4 100644 --- a/packages/pi-coding-agent/src/core/extensions/index.ts +++ b/packages/pi-coding-agent/src/core/extensions/index.ts @@ -94,6 +94,11 @@ export type { // Provider Registration ProviderConfig, ProviderModelConfig, + LifecycleHookContext, + LifecycleHookHandler, + LifecycleHookMap, + LifecycleHookPhase, + LifecycleHookScope, ReadToolCallEvent, ReadToolResultEvent, // Commands diff --git a/packages/pi-coding-agent/src/core/extensions/loader.ts b/packages/pi-coding-agent/src/core/extensions/loader.ts index b87497138..24a4385b5 100644 --- a/packages/pi-coding-agent/src/core/extensions/loader.ts +++ b/packages/pi-coding-agent/src/core/extensions/loader.ts @@ -42,6 +42,7 @@ import type { Extension, ExtensionAPI, ExtensionFactory, + LifecycleHookHandler, ExtensionRuntime, LoadExtensionsResult, MessageRenderer, @@ -463,6 +464,22 @@ function createExtensionAPI( extension.commands.set(name, { name, ...options }); }, + registerBeforeInstall(handler: LifecycleHookHandler): void { + extension.lifecycleHooks.beforeInstall.push(handler); + }, + + registerAfterInstall(handler: LifecycleHookHandler): void { + extension.lifecycleHooks.afterInstall.push(handler); + }, + + registerBeforeRemove(handler: LifecycleHookHandler): void { + extension.lifecycleHooks.beforeRemove.push(handler); + }, + + registerAfterRemove(handler: LifecycleHookHandler): void { + extension.lifecycleHooks.afterRemove.push(handler); + }, + registerShortcut( shortcut: KeyId, options: { @@ -683,6 +700,12 @@ function createExtension(extensionPath: string, resolvedPath: string): Extension commands: new Map(), flags: new Map(), shortcuts: new Map(), + lifecycleHooks: { + beforeInstall: [], + afterInstall: [], + beforeRemove: [], + afterRemove: [], + }, }; } diff --git a/packages/pi-coding-agent/src/core/extensions/types.ts b/packages/pi-coding-agent/src/core/extensions/types.ts index 22b05a1a6..30a689c91 100644 --- a/packages/pi-coding-agent/src/core/extensions/types.ts +++ b/packages/pi-coding-agent/src/core/extensions/types.ts @@ -949,6 +949,33 @@ export interface RegisteredCommand { handler: (args: string, ctx: ExtensionCommandContext) => Promise; } +export type LifecycleHookScope = "user" | "project"; +export type LifecycleHookPhase = "beforeInstall" | "afterInstall" | "beforeRemove" | "afterRemove"; + +export interface LifecycleHookContext { + /** Lifecycle phase currently being executed. */ + phase: LifecycleHookPhase; + /** Package source string passed to install (npm:, git:, https://, local path). */ + source: string; + /** Resolved installed package path (or resolved local path), when available for this phase. */ + installedPath?: string; + /** Where the package was installed. */ + scope: LifecycleHookScope; + /** Current working directory for the install invocation. */ + cwd: string; + /** Whether install is running in an interactive TTY. */ + interactive: boolean; + /** Info-level logging sink for install output. */ + log(message: string): void; + /** Warning-level logging sink for install output. */ + warn(message: string): void; + /** Error-level logging sink for install output. */ + error(message: string): void; +} + +export type LifecycleHookHandler = (ctx: LifecycleHookContext) => Promise | void; +export type LifecycleHookMap = Record; + // ============================================================================ // Extension API // ============================================================================ @@ -1019,6 +1046,18 @@ export interface ExtensionAPI { /** Register a custom command. */ registerCommand(name: string, options: Omit): void; + /** Register a lifecycle hook run before package installation starts. */ + registerBeforeInstall(handler: LifecycleHookHandler): void; + + /** Register a lifecycle hook run after package installation completes. */ + registerAfterInstall(handler: LifecycleHookHandler): void; + + /** Register a lifecycle hook run before package removal starts. */ + registerBeforeRemove(handler: LifecycleHookHandler): void; + + /** Register a lifecycle hook run after package removal completes. */ + registerAfterRemove(handler: LifecycleHookHandler): void; + /** Register a keyboard shortcut. */ registerShortcut( shortcut: KeyId, @@ -1201,6 +1240,10 @@ export interface ExtensionAPI { /** Configuration for registering a provider via pi.registerProvider(). */ export interface ProviderConfig { + /** Auth behavior for provider availability and request key handling. Defaults to "apiKey". */ + authMode?: "apiKey" | "oauth" | "externalCli" | "none"; + /** Optional readiness check. Return false if the provider cannot accept requests (e.g., CLI not authenticated, API key invalid). Called before default auth checks. */ + isReady?: () => boolean; /** Base URL for the API endpoint. Required when defining models. */ baseUrl?: string; /** API key or environment variable name. Required when defining models (unless oauth provided). */ @@ -1382,6 +1425,7 @@ export interface Extension { commands: Map; flags: Map; shortcuts: Map; + lifecycleHooks: LifecycleHookMap; } /** Result of loading extensions. */ diff --git a/packages/pi-coding-agent/src/core/fallback-resolver.test.ts b/packages/pi-coding-agent/src/core/fallback-resolver.test.ts index c62f5d473..f454d1c8e 100644 --- a/packages/pi-coding-agent/src/core/fallback-resolver.test.ts +++ b/packages/pi-coding-agent/src/core/fallback-resolver.test.ts @@ -38,6 +38,7 @@ function createResolver(overrides?: { enabled?: boolean; isProviderAvailable?: (provider: string) => boolean; hasAuth?: (provider: string) => boolean; + isProviderRequestReady?: (provider: string) => boolean; find?: (provider: string, modelId: string) => Model | undefined; }) { const settingsManager = { @@ -60,6 +61,7 @@ function createResolver(overrides?: { if (provider === "openai" && modelId === "gpt-4.1") return openaiModel; return undefined; }), + isProviderRequestReady: overrides?.isProviderRequestReady ?? overrides?.hasAuth ?? (() => true), } as unknown as ModelRegistry; return { resolver: new FallbackResolver(settingsManager, authStorage, modelRegistry), authStorage }; @@ -122,9 +124,9 @@ describe("FallbackResolver — findFallback", () => { assert.equal(result, null); }); - it("skips providers without auth", async () => { + it("skips providers that are not request-ready", async () => { const { resolver } = createResolver({ - hasAuth: (provider: string) => provider !== "alibaba", + isProviderRequestReady: (provider: string) => provider !== "alibaba", }); const result = await resolver.findFallback(zaiModel, "quota_exhausted"); @@ -133,6 +135,17 @@ describe("FallbackResolver — findFallback", () => { assert.equal(result!.model.provider, "openai"); }); + it("allows fallback to external-cli style providers without stored auth", async () => { + const { resolver } = createResolver({ + hasAuth: () => false, + isProviderRequestReady: (provider: string) => provider === "alibaba", + }); + + const result = await resolver.findFallback(zaiModel, "quota_exhausted"); + assert.notEqual(result, null); + assert.equal(result!.model.provider, "alibaba"); + }); + it("skips providers with no model in registry", async () => { const { resolver } = createResolver({ find: (provider: string, modelId: string) => { diff --git a/packages/pi-coding-agent/src/core/fallback-resolver.ts b/packages/pi-coding-agent/src/core/fallback-resolver.ts index 5d6b61499..e390f2038 100644 --- a/packages/pi-coding-agent/src/core/fallback-resolver.ts +++ b/packages/pi-coding-agent/src/core/fallback-resolver.ts @@ -149,9 +149,8 @@ export class FallbackResolver { const model = this.modelRegistry.find(entry.provider, entry.model); if (!model) continue; - // Check if API key is available - const hasAuth = this.authStorage.hasAuth(entry.provider); - if (!hasAuth) continue; + // Check if provider is request-ready for fallback (authMode-aware) + if (!this.modelRegistry.isProviderRequestReady(entry.provider)) continue; return { model, diff --git a/packages/pi-coding-agent/src/core/lifecycle-hooks.ts b/packages/pi-coding-agent/src/core/lifecycle-hooks.ts new file mode 100644 index 000000000..a31ed8eab --- /dev/null +++ b/packages/pi-coding-agent/src/core/lifecycle-hooks.ts @@ -0,0 +1,274 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseGitUrl } from "../utils/git.js"; +import { + importExtensionModule, + loadExtensions, + type LifecycleHookContext, + type LifecycleHookMap, + type LifecycleHookHandler, + type LifecycleHookPhase, + type LifecycleHookScope, +} from "./extensions/index.js"; +import type { DefaultPackageManager } from "./package-manager.js"; + +interface ExtensionManifest { + dependencies?: { + runtime?: string[]; + }; +} + +export interface PackageLifecycleHooksOptions { + source: string; + local: boolean; + cwd: string; + agentDir: string; + appName: string; + packageManager: DefaultPackageManager; + stdout: NodeJS.WriteStream; + stderr: NodeJS.WriteStream; +} + +export type LifecycleHooksTarget = "source" | "installed"; + +export interface PrepareLifecycleHooksOptions { + verifyRuntimeDependencies?: boolean; +} + +export interface LifecycleHooksRunResult { + phase: LifecycleHookPhase; + hooksRun: number; + hookErrors: number; + legacyHooksRun: number; + entryPathCount: number; + skipped: boolean; +} + +interface LoadedLifecycleHooks { + source: string; + scope: LifecycleHookScope; + installedPath?: string; + cwd: string; + stdout: NodeJS.WriteStream; + stderr: NodeJS.WriteStream; + entryPaths: string[]; + hooksByPath: Map; +} + +function toScope(local: boolean): LifecycleHookScope { + return local ? "project" : "user"; +} + +function readManifestRuntimeDeps(dir: string): string[] { + const manifestPath = join(dir, "extension-manifest.json"); + if (!existsSync(manifestPath)) return []; + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as ExtensionManifest; + return manifest.dependencies?.runtime?.filter((dep): dep is string => typeof dep === "string") ?? []; + } catch { + return []; + } +} + +function collectRuntimeDependencies(installedPath: string, entryPaths: string[]): string[] { + const deps = new Set(); + const candidateDirs = new Set([installedPath, ...entryPaths.map((entryPath) => dirname(entryPath))]); + for (const dir of candidateDirs) { + for (const dep of readManifestRuntimeDeps(dir)) { + deps.add(dep); + } + } + return Array.from(deps); +} + +function verifyRuntimeDependencies(runtimeDeps: string[], source: string, appName: string): void { + const missing: string[] = []; + for (const dep of runtimeDeps) { + const result = spawnSync(dep, ["--version"], { encoding: "utf-8", timeout: 5000 }); + if (result.error || result.status !== 0) { + missing.push(dep); + } + } + if (missing.length === 0) return; + throw new Error( + `Missing runtime dependencies: ${missing.join(", ")}.\n` + + `Install them and retry: ${appName} install ${source}`, + ); +} + +function resolveLocalSourcePath(source: string, cwd: string): string | undefined { + const trimmed = source.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith("npm:")) return undefined; + if (parseGitUrl(trimmed)) return undefined; + + let normalized = trimmed; + if (normalized === "~") { + normalized = homedir(); + } else if (normalized.startsWith("~/")) { + normalized = join(homedir(), normalized.slice(2)); + } + + const absolutePath = resolve(cwd, normalized); + return existsSync(absolutePath) ? absolutePath : undefined; +} + +async function resolveEntryPathsFromTarget( + options: PackageLifecycleHooksOptions, + target: LifecycleHooksTarget, + scope: LifecycleHookScope, +): Promise<{ entryPaths: string[]; installedPath?: string }> { + if (target === "source") { + const localSourcePath = resolveLocalSourcePath(options.source, options.cwd); + if (!localSourcePath) return { entryPaths: [] }; + const resolved = await options.packageManager.resolveExtensionSources([localSourcePath], { local: true }); + const entryPaths = resolved.extensions.filter((resource) => resource.enabled).map((resource) => resource.path); + return { entryPaths, installedPath: localSourcePath }; + } + + const installedPath = options.packageManager.getInstalledPath(options.source, scope); + if (!installedPath) return { entryPaths: [] }; + const resolved = await options.packageManager.resolveExtensionSources([installedPath], { local: true }); + const entryPaths = resolved.extensions.filter((resource) => resource.enabled).map((resource) => resource.path); + return { entryPaths, installedPath }; +} + +export async function prepareLifecycleHooks( + options: PackageLifecycleHooksOptions, + target: LifecycleHooksTarget, + prepareOptions?: PrepareLifecycleHooksOptions, +): Promise { + const scope = toScope(options.local); + const { entryPaths, installedPath } = await resolveEntryPathsFromTarget(options, target, scope); + if (entryPaths.length === 0) { + return null; + } + + if (prepareOptions?.verifyRuntimeDependencies && installedPath) { + const runtimeDeps = collectRuntimeDependencies(installedPath, entryPaths); + verifyRuntimeDependencies(runtimeDeps, options.source, options.appName); + } + + const loaded = await loadExtensions(entryPaths, options.cwd); + for (const { path, error } of loaded.errors) { + options.stderr.write(`[lifecycle-hooks] Failed to load extension "${path}": ${error}\n`); + } + + const hooksByPath = new Map(); + for (const extension of loaded.extensions) { + hooksByPath.set(extension.path, extension.lifecycleHooks); + } + + return { + source: options.source, + scope, + installedPath, + cwd: options.cwd, + stdout: options.stdout, + stderr: options.stderr, + entryPaths, + hooksByPath, + }; +} + +async function runHookSafe( + hook: LifecycleHookHandler, + context: LifecycleHookContext, + stderr: NodeJS.WriteStream, +): Promise { + try { + await hook(context); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + stderr.write(`[lifecycle-hooks:${context.phase}] Hook failed: ${message}\n`); + return false; + } +} + +function getLegacyExportCandidates(phase: LifecycleHookPhase): string[] { + return [phase]; +} + +async function runLegacyExportHook( + entryPath: string, + phase: LifecycleHookPhase, + context: LifecycleHookContext, +): Promise { + try { + const module = await importExtensionModule>(import.meta.url, pathToFileURL(entryPath).href); + for (const exportName of getLegacyExportCandidates(phase)) { + const candidate = module[exportName]; + if (typeof candidate === "function") { + return candidate as LifecycleHookHandler; + } + } + return null; + } catch { + return null; + } +} + +export async function runLifecycleHooks( + loaded: LoadedLifecycleHooks | null, + phase: LifecycleHookPhase, +): Promise { + if (!loaded) { + return { + phase, + hooksRun: 0, + hookErrors: 0, + legacyHooksRun: 0, + entryPathCount: 0, + skipped: true, + }; + } + + const context: LifecycleHookContext = { + phase, + source: loaded.source, + installedPath: loaded.installedPath, + scope: loaded.scope, + cwd: loaded.cwd, + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), + log: (message) => loaded.stdout.write(`${message}\n`), + warn: (message) => loaded.stderr.write(`${message}\n`), + error: (message) => loaded.stderr.write(`${message}\n`), + }; + + let hooksRun = 0; + let hookErrors = 0; + let legacyHooksRun = 0; + + for (const entryPath of loaded.entryPaths) { + const hookMap = loaded.hooksByPath.get(entryPath); + const registeredHooks = hookMap?.[phase] ?? []; + if (registeredHooks.length > 0) { + for (const hook of registeredHooks) { + hooksRun += 1; + const ok = await runHookSafe(hook, context, loaded.stderr); + if (!ok) hookErrors += 1; + } + continue; + } + + const legacyHook = await runLegacyExportHook(entryPath, phase, context); + if (!legacyHook) continue; + + legacyHooksRun += 1; + const ok = await runHookSafe(legacyHook, context, loaded.stderr); + if (!ok) hookErrors += 1; + } + + return { + phase, + hooksRun, + hookErrors, + legacyHooksRun, + entryPathCount: loaded.entryPaths.length, + skipped: false, + }; +} diff --git a/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts b/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts new file mode 100644 index 000000000..eba74cecc --- /dev/null +++ b/packages/pi-coding-agent/src/core/model-registry-auth-mode.test.ts @@ -0,0 +1,288 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Api, Model } from "@gsd/pi-ai"; +import type { AuthStorage } from "./auth-storage.js"; +import { ModelRegistry } from "./model-registry.js"; + +function createRegistry(hasAuthFn?: (provider: string) => boolean): ModelRegistry { + const authStorage = { + setFallbackResolver: () => {}, + onCredentialChange: () => {}, + getOAuthProviders: () => [], + get: () => undefined, + hasAuth: hasAuthFn ?? (() => false), + getApiKey: async () => undefined, + } as unknown as AuthStorage; + + return new ModelRegistry(authStorage, undefined); +} + +function createProviderModel(id: string): NonNullable[1]["models"]>[number] { + return { + id, + name: id, + api: "openai-completions", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }; +} + +function findModel(registry: ModelRegistry, provider: string, id: string): Model | undefined { + return registry.getAvailable().find((m) => m.provider === provider && m.id === id); +} + +// ─── Registration ───────────────────────────────────────────────────────────── + +describe("ModelRegistry authMode — registration", () => { + it("registers externalCli provider without apiKey/oauth", () => { + const registry = createRegistry(); + assert.doesNotThrow(() => { + registry.registerProvider("cli-provider", { + authMode: "externalCli", + baseUrl: "https://cli.local", + api: "openai-completions", + models: [createProviderModel("cli-model")], + }); + }); + }); + + it("registers none provider without apiKey/oauth", () => { + const registry = createRegistry(); + assert.doesNotThrow(() => { + registry.registerProvider("none-provider", { + authMode: "none", + baseUrl: "http://localhost:11434", + api: "openai-completions", + models: [createProviderModel("local-model")], + }); + }); + }); + + it("rejects apiKey provider without apiKey or oauth", () => { + const registry = createRegistry(); + assert.throws(() => { + registry.registerProvider("apikey-provider", { + authMode: "apiKey", + baseUrl: "https://api.local", + api: "openai-completions", + models: [createProviderModel("model")], + }); + }); + }); + + it("rejects provider with no authMode and no apiKey/oauth (defaults to apiKey)", () => { + const registry = createRegistry(); + assert.throws(() => { + registry.registerProvider("bare-provider", { + baseUrl: "https://api.local", + api: "openai-completions", + models: [createProviderModel("model")], + }); + }); + }); +}); + +// ─── getProviderAuthMode ────────────────────────────────────────────────────── + +describe("ModelRegistry authMode — getProviderAuthMode", () => { + it("returns apiKey for unregistered (built-in) providers", () => { + const registry = createRegistry(); + assert.equal(registry.getProviderAuthMode("anthropic"), "apiKey"); + }); + + it("returns explicit authMode when set", () => { + const registry = createRegistry(); + registry.registerProvider("cli", { + authMode: "externalCli", + baseUrl: "https://cli.local", + api: "openai-completions", + models: [createProviderModel("m")], + }); + assert.equal(registry.getProviderAuthMode("cli"), "externalCli"); + }); + + it("returns none when authMode is none", () => { + const registry = createRegistry(); + registry.registerProvider("local", { + authMode: "none", + baseUrl: "http://localhost:11434", + api: "openai-completions", + models: [createProviderModel("m")], + }); + assert.equal(registry.getProviderAuthMode("local"), "none"); + }); +}); + +// ─── isProviderRequestReady ─────────────────────────────────────────────────── + +describe("ModelRegistry authMode — isProviderRequestReady", () => { + it("returns true for externalCli without stored auth", () => { + const registry = createRegistry(() => false); + registry.registerProvider("cli", { + authMode: "externalCli", + baseUrl: "https://cli.local", + api: "openai-completions", + models: [createProviderModel("m")], + }); + assert.equal(registry.isProviderRequestReady("cli"), true); + }); + + it("returns true for none without stored auth", () => { + const registry = createRegistry(() => false); + registry.registerProvider("local", { + authMode: "none", + baseUrl: "http://localhost:11434", + api: "openai-completions", + models: [createProviderModel("m")], + }); + assert.equal(registry.isProviderRequestReady("local"), true); + }); + + it("returns false for apiKey provider without stored auth", () => { + const registry = createRegistry(() => false); + assert.equal(registry.isProviderRequestReady("anthropic"), false); + }); + + it("returns true for apiKey provider with stored auth", () => { + const registry = createRegistry(() => true); + assert.equal(registry.isProviderRequestReady("anthropic"), true); + }); +}); + +// ─── isReady callback ───────────────────────────────────────────────────────── + +describe("ModelRegistry authMode — isReady callback", () => { + it("calls isReady and returns its result for externalCli provider", () => { + const registry = createRegistry(() => false); + registry.registerProvider("cli-down", { + authMode: "externalCli", + baseUrl: "https://cli.local", + api: "openai-completions", + isReady: () => false, + models: [createProviderModel("m")], + }); + assert.equal(registry.isProviderRequestReady("cli-down"), false); + }); + + it("calls isReady for apiKey provider (overrides hasAuth)", () => { + const registry = createRegistry(() => true); + registry.registerProvider("strict-provider", { + apiKey: "MY_KEY", + baseUrl: "https://api.local", + api: "openai-completions", + isReady: () => false, + models: [createProviderModel("m")], + }); + assert.equal(registry.isProviderRequestReady("strict-provider"), false); + }); + + it("isReady returning true makes provider available", () => { + const registry = createRegistry(() => false); + registry.registerProvider("healthy-cli", { + authMode: "externalCli", + baseUrl: "https://cli.local", + api: "openai-completions", + isReady: () => true, + models: [createProviderModel("m")], + }); + assert.equal(registry.isProviderRequestReady("healthy-cli"), true); + }); + + it("falls through to default behavior when isReady not provided", () => { + const registry = createRegistry(() => false); + registry.registerProvider("no-callback", { + authMode: "externalCli", + baseUrl: "https://cli.local", + api: "openai-completions", + models: [createProviderModel("m")], + }); + // externalCli without isReady → true (default) + assert.equal(registry.isProviderRequestReady("no-callback"), true); + }); +}); + +// ─── getAvailable ───────────────────────────────────────────────────────────── + +describe("ModelRegistry authMode — getAvailable", () => { + it("includes externalCli models without stored auth", () => { + const registry = createRegistry(() => false); + registry.registerProvider("cli", { + authMode: "externalCli", + baseUrl: "https://cli.local", + api: "openai-completions", + models: [createProviderModel("cli-model")], + }); + assert.ok(findModel(registry, "cli", "cli-model")); + }); + + it("includes none models without stored auth", () => { + const registry = createRegistry(() => false); + registry.registerProvider("local", { + authMode: "none", + baseUrl: "http://localhost:11434", + api: "openai-completions", + models: [createProviderModel("local-model")], + }); + assert.ok(findModel(registry, "local", "local-model")); + }); + + it("excludes externalCli models when isReady returns false", () => { + const registry = createRegistry(() => false); + registry.registerProvider("cli-down", { + authMode: "externalCli", + baseUrl: "https://cli.local", + api: "openai-completions", + isReady: () => false, + models: [createProviderModel("m")], + }); + assert.equal(findModel(registry, "cli-down", "m"), undefined); + }); + + it("excludes apiKey models without stored auth", () => { + const registry = createRegistry(() => false); + // Built-in providers have no registeredProviders entry, so authMode defaults to apiKey + // getAvailable filters by isProviderRequestReady → hasAuth → false + const available = registry.getAvailable(); + // No models should be available since hasAuth returns false for everything + assert.equal(available.length, 0); + }); +}); + +// ─── getApiKey ──────────────────────────────────────────────────────────────── + +describe("ModelRegistry authMode — getApiKey", () => { + it("returns undefined for externalCli provider", async () => { + const registry = createRegistry(); + registry.registerProvider("cli", { + authMode: "externalCli", + baseUrl: "https://cli.local", + api: "openai-completions", + models: [createProviderModel("m")], + }); + const model = registry.getAll().find((m) => m.provider === "cli")!; + assert.equal(await registry.getApiKey(model), undefined); + }); + + it("returns undefined for none provider", async () => { + const registry = createRegistry(); + registry.registerProvider("local", { + authMode: "none", + baseUrl: "http://localhost:11434", + api: "openai-completions", + models: [createProviderModel("m")], + }); + const model = registry.getAll().find((m) => m.provider === "local")!; + assert.equal(await registry.getApiKey(model), undefined); + }); + + it("delegates to authStorage for apiKey provider", async () => { + const registry = createRegistry(); + // authStorage.getApiKey returns undefined (no key configured) + // For apiKey providers this is an expected "no key" response, not early exit + const key = await registry.getApiKeyForProvider("anthropic"); + assert.equal(key, undefined); + }); +}); diff --git a/packages/pi-coding-agent/src/core/model-registry.ts b/packages/pi-coding-agent/src/core/model-registry.ts index b6d161c89..dfc6c8580 100644 --- a/packages/pi-coding-agent/src/core/model-registry.ts +++ b/packages/pi-coding-agent/src/core/model-registry.ts @@ -128,6 +128,8 @@ ajv.addSchema(ModelsConfigSchema, "ModelsConfig"); type ModelsConfig = Static; +export type ProviderAuthMode = "apiKey" | "oauth" | "externalCli" | "none"; + /** Provider override config (baseUrl, headers, apiKey) without custom models */ interface ProviderOverride { baseUrl?: string; @@ -513,7 +515,31 @@ export class ModelRegistry { * This is a fast check that doesn't refresh OAuth tokens. */ getAvailable(): Model[] { - return this.models.filter((m) => this.authStorage.hasAuth(m.provider)); + return this.models.filter((m) => this.isProviderRequestReady(m.provider)); + } + + /** + * Get auth mode for a provider. + * Defaults to "apiKey" for built-ins and providers without explicit mode. + */ + getProviderAuthMode(provider: string): ProviderAuthMode { + const config = this.registeredProviders.get(provider); + if (!config) return "apiKey"; + if (config.authMode) return config.authMode; + if (config.oauth) return "oauth"; + if (config.apiKey) return "apiKey"; + return "apiKey"; + } + + /** + * Whether a provider can be used for requests/fallback without hard auth gating. + */ + isProviderRequestReady(provider: string): boolean { + const config = this.registeredProviders.get(provider); + if (config?.isReady) return config.isReady(); + const authMode = this.getProviderAuthMode(provider); + if (authMode === "externalCli" || authMode === "none") return true; + return this.authStorage.hasAuth(provider); } /** @@ -525,17 +551,23 @@ export class ModelRegistry { /** * Get API key for a model. + * Returns undefined for externalCli/none providers (no key needed). * @param sessionId - Optional session ID for sticky credential selection */ async getApiKey(model: Model, sessionId?: string): Promise { + const authMode = this.getProviderAuthMode(model.provider); + if (authMode === "externalCli" || authMode === "none") return undefined; return this.authStorage.getApiKey(model.provider, sessionId); } /** * Get API key for a provider. + * Returns undefined for externalCli/none providers (no key needed). * @param sessionId - Optional session ID for sticky credential selection */ async getApiKeyForProvider(provider: string, sessionId?: string): Promise { + const authMode = this.getProviderAuthMode(provider); + if (authMode === "externalCli" || authMode === "none") return undefined; return this.authStorage.getApiKey(provider, sessionId); } @@ -614,7 +646,8 @@ export class ModelRegistry { if (!config.baseUrl) { throw new Error(`Provider ${providerName}: "baseUrl" is required when defining models.`); } - if (!config.apiKey && !config.oauth) { + const authMode = config.authMode ?? (config.oauth ? "oauth" : config.apiKey ? "apiKey" : "apiKey"); + if (authMode === "apiKey" && !config.apiKey && !config.oauth) { throw new Error(`Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`); } @@ -702,7 +735,7 @@ export class ModelRegistry { try { const apiKey = await this.authStorage.getApiKey(providerName); - if (!apiKey && providerName !== "ollama") continue; + if (!apiKey && !this.isProviderRequestReady(providerName)) continue; const models = await adapter.fetchModels(apiKey ?? "", undefined); this.discoveryCache.set(providerName, models); @@ -780,6 +813,9 @@ export class ModelRegistry { * Input type for registerProvider API. */ export interface ProviderConfigInput { + authMode?: ProviderAuthMode; + /** Optional readiness check. Called by isProviderRequestReady() before default auth checks. */ + isReady?: () => boolean; baseUrl?: string; apiKey?: string; api?: Api; diff --git a/packages/pi-coding-agent/src/core/package-commands.test.ts b/packages/pi-coding-agent/src/core/package-commands.test.ts new file mode 100644 index 000000000..0f87fb57f --- /dev/null +++ b/packages/pi-coding-agent/src/core/package-commands.test.ts @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Writable } from "node:stream"; +import { describe, it } from "node:test"; +import { runPackageCommand } from "./package-commands.js"; + +function createCaptureStream() { + let output = ""; + const stream = new Writable({ + write(chunk, _encoding, callback) { + output += chunk.toString(); + callback(); + }, + }) as unknown as NodeJS.WriteStream; + return { stream, getOutput: () => output }; +} + +function writePackage(root: string, files: Record): void { + for (const [relPath, content] of Object.entries(files)) { + const abs = join(root, relPath); + mkdirSync(join(abs, ".."), { recursive: true }); + writeFileSync(abs, content, "utf-8"); + } +} + +describe("runPackageCommand lifecycle hooks", () => { + it("executes registered beforeInstall and afterInstall handlers for local packages", async () => { + const root = mkdtempSync(join(tmpdir(), "pi-lifecycle-install-")); + const cwd = join(root, "cwd"); + const agentDir = join(root, "agent"); + const extensionDir = join(root, "ext-registered"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(extensionDir, { recursive: true }); + + try { + writePackage(extensionDir, { + "package.json": JSON.stringify({ + name: "ext-registered", + type: "module", + pi: { extensions: ["./index.js"] }, + }), + "index.js": ` + import { writeFileSync } from "node:fs"; + import { join } from "node:path"; + export default function (pi) { + pi.registerBeforeInstall((ctx) => { + writeFileSync(join(ctx.installedPath, "before-install-ran.txt"), "ok", "utf-8"); + }); + pi.registerAfterInstall((ctx) => { + writeFileSync(join(ctx.installedPath, "after-install-ran.txt"), "ok", "utf-8"); + }); + } + `, + }); + + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + const result = await runPackageCommand({ + appName: "pi", + args: ["install", extensionDir], + cwd, + agentDir, + stdout: stdout.stream, + stderr: stderr.stream, + }); + + assert.equal(result.handled, true); + assert.equal(result.exitCode, 0); + assert.equal(readFileSync(join(extensionDir, "before-install-ran.txt"), "utf-8"), "ok"); + assert.equal(readFileSync(join(extensionDir, "after-install-ran.txt"), "utf-8"), "ok"); + assert.ok(stdout.getOutput().includes(`Installed ${extensionDir}`)); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("runs legacy named lifecycle hooks when no registered hooks exist", async () => { + const root = mkdtempSync(join(tmpdir(), "pi-lifecycle-legacy-")); + const cwd = join(root, "cwd"); + const agentDir = join(root, "agent"); + const extensionDir = join(root, "ext-legacy"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(extensionDir, { recursive: true }); + + try { + writePackage(extensionDir, { + "package.json": JSON.stringify({ + name: "ext-legacy", + type: "module", + pi: { extensions: ["./index.js"] }, + }), + "index.js": ` + import { writeFileSync } from "node:fs"; + import { join } from "node:path"; + export default function () {} + export async function beforeInstall(ctx) { + writeFileSync(join(ctx.installedPath, "legacy-before-install.txt"), "ok", "utf-8"); + } + export async function afterInstall(ctx) { + writeFileSync(join(ctx.installedPath, "legacy-after-install.txt"), "ok", "utf-8"); + } + export async function beforeRemove(ctx) { + writeFileSync(join(ctx.installedPath, "legacy-before-remove.txt"), "ok", "utf-8"); + } + export async function afterRemove(ctx) { + writeFileSync(join(ctx.installedPath, "legacy-after-remove.txt"), "ok", "utf-8"); + } + `, + }); + + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + const installResult = await runPackageCommand({ + appName: "pi", + args: ["install", extensionDir], + cwd, + agentDir, + stdout: stdout.stream, + stderr: stderr.stream, + }); + + assert.equal(installResult.handled, true); + assert.equal(installResult.exitCode, 0); + assert.equal(readFileSync(join(extensionDir, "legacy-before-install.txt"), "utf-8"), "ok"); + assert.equal(readFileSync(join(extensionDir, "legacy-after-install.txt"), "utf-8"), "ok"); + + const removeResult = await runPackageCommand({ + appName: "pi", + args: ["remove", extensionDir], + cwd, + agentDir, + stdout: stdout.stream, + stderr: stderr.stream, + }); + + assert.equal(removeResult.handled, true); + assert.equal(removeResult.exitCode, 0); + assert.equal(readFileSync(join(extensionDir, "legacy-before-remove.txt"), "utf-8"), "ok"); + assert.equal(readFileSync(join(extensionDir, "legacy-after-remove.txt"), "utf-8"), "ok"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("skips lifecycle phases with no hooks declared", async () => { + const root = mkdtempSync(join(tmpdir(), "pi-lifecycle-skip-")); + const cwd = join(root, "cwd"); + const agentDir = join(root, "agent"); + const extensionDir = join(root, "ext-empty"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(extensionDir, { recursive: true }); + + try { + writePackage(extensionDir, { + "package.json": JSON.stringify({ + name: "ext-empty", + type: "module", + pi: { extensions: ["./index.js"] }, + }), + "index.js": `export default function () {}`, + }); + + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + const installResult = await runPackageCommand({ + appName: "pi", + args: ["install", extensionDir], + cwd, + agentDir, + stdout: stdout.stream, + stderr: stderr.stream, + }); + assert.equal(installResult.handled, true); + assert.equal(installResult.exitCode, 0); + + const removeResult = await runPackageCommand({ + appName: "pi", + args: ["remove", extensionDir], + cwd, + agentDir, + stdout: stdout.stream, + stderr: stderr.stream, + }); + assert.equal(removeResult.handled, true); + assert.equal(removeResult.exitCode, 0); + assert.equal(stderr.getOutput().includes("Hook failed"), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("fails install when manifest runtime dependency is missing", async () => { + const root = mkdtempSync(join(tmpdir(), "pi-lifecycle-deps-")); + const cwd = join(root, "cwd"); + const agentDir = join(root, "agent"); + const extensionDir = join(root, "ext-runtime-deps"); + mkdirSync(cwd, { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(extensionDir, { recursive: true }); + + try { + writePackage(extensionDir, { + "package.json": JSON.stringify({ + name: "ext-runtime-deps", + type: "module", + pi: { extensions: ["./index.js"] }, + }), + "index.js": `export default function () {}`, + "extension-manifest.json": JSON.stringify({ + id: "ext-runtime-deps", + name: "Runtime Dep Test", + version: "1.0.0", + dependencies: { runtime: ["__definitely_missing_command_for_test__"] }, + }), + }); + + const stdout = createCaptureStream(); + const stderr = createCaptureStream(); + const result = await runPackageCommand({ + appName: "pi", + args: ["install", extensionDir], + cwd, + agentDir, + stdout: stdout.stream, + stderr: stderr.stream, + }); + + assert.equal(result.handled, true); + assert.equal(result.exitCode, 1); + assert.ok(stderr.getOutput().includes("Missing runtime dependencies")); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/pi-coding-agent/src/core/package-commands.ts b/packages/pi-coding-agent/src/core/package-commands.ts new file mode 100644 index 000000000..273da7145 --- /dev/null +++ b/packages/pi-coding-agent/src/core/package-commands.ts @@ -0,0 +1,310 @@ +import chalk from "chalk"; +import { DefaultPackageManager } from "./package-manager.js"; +import { prepareLifecycleHooks, runLifecycleHooks } from "./lifecycle-hooks.js"; +import { SettingsManager } from "./settings-manager.js"; + +export type PackageCommand = "install" | "remove" | "update" | "list"; + +export interface PackageCommandOptions { + command: PackageCommand; + source?: string; + local: boolean; + help: boolean; + invalidOption?: string; +} + +export interface PackageCommandRunnerOptions { + appName: string; + args: string[]; + cwd: string; + agentDir: string; + stdout?: NodeJS.WriteStream; + stderr?: NodeJS.WriteStream; + allowedCommands?: ReadonlySet; +} + +export interface PackageCommandRunnerResult { + handled: boolean; + exitCode: number; +} + +function reportSettingsErrors(settingsManager: SettingsManager, context: string, stderr: NodeJS.WriteStream): void { + const errors = settingsManager.drainErrors(); + for (const { scope, error } of errors) { + stderr.write(chalk.yellow(`Warning (${context}, ${scope} settings): ${error.message}`) + "\n"); + if (error.stack) { + stderr.write(chalk.dim(error.stack) + "\n"); + } + } +} + +export function getPackageCommandUsage(appName: string, command: PackageCommand): string { + switch (command) { + case "install": + return `${appName} install [-l]`; + case "remove": + return `${appName} remove [-l]`; + case "update": + return `${appName} update [source]`; + case "list": + return `${appName} list`; + } +} + +function printPackageCommandHelp( + appName: string, + command: PackageCommand, + stdout: NodeJS.WriteStream, +): void { + switch (command) { + case "install": + stdout.write(`${chalk.bold("Usage:")} + ${getPackageCommandUsage(appName, "install")} + +Install a package, add it to settings, and run lifecycle hooks. + +Options: + -l, --local Install project-locally (.pi/settings.json) + +Examples: + ${appName} install npm:@foo/bar + ${appName} install git:github.com/user/repo + ${appName} install git:git@github.com:user/repo + ${appName} install https://github.com/user/repo + ${appName} install ssh://git@github.com/user/repo + ${appName} install ./local/path +`); + return; + case "remove": + stdout.write(`${chalk.bold("Usage:")} + ${getPackageCommandUsage(appName, "remove")} + +Remove a package and its source from settings. + +Options: + -l, --local Remove from project settings (.pi/settings.json) + +Example: + ${appName} remove npm:@foo/bar +`); + return; + case "update": + stdout.write(`${chalk.bold("Usage:")} + ${getPackageCommandUsage(appName, "update")} + +Update installed packages. +If is provided, only that package is updated. +`); + return; + case "list": + stdout.write(`${chalk.bold("Usage:")} + ${getPackageCommandUsage(appName, "list")} + +List installed packages from user and project settings. +`); + return; + } +} + +export function parsePackageCommand( + args: string[], + allowedCommands?: ReadonlySet, +): PackageCommandOptions | undefined { + const [command, ...rest] = args; + if (command !== "install" && command !== "remove" && command !== "update" && command !== "list") { + return undefined; + } + if (allowedCommands && !allowedCommands.has(command)) { + return undefined; + } + + let local = false; + let help = false; + let invalidOption: string | undefined; + let source: string | undefined; + + for (const arg of rest) { + if (arg === "-h" || arg === "--help") { + help = true; + continue; + } + if (arg === "-l" || arg === "--local") { + if (command === "install" || command === "remove") { + local = true; + } else { + invalidOption = invalidOption ?? arg; + } + continue; + } + if (arg.startsWith("-")) { + invalidOption = invalidOption ?? arg; + continue; + } + if (!source) { + source = arg; + } + } + + return { command, source, local, help, invalidOption }; +} + +export async function runPackageCommand( + options: PackageCommandRunnerOptions, +): Promise { + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const parsed = parsePackageCommand(options.args, options.allowedCommands); + if (!parsed) { + return { handled: false, exitCode: 0 }; + } + + if (parsed.help) { + printPackageCommandHelp(options.appName, parsed.command, stdout); + return { handled: true, exitCode: 0 }; + } + + if (parsed.invalidOption) { + stderr.write(chalk.red(`Unknown option ${parsed.invalidOption} for "${parsed.command}".`) + "\n"); + stderr.write(chalk.dim(`Use "${options.appName} --help" or "${getPackageCommandUsage(options.appName, parsed.command)}".`) + "\n"); + return { handled: true, exitCode: 1 }; + } + + const source = parsed.source; + if ((parsed.command === "install" || parsed.command === "remove") && !source) { + stderr.write(chalk.red(`Missing ${parsed.command} source.`) + "\n"); + stderr.write(chalk.dim(`Usage: ${getPackageCommandUsage(options.appName, parsed.command)}`) + "\n"); + return { handled: true, exitCode: 1 }; + } + + const settingsManager = SettingsManager.create(options.cwd, options.agentDir); + reportSettingsErrors(settingsManager, "package command", stderr); + const packageManager = new DefaultPackageManager({ + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager, + }); + packageManager.setProgressCallback((event) => { + if (event.type === "start" && event.message) { + stdout.write(chalk.dim(`${event.message}\n`)); + } + }); + + try { + switch (parsed.command) { + case "install": { + const lifecycleOptions = { + source: source!, + local: parsed.local, + cwd: options.cwd, + agentDir: options.agentDir, + appName: options.appName, + packageManager, + stdout, + stderr, + }; + + const beforeInstallHooks = await prepareLifecycleHooks(lifecycleOptions, "source"); + const beforeInstallResult = await runLifecycleHooks(beforeInstallHooks, "beforeInstall"); + + await packageManager.install(source!, { local: parsed.local }); + packageManager.addSourceToSettings(source!, { local: parsed.local }); + + const afterInstallHooks = await prepareLifecycleHooks(lifecycleOptions, "installed", { + verifyRuntimeDependencies: true, + }); + const afterInstallResult = await runLifecycleHooks(afterInstallHooks, "afterInstall"); + + const hookErrors = beforeInstallResult.hookErrors + afterInstallResult.hookErrors; + if (hookErrors > 0) { + stderr.write(chalk.yellow(`Lifecycle hooks completed with ${hookErrors} hook error(s).`) + "\n"); + } + stdout.write(chalk.green(`Installed ${source}`) + "\n"); + return { handled: true, exitCode: 0 }; + } + + case "remove": { + const lifecycleOptions = { + source: source!, + local: parsed.local, + cwd: options.cwd, + agentDir: options.agentDir, + appName: options.appName, + packageManager, + stdout, + stderr, + }; + const removeHooks = await prepareLifecycleHooks(lifecycleOptions, "installed"); + const beforeRemoveResult = await runLifecycleHooks(removeHooks, "beforeRemove"); + + await packageManager.remove(source!, { local: parsed.local }); + const removed = packageManager.removeSourceFromSettings(source!, { local: parsed.local }); + + const afterRemoveResult = await runLifecycleHooks(removeHooks, "afterRemove"); + const hookErrors = beforeRemoveResult.hookErrors + afterRemoveResult.hookErrors; + if (hookErrors > 0) { + stderr.write(chalk.yellow(`Lifecycle hooks completed with ${hookErrors} hook error(s).`) + "\n"); + } + + if (!removed) { + stderr.write(chalk.red(`No matching package found for ${source}`) + "\n"); + return { handled: true, exitCode: 1 }; + } + stdout.write(chalk.green(`Removed ${source}`) + "\n"); + return { handled: true, exitCode: 0 }; + } + + case "list": { + const globalSettings = settingsManager.getGlobalSettings(); + const projectSettings = settingsManager.getProjectSettings(); + const globalPackages = globalSettings.packages ?? []; + const projectPackages = projectSettings.packages ?? []; + + if (globalPackages.length === 0 && projectPackages.length === 0) { + stdout.write(chalk.dim("No packages installed.") + "\n"); + return { handled: true, exitCode: 0 }; + } + + const formatPackage = (pkg: (typeof globalPackages)[number], scope: "user" | "project") => { + const pkgSource = typeof pkg === "string" ? pkg : pkg.source; + const filtered = typeof pkg === "object"; + const display = filtered ? `${pkgSource} (filtered)` : pkgSource; + stdout.write(` ${display}\n`); + const path = packageManager.getInstalledPath(pkgSource, scope); + if (path) { + stdout.write(chalk.dim(` ${path}`) + "\n"); + } + }; + + if (globalPackages.length > 0) { + stdout.write(chalk.bold("User packages:") + "\n"); + for (const pkg of globalPackages) { + formatPackage(pkg, "user"); + } + } + + if (projectPackages.length > 0) { + if (globalPackages.length > 0) stdout.write("\n"); + stdout.write(chalk.bold("Project packages:") + "\n"); + for (const pkg of projectPackages) { + formatPackage(pkg, "project"); + } + } + + return { handled: true, exitCode: 0 }; + } + + case "update": + await packageManager.update(source); + if (source) { + stdout.write(chalk.green(`Updated ${source}`) + "\n"); + } else { + stdout.write(chalk.green("Updated packages") + "\n"); + } + return { handled: true, exitCode: 0 }; + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown package command error"; + stderr.write(chalk.red(`Error: ${message}`) + "\n"); + return { handled: true, exitCode: 1 }; + } +} diff --git a/packages/pi-coding-agent/src/core/sdk.ts b/packages/pi-coding-agent/src/core/sdk.ts index 97e8c5f5e..f9da7c022 100644 --- a/packages/pi-coding-agent/src/core/sdk.ts +++ b/packages/pi-coding-agent/src/core/sdk.ts @@ -333,6 +333,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (!resolvedProvider) { throw new Error("No model selected"); } + const authMode = modelRegistry.getProviderAuthMode(resolvedProvider); + if (authMode === "externalCli" || authMode === "none") { + return undefined; + } // Retry key resolution with backoff to handle transient network failures // (e.g., OAuth token refresh failing due to brief connectivity loss). diff --git a/packages/pi-coding-agent/src/index.ts b/packages/pi-coding-agent/src/index.ts index 882f92e5b..9787c3b5e 100644 --- a/packages/pi-coding-agent/src/index.ts +++ b/packages/pi-coding-agent/src/index.ts @@ -94,6 +94,11 @@ export type { MessageRenderOptions, ProviderConfig, ProviderModelConfig, + LifecycleHookContext, + LifecycleHookHandler, + LifecycleHookMap, + LifecycleHookPhase, + LifecycleHookScope, ReadToolCallEvent, RegisteredCommand, RegisteredTool, @@ -152,6 +157,8 @@ export type { ResolvedResource, } from "./core/package-manager.js"; export { DefaultPackageManager } from "./core/package-manager.js"; +export type { PackageCommand, PackageCommandOptions, PackageCommandRunnerOptions, PackageCommandRunnerResult } from "./core/package-commands.js"; +export { getPackageCommandUsage, parsePackageCommand, runPackageCommand } from "./core/package-commands.js"; export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js"; export { DefaultResourceLoader } from "./core/resource-loader.js"; // SDK for programmatic usage diff --git a/packages/pi-coding-agent/src/main.ts b/packages/pi-coding-agent/src/main.ts index 1f1c961e0..c453f5eb8 100644 --- a/packages/pi-coding-agent/src/main.ts +++ b/packages/pi-coding-agent/src/main.ts @@ -20,6 +20,7 @@ import type { LoadExtensionsResult } from "./core/extensions/index.js"; import { KeybindingsManager } from "./core/keybindings.js"; import { ModelRegistry } from "./core/model-registry.js"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; +import { runPackageCommand } from "./core/package-commands.js"; import { DefaultPackageManager } from "./core/package-manager.js"; import { DefaultResourceLoader } from "./core/resource-loader.js"; import { type CreateAgentSessionOptions, createAgentSession } from "./core/sdk.js"; @@ -69,237 +70,6 @@ function isTruthyEnvFlag(value: string | undefined): boolean { return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; } -type PackageCommand = "install" | "remove" | "update" | "list"; - -interface PackageCommandOptions { - command: PackageCommand; - source?: string; - local: boolean; - help: boolean; - invalidOption?: string; -} - -function getPackageCommandUsage(command: PackageCommand): string { - switch (command) { - case "install": - return `${APP_NAME} install [-l]`; - case "remove": - return `${APP_NAME} remove [-l]`; - case "update": - return `${APP_NAME} update [source]`; - case "list": - return `${APP_NAME} list`; - } -} - -function printPackageCommandHelp(command: PackageCommand): void { - switch (command) { - case "install": - console.log(`${chalk.bold("Usage:")} - ${getPackageCommandUsage("install")} - -Install a package and add it to settings. - -Options: - -l, --local Install project-locally (.pi/settings.json) - -Examples: - ${APP_NAME} install npm:@foo/bar - ${APP_NAME} install git:github.com/user/repo - ${APP_NAME} install git:git@github.com:user/repo - ${APP_NAME} install https://github.com/user/repo - ${APP_NAME} install ssh://git@github.com/user/repo - ${APP_NAME} install ./local/path -`); - return; - - case "remove": - console.log(`${chalk.bold("Usage:")} - ${getPackageCommandUsage("remove")} - -Remove a package and its source from settings. - -Options: - -l, --local Remove from project settings (.pi/settings.json) - -Example: - ${APP_NAME} remove npm:@foo/bar -`); - return; - - case "update": - console.log(`${chalk.bold("Usage:")} - ${getPackageCommandUsage("update")} - -Update installed packages. -If is provided, only that package is updated. -`); - return; - - case "list": - console.log(`${chalk.bold("Usage:")} - ${getPackageCommandUsage("list")} - -List installed packages from user and project settings. -`); - return; - } -} - -function parsePackageCommand(args: string[]): PackageCommandOptions | undefined { - const [command, ...rest] = args; - if (command !== "install" && command !== "remove" && command !== "update" && command !== "list") { - return undefined; - } - - let local = false; - let help = false; - let invalidOption: string | undefined; - let source: string | undefined; - - for (const arg of rest) { - if (arg === "-h" || arg === "--help") { - help = true; - continue; - } - - if (arg === "-l" || arg === "--local") { - if (command === "install" || command === "remove") { - local = true; - } else { - invalidOption = invalidOption ?? arg; - } - continue; - } - - if (arg.startsWith("-")) { - invalidOption = invalidOption ?? arg; - continue; - } - - if (!source) { - source = arg; - } - } - - return { command, source, local, help, invalidOption }; -} - -async function handlePackageCommand(args: string[]): Promise { - const options = parsePackageCommand(args); - if (!options) { - return false; - } - - if (options.help) { - printPackageCommandHelp(options.command); - return true; - } - - if (options.invalidOption) { - console.error(chalk.red(`Unknown option ${options.invalidOption} for "${options.command}".`)); - console.error(chalk.dim(`Use "${APP_NAME} --help" or "${getPackageCommandUsage(options.command)}".`)); - process.exitCode = 1; - return true; - } - - const source = options.source; - if ((options.command === "install" || options.command === "remove") && !source) { - console.error(chalk.red(`Missing ${options.command} source.`)); - console.error(chalk.dim(`Usage: ${getPackageCommandUsage(options.command)}`)); - process.exitCode = 1; - return true; - } - - const cwd = process.cwd(); - const agentDir = getAgentDir(); - const settingsManager = SettingsManager.create(cwd, agentDir); - reportSettingsErrors(settingsManager, "package command"); - const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); - - packageManager.setProgressCallback((event) => { - if (event.type === "start") { - process.stdout.write(chalk.dim(`${event.message}\n`)); - } - }); - - try { - switch (options.command) { - case "install": - await packageManager.install(source!, { local: options.local }); - packageManager.addSourceToSettings(source!, { local: options.local }); - console.log(chalk.green(`Installed ${source}`)); - return true; - - case "remove": { - await packageManager.remove(source!, { local: options.local }); - const removed = packageManager.removeSourceFromSettings(source!, { local: options.local }); - if (!removed) { - console.error(chalk.red(`No matching package found for ${source}`)); - process.exitCode = 1; - return true; - } - console.log(chalk.green(`Removed ${source}`)); - return true; - } - - case "list": { - const globalSettings = settingsManager.getGlobalSettings(); - const projectSettings = settingsManager.getProjectSettings(); - const globalPackages = globalSettings.packages ?? []; - const projectPackages = projectSettings.packages ?? []; - - if (globalPackages.length === 0 && projectPackages.length === 0) { - console.log(chalk.dim("No packages installed.")); - return true; - } - - const formatPackage = (pkg: (typeof globalPackages)[number], scope: "user" | "project") => { - const source = typeof pkg === "string" ? pkg : pkg.source; - const filtered = typeof pkg === "object"; - const display = filtered ? `${source} (filtered)` : source; - console.log(` ${display}`); - const path = packageManager.getInstalledPath(source, scope); - if (path) { - console.log(chalk.dim(` ${path}`)); - } - }; - - if (globalPackages.length > 0) { - console.log(chalk.bold("User packages:")); - for (const pkg of globalPackages) { - formatPackage(pkg, "user"); - } - } - - if (projectPackages.length > 0) { - if (globalPackages.length > 0) console.log(); - console.log(chalk.bold("Project packages:")); - for (const pkg of projectPackages) { - formatPackage(pkg, "project"); - } - } - - return true; - } - - case "update": - await packageManager.update(source); - if (source) { - console.log(chalk.green(`Updated ${source}`)); - } else { - console.log(chalk.green("Updated packages")); - } - return true; - } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Unknown package command error"; - console.error(chalk.red(`Error: ${message}`)); - process.exitCode = 1; - return true; - } -} - async function prepareInitialMessage( parsed: Args, autoResizeImages: boolean, @@ -590,7 +360,16 @@ export async function main(args: string[]) { process.env.PI_SKIP_VERSION_CHECK = "1"; } - if (await handlePackageCommand(args)) { + const packageCommand = await runPackageCommand({ + appName: APP_NAME, + args, + cwd: process.cwd(), + agentDir: getAgentDir(), + stdout: process.stdout, + stderr: process.stderr, + }); + if (packageCommand.handled) { + process.exitCode = packageCommand.exitCode; return; } diff --git a/src/cli.ts b/src/cli.ts index 6a7fba97a..f14cbe0c4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,7 @@ import { AuthStorage, DefaultResourceLoader, ModelRegistry, + runPackageCommand, SettingsManager, SessionManager, createAgentSession, @@ -153,6 +154,19 @@ if (subcommand && process.argv.includes('--help')) { } } +const packageCommand = await runPackageCommand({ + appName: 'gsd', + args: process.argv.slice(2), + cwd: process.cwd(), + agentDir, + stdout: process.stdout, + stderr: process.stderr, + allowedCommands: new Set(['install', 'remove', 'list']), +}) +if (packageCommand.handled) { + process.exit(packageCommand.exitCode) +} + // `gsd config` — replay the setup wizard and exit if (cliFlags.messages[0] === 'config') { const authStorage = AuthStorage.create(authFilePath) diff --git a/src/help-text.ts b/src/help-text.ts index 03f873bda..d28d79091 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -32,6 +32,30 @@ const SUBCOMMAND_HELP: Record = { 'Compare with --continue (-c) which always resumes the most recent session.', ].join('\n'), + install: [ + 'Usage: gsd install [-l, --local]', + '', + 'Install a package/extension source and run declared lifecycle hooks.', + '', + 'Examples:', + ' gsd install npm:@foo/bar', + ' gsd install git:github.com/user/repo', + ' gsd install https://github.com/user/repo', + ' gsd install ./local/path', + ].join('\n'), + + remove: [ + 'Usage: gsd remove [-l, --local]', + '', + 'Remove an installed package source and its settings entry.', + ].join('\n'), + + list: [ + 'Usage: gsd list', + '', + 'List installed package sources from user and project settings.', + ].join('\n'), + worktree: [ 'Usage: gsd worktree [args]', '', @@ -128,6 +152,9 @@ export function printHelp(version: string): void { process.stdout.write(' --help, -h Print this help and exit\n') process.stdout.write('\nSubcommands:\n') process.stdout.write(' config Re-run the setup wizard\n') + process.stdout.write(' install Install a package/extension source\n') + process.stdout.write(' remove Remove an installed package source\n') + process.stdout.write(' list List installed package sources\n') process.stdout.write(' update Update GSD to the latest version\n') process.stdout.write(' sessions List and resume a past session\n') process.stdout.write(' worktree Manage worktrees (list, merge, clean, remove)\n')