feat(core): support for 'non-api-key' provider extensions like Claude Code CLI (#2382)

* feat(core): add generic native post-install hooks for package install

* feat(core): add before/after install/remove lifecycle hooks

* refactor(core): remove postInstall alias from lifecycle hook fallback

* feat(core): complete authMode support for keyless providers

The initial authMode implementation fixed model-registry, sdk, and
fallback-resolver but missed agent-session.ts (6 callsites) and
compaction-orchestrator.ts (2 callsites) that block externalCli
providers at runtime.

Architecture: separate readiness gating from credential retrieval.
- isProviderRequestReady(): authMode-aware readiness check
- getApiKey()/getApiKeyForProvider(): return undefined for
  externalCli/none providers instead of triggering auth errors
- All 8 callsites in agent-session and compaction-orchestrator
  now gate on readiness, not key presence
- Downstream signatures (compaction, branch-summarization) accept
  apiKey: string | undefined
- Replaced hardcoded ollama exception in discoverModels with
  isProviderRequestReady

Zero behavioral change for classic apiKey/oauth providers.

* feat(core): add isReady callback for provider readiness verification

Extensions can now provide an isReady() callback when registering any
provider. isProviderRequestReady() calls it before default auth checks,
allowing providers to verify actual reachability (CLI authenticated,
API key valid, service online) rather than relying solely on credential
presence.

* test(core): expand authMode test coverage

Cover all four auth modes (apiKey, oauth, externalCli, none),
isReady callback behavior, getProviderAuthMode defaults,
isProviderRequestReady for each mode, getAvailable filtering,
and getApiKey early-return for keyless providers.

* chore: remove provider-api-bridge files from this branch

These files implement GSD core → provider-api wiring (deps + tool
registry) and belong in a separate PR. Reverts register-extension.ts
to upstream state.
This commit is contained in:
Jay The Reaper 2026-03-24 21:50:12 +00:00 committed by GitHub
parent dae38f797e
commit bc278d12d9
19 changed files with 1325 additions and 286 deletions

View file

@ -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<any>, options?: { persist?: boolean }): Promise<void> {
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<Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>> {
const apiKeysByProvider = new Map<string, string | undefined>();
const result: Array<{ model: Model<any>; 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<any>; thinkingLevel?: ThinkingLevel }> {
return this._scopedModels.filter((scoped) =>
this._modelRegistry.isProviderRequestReady(scoped.model.provider),
);
}
private async _cycleScopedModel(direction: "forward" | "backward", options?: { persist?: boolean }): Promise<ModelCycleResult | undefined> {
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,

View file

@ -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);

View file

@ -64,8 +64,8 @@ export interface CollectEntriesResult {
export interface GenerateBranchSummaryOptions {
/** Model to use for summarization */
model: Model<any>;
/** 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 */

View file

@ -497,7 +497,7 @@ export async function generateSummary(
currentMessages: AgentMessage[],
model: Model<any>,
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<any>,
apiKey: string,
apiKey: string | undefined,
customInstructions?: string,
signal?: AbortSignal,
): Promise<CompactionResult> {
@ -732,7 +732,7 @@ async function generateTurnPrefixSummary(
messages: AgentMessage[],
model: Model<any>,
reserveTokens: number,
apiKey: string,
apiKey: string | undefined,
signal?: AbortSignal,
): Promise<string> {
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix

View file

@ -94,6 +94,11 @@ export type {
// Provider Registration
ProviderConfig,
ProviderModelConfig,
LifecycleHookContext,
LifecycleHookHandler,
LifecycleHookMap,
LifecycleHookPhase,
LifecycleHookScope,
ReadToolCallEvent,
ReadToolResultEvent,
// Commands

View file

@ -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: [],
},
};
}

View file

@ -949,6 +949,33 @@ export interface RegisteredCommand {
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
}
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> | void;
export type LifecycleHookMap = Record<LifecycleHookPhase, LifecycleHookHandler[]>;
// ============================================================================
// Extension API
// ============================================================================
@ -1019,6 +1046,18 @@ export interface ExtensionAPI {
/** Register a custom command. */
registerCommand(name: string, options: Omit<RegisteredCommand, "name">): 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<string, RegisteredCommand>;
flags: Map<string, ExtensionFlag>;
shortcuts: Map<KeyId, ExtensionShortcut>;
lifecycleHooks: LifecycleHookMap;
}
/** Result of loading extensions. */

View file

@ -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<Api> | 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) => {

View file

@ -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,

View file

@ -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<string, LifecycleHookMap>;
}
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<string>();
const candidateDirs = new Set<string>([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<LoadedLifecycleHooks | null> {
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<string, LifecycleHookMap>();
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<boolean> {
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<LifecycleHookHandler | null> {
try {
const module = await importExtensionModule<Record<string, unknown>>(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<LifecycleHooksRunResult> {
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,
};
}

View file

@ -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<Parameters<ModelRegistry["registerProvider"]>[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<Api> | 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);
});
});

View file

@ -128,6 +128,8 @@ ajv.addSchema(ModelsConfigSchema, "ModelsConfig");
type ModelsConfig = Static<typeof ModelsConfigSchema>;
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<Api>[] {
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<Api>, sessionId?: string): Promise<string | undefined> {
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<string | undefined> {
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;

View file

@ -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<string, string>): 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 });
}
});
});

View file

@ -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<PackageCommand>;
}
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 <source> [-l]`;
case "remove":
return `${appName} remove <source> [-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 <source> 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<PackageCommand>,
): 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<PackageCommandRunnerResult> {
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 };
}
}

View file

@ -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).

View file

@ -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

View file

@ -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 <source> [-l]`;
case "remove":
return `${APP_NAME} remove <source> [-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 <source> 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<boolean> {
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;
}

View file

@ -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)

View file

@ -32,6 +32,30 @@ const SUBCOMMAND_HELP: Record<string, string> = {
'Compare with --continue (-c) which always resumes the most recent session.',
].join('\n'),
install: [
'Usage: gsd install <source> [-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 <source> [-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 <command> [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 <source> Install a package/extension source\n')
process.stdout.write(' remove <source> 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 <cmd> Manage worktrees (list, merge, clean, remove)\n')