feat(ui): Improved project approval settings
This commit is contained in:
@@ -232,7 +232,7 @@ ${chalk.bold("Commands:")}
|
||||
${APP_NAME} update [source|self|pi] Update pi and installed extensions
|
||||
${APP_NAME} list [--approve|--no-approve]
|
||||
List installed extensions from settings
|
||||
${APP_NAME} config [--no-approve]
|
||||
${APP_NAME} config [--approve|--no-approve]
|
||||
Open TUI to enable/disable package resources
|
||||
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list
|
||||
|
||||
|
||||
62
packages/coding-agent/src/cli/project-trust.ts
Normal file
62
packages/coding-agent/src/cli/project-trust.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import chalk from "chalk";
|
||||
import type { ProjectTrustContext } from "../core/extensions/types.ts";
|
||||
import type { AppMode } from "../core/project-trust.ts";
|
||||
import type { SettingsManager } from "../core/settings-manager.ts";
|
||||
import { showStartupInput, showStartupSelector } from "./startup-ui.ts";
|
||||
|
||||
export function createProjectTrustContext(options: {
|
||||
cwd: string;
|
||||
mode: AppMode;
|
||||
settingsManager: SettingsManager;
|
||||
hasUI: boolean;
|
||||
}): ProjectTrustContext {
|
||||
return {
|
||||
cwd: options.cwd,
|
||||
mode: options.mode === "interactive" ? "tui" : options.mode,
|
||||
hasUI: options.hasUI,
|
||||
ui: {
|
||||
select: async (title, selectOptions) => {
|
||||
if (!options.hasUI) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return undefined;
|
||||
}
|
||||
return showStartupSelector(
|
||||
options.settingsManager,
|
||||
title,
|
||||
selectOptions.map((option) => ({ label: option, value: option })),
|
||||
);
|
||||
},
|
||||
confirm: async (title, message) => {
|
||||
if (!options.hasUI) {
|
||||
return false;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(await showStartupSelector(options.settingsManager, `${title}\n${message}`, [
|
||||
{ label: "Yes", value: true },
|
||||
{ label: "No", value: false },
|
||||
])) ?? false
|
||||
);
|
||||
},
|
||||
input: async (title, placeholder) => {
|
||||
if (!options.hasUI) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return undefined;
|
||||
}
|
||||
return showStartupInput(options.settingsManager, title, placeholder);
|
||||
},
|
||||
notify: (message, type = "info") => {
|
||||
if (options.mode !== "interactive") {
|
||||
const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan;
|
||||
console.error(color(message));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
87
packages/coding-agent/src/cli/startup-ui.ts
Normal file
87
packages/coding-agent/src/cli/startup-ui.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
|
||||
import { KeybindingsManager } from "../core/keybindings.ts";
|
||||
import type { SettingsManager } from "../core/settings-manager.ts";
|
||||
import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts";
|
||||
import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts";
|
||||
import { initTheme } from "../modes/interactive/theme/theme.ts";
|
||||
|
||||
function createStartupTui(settingsManager: SettingsManager): TUI {
|
||||
initTheme(settingsManager.getTheme());
|
||||
setKeybindings(KeybindingsManager.create());
|
||||
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
|
||||
ui.setClearOnShrink(settingsManager.getClearOnShrink());
|
||||
return ui;
|
||||
}
|
||||
|
||||
async function clearStartupTui(ui: TUI): Promise<void> {
|
||||
ui.clear();
|
||||
ui.requestRender();
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
export async function showStartupSelector<T>(
|
||||
settingsManager: SettingsManager,
|
||||
title: string,
|
||||
options: Array<{ label: string; value: T }>,
|
||||
): Promise<T | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = async (result: T | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const selector = new ExtensionSelectorComponent(
|
||||
title,
|
||||
options.map((option) => option.label),
|
||||
(option) => void finish(options.find((entry) => entry.label === option)?.value),
|
||||
() => void finish(undefined),
|
||||
{ tui: ui },
|
||||
);
|
||||
ui.addChild(selector);
|
||||
ui.setFocus(selector);
|
||||
ui.start();
|
||||
});
|
||||
}
|
||||
|
||||
export async function showStartupInput(
|
||||
settingsManager: SettingsManager,
|
||||
title: string,
|
||||
placeholder?: string,
|
||||
): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = async (result: string | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
input.dispose();
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const input = new ExtensionInputComponent(
|
||||
title,
|
||||
placeholder,
|
||||
(value) => void finish(value),
|
||||
() => void finish(undefined),
|
||||
{
|
||||
tui: ui,
|
||||
},
|
||||
);
|
||||
ui.addChild(input);
|
||||
ui.setFocus(input);
|
||||
ui.start();
|
||||
});
|
||||
}
|
||||
95
packages/coding-agent/src/core/project-trust.ts
Normal file
95
packages/coding-agent/src/core/project-trust.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { emitProjectTrustEvent } from "./extensions/runner.ts";
|
||||
import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts";
|
||||
import type { DefaultProjectTrust } from "./settings-manager.ts";
|
||||
import {
|
||||
getProjectTrustOptions,
|
||||
hasProjectTrustInputs,
|
||||
type ProjectTrustOption,
|
||||
type ProjectTrustStore,
|
||||
} from "./trust-manager.ts";
|
||||
|
||||
export type AppMode = "interactive" | "print" | "json" | "rpc";
|
||||
|
||||
export interface ResolveProjectTrustedOptions {
|
||||
cwd: string;
|
||||
trustStore: ProjectTrustStore;
|
||||
trustOverride?: boolean;
|
||||
defaultProjectTrust?: DefaultProjectTrust;
|
||||
extensionsResult?: LoadExtensionsResult;
|
||||
projectTrustContext: ProjectTrustContext;
|
||||
onExtensionError?: (message: string) => void;
|
||||
}
|
||||
|
||||
function formatProjectTrustPrompt(cwd: string): string {
|
||||
return `Trust project folder?\n${cwd}\n\nThis allows pi to load .pi settings and resources, install missing project packages, and execute project extensions.`;
|
||||
}
|
||||
|
||||
async function selectProjectTrustOption(
|
||||
cwd: string,
|
||||
ctx: ProjectTrustContext,
|
||||
): Promise<ProjectTrustOption | undefined> {
|
||||
const options = getProjectTrustOptions(cwd, { includeSessionOnly: true });
|
||||
const selected = await ctx.ui.select(
|
||||
formatProjectTrustPrompt(cwd),
|
||||
options.map((option) => option.label),
|
||||
);
|
||||
return options.find((option) => option.label === selected);
|
||||
}
|
||||
|
||||
function saveProjectTrustPromptResult(trustStore: ProjectTrustStore, result: ProjectTrustOption): void {
|
||||
if (result.updates.length > 0) {
|
||||
trustStore.setMany(result.updates);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveProjectTrusted(options: ResolveProjectTrustedOptions): Promise<boolean> {
|
||||
if (options.trustOverride !== undefined) {
|
||||
return options.trustOverride;
|
||||
}
|
||||
if (!hasProjectTrustInputs(options.cwd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.extensionsResult) {
|
||||
const { result, errors } = await emitProjectTrustEvent(
|
||||
options.extensionsResult,
|
||||
{ type: "project_trust", cwd: options.cwd },
|
||||
options.projectTrustContext,
|
||||
);
|
||||
for (const error of errors) {
|
||||
options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
|
||||
}
|
||||
if (result) {
|
||||
const trusted = result.trusted === "yes";
|
||||
if (result.remember === true) {
|
||||
options.trustStore.set(options.cwd, trusted);
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
}
|
||||
|
||||
const decision = options.trustStore.get(options.cwd);
|
||||
if (decision !== null) {
|
||||
return decision;
|
||||
}
|
||||
|
||||
switch (options.defaultProjectTrust ?? "ask") {
|
||||
case "always":
|
||||
return true;
|
||||
case "never":
|
||||
return false;
|
||||
case "ask":
|
||||
break;
|
||||
}
|
||||
|
||||
if (!options.projectTrustContext.hasUI) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selected = await selectProjectTrustOption(options.cwd, options.projectTrustContext);
|
||||
if (selected !== undefined) {
|
||||
saveProjectTrustPromptResult(options.trustStore, selected);
|
||||
return selected.trusted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -79,7 +79,6 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
|
||||
export function loadProjectContextFiles(options: {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
projectTrusted?: boolean;
|
||||
}): Array<{ path: string; content: string }> {
|
||||
const resolvedCwd = resolvePath(options.cwd);
|
||||
const resolvedAgentDir = resolvePath(options.agentDir);
|
||||
@@ -93,29 +92,27 @@ export function loadProjectContextFiles(options: {
|
||||
seenPaths.add(globalContext.path);
|
||||
}
|
||||
|
||||
if (options.projectTrusted !== false) {
|
||||
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
|
||||
const ancestorContextFiles: Array<{ path: string; content: string }> = [];
|
||||
|
||||
let currentDir = resolvedCwd;
|
||||
const root = resolve("/");
|
||||
let currentDir = resolvedCwd;
|
||||
const root = resolve("/");
|
||||
|
||||
while (true) {
|
||||
const contextFile = loadContextFileFromDir(currentDir);
|
||||
if (contextFile && !seenPaths.has(contextFile.path)) {
|
||||
ancestorContextFiles.unshift(contextFile);
|
||||
seenPaths.add(contextFile.path);
|
||||
}
|
||||
|
||||
if (currentDir === root) break;
|
||||
|
||||
const parentDir = resolve(currentDir, "..");
|
||||
if (parentDir === currentDir) break;
|
||||
currentDir = parentDir;
|
||||
while (true) {
|
||||
const contextFile = loadContextFileFromDir(currentDir);
|
||||
if (contextFile && !seenPaths.has(contextFile.path)) {
|
||||
ancestorContextFiles.unshift(contextFile);
|
||||
seenPaths.add(contextFile.path);
|
||||
}
|
||||
|
||||
contextFiles.push(...ancestorContextFiles);
|
||||
if (currentDir === root) break;
|
||||
|
||||
const parentDir = resolve(currentDir, "..");
|
||||
if (parentDir === currentDir) break;
|
||||
currentDir = parentDir;
|
||||
}
|
||||
|
||||
contextFiles.push(...ancestorContextFiles);
|
||||
|
||||
return contextFiles;
|
||||
}
|
||||
|
||||
@@ -325,14 +322,18 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
}
|
||||
}
|
||||
|
||||
async loadProjectTrustExtensions(): Promise<LoadExtensionsResult> {
|
||||
// Force untrusted project settings for the bootstrap pass. This keeps project-local
|
||||
// extensions/packages out while still loading user/global and temporary CLI extensions.
|
||||
this.settingsManager.setProjectTrusted(false);
|
||||
await this.settingsManager.reload();
|
||||
return this.loadCurrentExtensionSet({ includeInlineFactories: true });
|
||||
}
|
||||
|
||||
async reload(options?: ResourceLoaderReloadOptions): Promise<void> {
|
||||
let preTrustExtensions: LoadExtensionsResult | undefined;
|
||||
if (options?.resolveProjectTrust) {
|
||||
// Force untrusted project settings for the bootstrap pass. This keeps project-local
|
||||
// extensions/packages out while still loading user/global and temporary CLI extensions.
|
||||
this.settingsManager.setProjectTrusted(false);
|
||||
await this.settingsManager.reload();
|
||||
preTrustExtensions = await this.loadCurrentExtensionSet({ includeInlineFactories: true });
|
||||
preTrustExtensions = await this.loadProjectTrustExtensions();
|
||||
const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions });
|
||||
this.settingsManager.setProjectTrusted(projectTrusted);
|
||||
}
|
||||
@@ -454,7 +455,6 @@ export class DefaultResourceLoader implements ResourceLoader {
|
||||
: loadProjectContextFiles({
|
||||
cwd: this.cwd,
|
||||
agentDir: this.agentDir,
|
||||
projectTrusted: this.settingsManager.isProjectTrusted(),
|
||||
}),
|
||||
};
|
||||
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface WarningSettings {
|
||||
anthropicExtraUsage?: boolean; // default: true
|
||||
}
|
||||
|
||||
export type DefaultProjectTrust = "ask" | "always" | "never";
|
||||
|
||||
export type TransportSetting = Transport;
|
||||
|
||||
/**
|
||||
@@ -89,6 +91,7 @@ export interface Settings {
|
||||
hideThinkingBlock?: boolean;
|
||||
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
|
||||
quietStartup?: boolean;
|
||||
defaultProjectTrust?: DefaultProjectTrust; // default: "ask"; global setting only
|
||||
shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support)
|
||||
npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"])
|
||||
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
||||
@@ -853,6 +856,17 @@ export class SettingsManager {
|
||||
this.save();
|
||||
}
|
||||
|
||||
getDefaultProjectTrust(): DefaultProjectTrust {
|
||||
const value = this.globalSettings.defaultProjectTrust;
|
||||
return value === "always" || value === "never" ? value : "ask";
|
||||
}
|
||||
|
||||
setDefaultProjectTrust(defaultProjectTrust: DefaultProjectTrust): void {
|
||||
this.globalSettings.defaultProjectTrust = defaultProjectTrust;
|
||||
this.markModified("defaultProjectTrust");
|
||||
this.save();
|
||||
}
|
||||
|
||||
getShellCommandPrefix(): string | undefined {
|
||||
return this.settings.shellCommandPrefix;
|
||||
}
|
||||
|
||||
@@ -6,14 +6,87 @@ import { canonicalizePath, resolvePath } from "../utils/paths.ts";
|
||||
|
||||
export type ProjectTrustDecision = boolean | null;
|
||||
|
||||
type TrustFile = Record<string, boolean | null | undefined>;
|
||||
export interface ProjectTrustStoreEntry {
|
||||
path: string;
|
||||
decision: boolean;
|
||||
}
|
||||
|
||||
const CONTEXT_FILE_NAMES = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
|
||||
export interface ProjectTrustUpdate {
|
||||
path: string;
|
||||
decision: ProjectTrustDecision;
|
||||
}
|
||||
|
||||
export interface ProjectTrustOption {
|
||||
label: string;
|
||||
trusted: boolean;
|
||||
updates: ProjectTrustUpdate[];
|
||||
savedPath?: string;
|
||||
}
|
||||
|
||||
type TrustFile = Record<string, boolean | null | undefined>;
|
||||
|
||||
function normalizeCwd(cwd: string): string {
|
||||
return canonicalizePath(resolvePath(cwd));
|
||||
}
|
||||
|
||||
function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null {
|
||||
let currentDir = normalizeCwd(cwd);
|
||||
while (true) {
|
||||
const value = data[currentDir];
|
||||
if (value === true || value === false) {
|
||||
return { path: currentDir, decision: value };
|
||||
}
|
||||
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
return null;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
export function getProjectTrustPath(cwd: string): string {
|
||||
return normalizeCwd(cwd);
|
||||
}
|
||||
|
||||
export function getProjectTrustParentPath(cwd: string): string | undefined {
|
||||
const trustPath = getProjectTrustPath(cwd);
|
||||
const parentDir = dirname(trustPath);
|
||||
return parentDir === trustPath ? undefined : parentDir;
|
||||
}
|
||||
|
||||
export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] {
|
||||
const trustPath = getProjectTrustPath(cwd);
|
||||
const trustOptions: ProjectTrustOption[] = [
|
||||
{ label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath },
|
||||
];
|
||||
const parentPath = getProjectTrustParentPath(cwd);
|
||||
if (parentPath !== undefined) {
|
||||
trustOptions.push({
|
||||
label: `Trust parent folder (${parentPath})`,
|
||||
trusted: true,
|
||||
updates: [
|
||||
{ path: parentPath, decision: true },
|
||||
{ path: trustPath, decision: null },
|
||||
],
|
||||
savedPath: parentPath,
|
||||
});
|
||||
}
|
||||
if (options?.includeSessionOnly) {
|
||||
trustOptions.push({ label: "Trust (this session only)", trusted: true, updates: [] });
|
||||
}
|
||||
trustOptions.push({
|
||||
label: "Do not trust",
|
||||
trusted: false,
|
||||
updates: [{ path: trustPath, decision: false }],
|
||||
savedPath: trustPath,
|
||||
});
|
||||
if (options?.includeSessionOnly) {
|
||||
trustOptions.push({ label: "Do not trust (this session only)", trusted: false, updates: [] });
|
||||
}
|
||||
return trustOptions;
|
||||
}
|
||||
|
||||
function readTrustFile(path: string): TrustFile {
|
||||
if (!existsSync(path)) {
|
||||
return {};
|
||||
@@ -105,11 +178,6 @@ export function hasProjectTrustInputs(cwd: string): boolean {
|
||||
}
|
||||
|
||||
while (true) {
|
||||
for (const filename of CONTEXT_FILE_NAMES) {
|
||||
if (existsSync(join(currentDir, filename))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (existsSync(join(currentDir, ".agents", "skills"))) {
|
||||
return true;
|
||||
}
|
||||
@@ -130,21 +198,30 @@ export class ProjectTrustStore {
|
||||
}
|
||||
|
||||
get(cwd: string): ProjectTrustDecision {
|
||||
return this.getEntry(cwd)?.decision ?? null;
|
||||
}
|
||||
|
||||
getEntry(cwd: string): ProjectTrustStoreEntry | null {
|
||||
return withTrustFileLock(this.trustPath, () => {
|
||||
const data = readTrustFile(this.trustPath);
|
||||
const value = data[normalizeCwd(cwd)];
|
||||
return value === true || value === false ? value : null;
|
||||
return findNearestTrustEntry(data, cwd);
|
||||
});
|
||||
}
|
||||
|
||||
set(cwd: string, decision: ProjectTrustDecision): void {
|
||||
this.setMany([{ path: cwd, decision }]);
|
||||
}
|
||||
|
||||
setMany(decisions: ProjectTrustUpdate[]): void {
|
||||
withTrustFileLock(this.trustPath, () => {
|
||||
const data = readTrustFile(this.trustPath);
|
||||
const key = normalizeCwd(cwd);
|
||||
if (decision === null) {
|
||||
delete data[key];
|
||||
} else {
|
||||
data[key] = decision;
|
||||
for (const { path, decision } of decisions) {
|
||||
const key = normalizeCwd(path);
|
||||
if (decision === null) {
|
||||
delete data[key];
|
||||
} else {
|
||||
data[key] = decision;
|
||||
}
|
||||
}
|
||||
writeTrustFile(this.trustPath, data);
|
||||
});
|
||||
|
||||
@@ -220,6 +220,7 @@ export {
|
||||
} from "./core/session-manager.ts";
|
||||
export {
|
||||
type CompactionSettings,
|
||||
type DefaultProjectTrust,
|
||||
type ImageSettings,
|
||||
type PackageSource,
|
||||
type RetrySettings,
|
||||
@@ -287,7 +288,13 @@ export {
|
||||
type WriteToolOptions,
|
||||
withFileMutationQueue,
|
||||
} from "./core/tools/index.ts";
|
||||
export { hasProjectTrustInputs, type ProjectTrustDecision, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
export {
|
||||
hasProjectTrustInputs,
|
||||
type ProjectTrustDecision,
|
||||
ProjectTrustStore,
|
||||
type ProjectTrustStoreEntry,
|
||||
type ProjectTrustUpdate,
|
||||
} from "./core/trust-manager.ts";
|
||||
// Main entry point
|
||||
export { type MainOptions, main } from "./main.ts";
|
||||
// Run modes for programmatic SDK usage
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai";
|
||||
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts";
|
||||
import { processFileArguments } from "./cli/file-processor.ts";
|
||||
import { buildInitialMessage } from "./cli/initial-message.ts";
|
||||
import { listModels } from "./cli/list-models.ts";
|
||||
import { createProjectTrustContext } from "./cli/project-trust.ts";
|
||||
import { selectSession } from "./cli/session-picker.ts";
|
||||
import { showStartupSelector } from "./cli/startup-ui.ts";
|
||||
import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts";
|
||||
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts";
|
||||
import {
|
||||
@@ -24,13 +25,12 @@ import {
|
||||
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
|
||||
import { AuthStorage } from "./core/auth-storage.ts";
|
||||
import { exportFromFile } from "./core/export-html/index.ts";
|
||||
import { emitProjectTrustEvent } from "./core/extensions/runner.ts";
|
||||
import type { ExtensionFactory, LoadExtensionsResult, ProjectTrustContext } from "./core/extensions/types.ts";
|
||||
import type { ExtensionFactory } from "./core/extensions/types.ts";
|
||||
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
|
||||
import { KeybindingsManager } from "./core/keybindings.ts";
|
||||
import type { ModelRegistry } from "./core/model-registry.ts";
|
||||
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts";
|
||||
import { restoreStdout, takeOverStdout } from "./core/output-guard.ts";
|
||||
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
|
||||
import type { CreateAgentSessionOptions } from "./core/sdk.ts";
|
||||
import {
|
||||
formatMissingSessionCwdPrompt,
|
||||
@@ -44,8 +44,6 @@ import { printTimings, resetTimings, time } from "./core/timings.ts";
|
||||
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
|
||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
|
||||
import { ExtensionInputComponent } from "./modes/interactive/components/extension-input.ts";
|
||||
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
|
||||
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
|
||||
import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts";
|
||||
@@ -97,16 +95,14 @@ function isTruthyEnvFlag(value: string | undefined): boolean {
|
||||
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
||||
}
|
||||
|
||||
type AppMode = "interactive" | "print" | "json" | "rpc";
|
||||
|
||||
function resolveAppMode(parsed: Args, stdinIsTTY: boolean): AppMode {
|
||||
function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode {
|
||||
if (parsed.mode === "rpc") {
|
||||
return "rpc";
|
||||
}
|
||||
if (parsed.mode === "json") {
|
||||
return "json";
|
||||
}
|
||||
if (parsed.print || !stdinIsTTY) {
|
||||
if (parsed.print || !stdinIsTTY || !stdoutIsTTY) {
|
||||
return "print";
|
||||
}
|
||||
return "interactive";
|
||||
@@ -439,87 +435,6 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u
|
||||
return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));
|
||||
}
|
||||
|
||||
function createStartupTui(settingsManager: SettingsManager): TUI {
|
||||
initTheme(settingsManager.getTheme());
|
||||
setKeybindings(KeybindingsManager.create());
|
||||
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
|
||||
ui.setClearOnShrink(settingsManager.getClearOnShrink());
|
||||
return ui;
|
||||
}
|
||||
|
||||
async function clearStartupTui(ui: TUI): Promise<void> {
|
||||
ui.clear();
|
||||
ui.requestRender();
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
async function showStartupSelector<T>(
|
||||
settingsManager: SettingsManager,
|
||||
title: string,
|
||||
options: Array<{ label: string; value: T }>,
|
||||
): Promise<T | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = async (result: T | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const selector = new ExtensionSelectorComponent(
|
||||
title,
|
||||
options.map((option) => option.label),
|
||||
(option) => void finish(options.find((entry) => entry.label === option)?.value),
|
||||
() => void finish(undefined),
|
||||
{ tui: ui },
|
||||
);
|
||||
ui.addChild(selector);
|
||||
ui.setFocus(selector);
|
||||
ui.start();
|
||||
});
|
||||
}
|
||||
|
||||
async function showStartupInput(
|
||||
settingsManager: SettingsManager,
|
||||
title: string,
|
||||
placeholder?: string,
|
||||
): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = async (result: string | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
input.dispose();
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const input = new ExtensionInputComponent(
|
||||
title,
|
||||
placeholder,
|
||||
(value) => void finish(value),
|
||||
() => void finish(undefined),
|
||||
{
|
||||
tui: ui,
|
||||
},
|
||||
);
|
||||
ui.addChild(input);
|
||||
ui.setFocus(input);
|
||||
ui.start();
|
||||
});
|
||||
}
|
||||
|
||||
async function promptForMissingSessionCwd(
|
||||
issue: SessionCwdIssue,
|
||||
settingsManager: SettingsManager,
|
||||
@@ -530,160 +445,6 @@ async function promptForMissingSessionCwd(
|
||||
]);
|
||||
}
|
||||
|
||||
interface ProjectTrustPromptResult {
|
||||
trusted: boolean;
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
const PROJECT_TRUST_PROMPT_OPTIONS: Array<{ label: string; value: ProjectTrustPromptResult }> = [
|
||||
{ label: "Trust", value: { trusted: true, remember: true } },
|
||||
{ label: "Trust (this session only)", value: { trusted: true, remember: false } },
|
||||
{ label: "Do not trust", value: { trusted: false, remember: true } },
|
||||
{ label: "Do not trust (this session only)", value: { trusted: false, remember: false } },
|
||||
];
|
||||
|
||||
function formatProjectTrustPrompt(cwd: string): string {
|
||||
return `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`;
|
||||
}
|
||||
|
||||
async function promptForProjectTrust(
|
||||
cwd: string,
|
||||
settingsManager: SettingsManager,
|
||||
): Promise<ProjectTrustPromptResult | undefined> {
|
||||
return showStartupSelector(settingsManager, formatProjectTrustPrompt(cwd), PROJECT_TRUST_PROMPT_OPTIONS);
|
||||
}
|
||||
|
||||
async function promptForProjectTrustWithContext(
|
||||
cwd: string,
|
||||
ctx: ProjectTrustContext,
|
||||
): Promise<ProjectTrustPromptResult | undefined> {
|
||||
const selected = await ctx.ui.select(
|
||||
formatProjectTrustPrompt(cwd),
|
||||
PROJECT_TRUST_PROMPT_OPTIONS.map((option) => option.label),
|
||||
);
|
||||
return PROJECT_TRUST_PROMPT_OPTIONS.find((option) => option.label === selected)?.value;
|
||||
}
|
||||
|
||||
function createProjectTrustContext(options: {
|
||||
cwd: string;
|
||||
mode: AppMode;
|
||||
settingsManager: SettingsManager;
|
||||
hasUI: boolean;
|
||||
}): ProjectTrustContext {
|
||||
return {
|
||||
cwd: options.cwd,
|
||||
mode: options.mode === "interactive" ? "tui" : options.mode,
|
||||
hasUI: options.hasUI,
|
||||
ui: {
|
||||
select: async (title, selectOptions) => {
|
||||
if (!options.hasUI) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return undefined;
|
||||
}
|
||||
return showStartupSelector(
|
||||
options.settingsManager,
|
||||
title,
|
||||
selectOptions.map((option) => ({ label: option, value: option })),
|
||||
);
|
||||
},
|
||||
confirm: async (title, message) => {
|
||||
if (!options.hasUI) {
|
||||
return false;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(await showStartupSelector(options.settingsManager, `${title}\n${message}`, [
|
||||
{ label: "Yes", value: true },
|
||||
{ label: "No", value: false },
|
||||
])) ?? false
|
||||
);
|
||||
},
|
||||
input: async (title, placeholder) => {
|
||||
if (!options.hasUI) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return undefined;
|
||||
}
|
||||
return showStartupInput(options.settingsManager, title, placeholder);
|
||||
},
|
||||
notify: (message, type = "info") => {
|
||||
if (options.mode !== "interactive") {
|
||||
const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan;
|
||||
console.error(color(message));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveProjectTrusted(options: {
|
||||
cwd: string;
|
||||
trustStore: ProjectTrustStore;
|
||||
trustOverride?: boolean;
|
||||
appMode: AppMode;
|
||||
settingsManagerForPrompt: SettingsManager;
|
||||
extensionsResult?: LoadExtensionsResult;
|
||||
projectTrustContext?: ProjectTrustContext;
|
||||
onExtensionError?: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
if (options.trustOverride !== undefined) {
|
||||
return options.trustOverride;
|
||||
}
|
||||
if (!hasProjectTrustInputs(options.cwd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.extensionsResult && options.projectTrustContext) {
|
||||
const { result, errors } = await emitProjectTrustEvent(
|
||||
options.extensionsResult,
|
||||
{ type: "project_trust", cwd: options.cwd },
|
||||
options.projectTrustContext,
|
||||
);
|
||||
for (const error of errors) {
|
||||
options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
|
||||
}
|
||||
if (result) {
|
||||
const trusted = result.trusted === "yes";
|
||||
if (result.remember === true) {
|
||||
options.trustStore.set(options.cwd, trusted);
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
}
|
||||
|
||||
const decision = options.trustStore.get(options.cwd);
|
||||
if (decision !== null) {
|
||||
return decision;
|
||||
}
|
||||
if (options.projectTrustContext?.hasUI) {
|
||||
const selected = await promptForProjectTrustWithContext(options.cwd, options.projectTrustContext);
|
||||
if (selected !== undefined) {
|
||||
if (selected.remember) {
|
||||
options.trustStore.set(options.cwd, selected.trusted);
|
||||
}
|
||||
return selected.trusted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (options.appMode !== "interactive") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selected = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt);
|
||||
if (selected !== undefined) {
|
||||
if (selected.remember) {
|
||||
options.trustStore.set(options.cwd, selected.trusted);
|
||||
}
|
||||
return selected.trusted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface MainOptions {
|
||||
extensionFactories?: ExtensionFactory[];
|
||||
}
|
||||
@@ -700,11 +461,11 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
|
||||
}
|
||||
|
||||
if (await handlePackageCommand(args)) {
|
||||
if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await handleConfigCommand(args)) {
|
||||
if (await handleConfigCommand(args, { extensionFactories: options?.extensionFactories })) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -719,7 +480,7 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
}
|
||||
}
|
||||
time("parseArgs");
|
||||
let appMode = resolveAppMode(parsed, process.stdin.isTTY);
|
||||
let appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY);
|
||||
const shouldTakeOverStdout = appMode !== "interactive";
|
||||
if (shouldTakeOverStdout) {
|
||||
takeOverStdout();
|
||||
@@ -837,8 +598,7 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
cwd,
|
||||
trustStore,
|
||||
trustOverride: parsed.projectTrustOverride,
|
||||
appMode: isInitialRuntime ? trustPromptMode : "print",
|
||||
settingsManagerForPrompt: startupSettingsManager,
|
||||
defaultProjectTrust: startupSettingsManager.getDefaultProjectTrust(),
|
||||
extensionsResult,
|
||||
projectTrustContext:
|
||||
projectTrustContext ??
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Text,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
|
||||
import type { WarningSettings } from "../../../core/settings-manager.ts";
|
||||
import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts";
|
||||
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { keyDisplayText } from "./keybinding-hints.ts";
|
||||
@@ -31,6 +31,16 @@ const THINKING_DESCRIPTIONS: Record<ThinkingLevel, string> = {
|
||||
xhigh: "Maximum reasoning (~32k tokens)",
|
||||
};
|
||||
|
||||
const DEFAULT_PROJECT_TRUST_LABELS: Record<DefaultProjectTrust, string> = {
|
||||
ask: "Ask",
|
||||
always: "Always trust",
|
||||
never: "Never trust",
|
||||
};
|
||||
|
||||
const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map(
|
||||
Object.entries(DEFAULT_PROJECT_TRUST_LABELS).map(([value, label]) => [label, value as DefaultProjectTrust]),
|
||||
);
|
||||
|
||||
export interface SettingsConfig {
|
||||
autoCompact: boolean;
|
||||
showImages: boolean;
|
||||
@@ -55,6 +65,7 @@ export interface SettingsConfig {
|
||||
editorPaddingX: number;
|
||||
autocompleteMaxVisible: number;
|
||||
quietStartup: boolean;
|
||||
defaultProjectTrust: DefaultProjectTrust;
|
||||
clearOnShrink: boolean;
|
||||
showTerminalProgress: boolean;
|
||||
warnings: WarningSettings;
|
||||
@@ -83,6 +94,7 @@ export interface SettingsCallbacks {
|
||||
onEditorPaddingXChange: (padding: number) => void;
|
||||
onAutocompleteMaxVisibleChange: (maxVisible: number) => void;
|
||||
onQuietStartupChange: (enabled: boolean) => void;
|
||||
onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void;
|
||||
onClearOnShrinkChange: (enabled: boolean) => void;
|
||||
onShowTerminalProgressChange: (enabled: boolean) => void;
|
||||
onWarningsChange: (warnings: WarningSettings) => void;
|
||||
@@ -277,6 +289,13 @@ export class SettingsSelectorComponent extends Container {
|
||||
currentValue: config.enableInstallTelemetry ? "true" : "false",
|
||||
values: ["true", "false"],
|
||||
},
|
||||
{
|
||||
id: "default-project-trust",
|
||||
label: "Default project trust",
|
||||
description: "Fallback behavior when no extension or saved trust decision decides project trust",
|
||||
currentValue: DEFAULT_PROJECT_TRUST_LABELS[config.defaultProjectTrust],
|
||||
values: Object.values(DEFAULT_PROJECT_TRUST_LABELS),
|
||||
},
|
||||
{
|
||||
id: "double-escape-action",
|
||||
label: "Double-escape action",
|
||||
@@ -512,6 +531,13 @@ export class SettingsSelectorComponent extends Container {
|
||||
case "install-telemetry":
|
||||
callbacks.onEnableInstallTelemetryChange(newValue === "true");
|
||||
break;
|
||||
case "default-project-trust": {
|
||||
const defaultProjectTrust = DEFAULT_PROJECT_TRUST_BY_LABEL.get(newValue);
|
||||
if (defaultProjectTrust) {
|
||||
callbacks.onDefaultProjectTrustChange(defaultProjectTrust);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "double-escape-action":
|
||||
callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree");
|
||||
break;
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
|
||||
import type { ProjectTrustDecision } from "../../../core/trust-manager.ts";
|
||||
import {
|
||||
getProjectTrustOptions,
|
||||
getProjectTrustPath,
|
||||
type ProjectTrustOption,
|
||||
type ProjectTrustStoreEntry,
|
||||
} from "../../../core/trust-manager.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
|
||||
|
||||
interface TrustOption {
|
||||
label: string;
|
||||
trusted: boolean;
|
||||
}
|
||||
export type TrustSelection = Pick<ProjectTrustOption, "trusted" | "updates">;
|
||||
|
||||
export interface TrustSelectorOptions {
|
||||
cwd: string;
|
||||
savedDecision: ProjectTrustDecision;
|
||||
savedDecision: ProjectTrustStoreEntry | null;
|
||||
projectTrusted: boolean;
|
||||
onSelect: (trusted: boolean) => void;
|
||||
onSelect: (selection: TrustSelection) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const TRUST_OPTIONS: TrustOption[] = [
|
||||
{ label: "Trust", trusted: true },
|
||||
{ label: "Do not trust", trusted: false },
|
||||
];
|
||||
|
||||
function formatDecision(decision: ProjectTrustDecision): string {
|
||||
if (decision === true) {
|
||||
return "trusted";
|
||||
function formatDecision(cwd: string, decision: ProjectTrustStoreEntry | null): string {
|
||||
if (decision === null) {
|
||||
return "none";
|
||||
}
|
||||
if (decision === false) {
|
||||
return "untrusted";
|
||||
const label = decision.decision ? "trusted" : "untrusted";
|
||||
if (decision.path !== getProjectTrustPath(cwd)) {
|
||||
return `${label} (inherited from ${decision.path})`;
|
||||
}
|
||||
return "none";
|
||||
return `${label} (${decision.path})`;
|
||||
}
|
||||
|
||||
export class TrustSelectorComponent extends Container {
|
||||
private selectedIndex: number;
|
||||
private readonly listContainer: Container;
|
||||
private readonly savedDecision: ProjectTrustDecision;
|
||||
private readonly onSelectCallback: (trusted: boolean) => void;
|
||||
private readonly trustOptions: ProjectTrustOption[];
|
||||
private readonly savedDecision: ProjectTrustStoreEntry | null;
|
||||
private readonly onSelectCallback: (selection: TrustSelection) => void;
|
||||
private readonly onCancelCallback: () => void;
|
||||
|
||||
constructor(options: TrustSelectorOptions) {
|
||||
super();
|
||||
|
||||
this.savedDecision = options.savedDecision;
|
||||
this.trustOptions = getProjectTrustOptions(options.cwd);
|
||||
this.selectedIndex = Math.max(
|
||||
0,
|
||||
TRUST_OPTIONS.findIndex((option) => option.trusted === options.savedDecision),
|
||||
this.trustOptions.findIndex((option) => this.isSavedOption(option)),
|
||||
);
|
||||
this.onSelectCallback = options.onSelect;
|
||||
this.onCancelCallback = options.onCancel;
|
||||
@@ -55,7 +55,9 @@ export class TrustSelectorComponent extends Container {
|
||||
this.addChild(new Text(theme.fg("accent", theme.bold("Project trust")), 1, 0));
|
||||
this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.savedDecision)}`), 1, 0));
|
||||
this.addChild(
|
||||
new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.cwd, options.savedDecision)}`), 1, 0),
|
||||
);
|
||||
this.addChild(
|
||||
new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0),
|
||||
);
|
||||
@@ -81,16 +83,24 @@ export class TrustSelectorComponent extends Container {
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
private isSavedOption(option: ProjectTrustOption): boolean {
|
||||
return (
|
||||
option.savedPath !== undefined &&
|
||||
this.savedDecision?.decision === option.trusted &&
|
||||
this.savedDecision.path === option.savedPath
|
||||
);
|
||||
}
|
||||
|
||||
private updateList(): void {
|
||||
this.listContainer.clear();
|
||||
for (let i = 0; i < TRUST_OPTIONS.length; i++) {
|
||||
const option = TRUST_OPTIONS[i];
|
||||
for (let i = 0; i < this.trustOptions.length; i++) {
|
||||
const option = this.trustOptions[i];
|
||||
if (!option) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isSelected = i === this.selectedIndex;
|
||||
const isCurrent = option.trusted === this.savedDecision;
|
||||
const isCurrent = this.isSavedOption(option);
|
||||
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
|
||||
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
|
||||
const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label);
|
||||
@@ -104,12 +114,12 @@ export class TrustSelectorComponent extends Container {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
this.updateList();
|
||||
} else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
|
||||
this.selectedIndex = Math.min(TRUST_OPTIONS.length - 1, this.selectedIndex + 1);
|
||||
this.selectedIndex = Math.min(this.trustOptions.length - 1, this.selectedIndex + 1);
|
||||
this.updateList();
|
||||
} else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
|
||||
const selected = TRUST_OPTIONS[this.selectedIndex];
|
||||
const selected = this.trustOptions[this.selectedIndex];
|
||||
if (selected) {
|
||||
this.onSelectCallback(selected.trusted);
|
||||
this.onSelectCallback({ trusted: selected.trusted, updates: selected.updates });
|
||||
}
|
||||
} else if (kb.matches(keyData, "tui.select.cancel")) {
|
||||
this.onCancelCallback();
|
||||
|
||||
@@ -3277,7 +3277,7 @@ export class InteractiveMode {
|
||||
new Text(
|
||||
theme.fg(
|
||||
"warning",
|
||||
"This project is not trusted. Project instructions (AGENTS.md/CLAUDE.md), .pi resources, and project packages are ignored. Use /trust to save a trust decision, then restart pi.",
|
||||
"This project is not trusted. Project .pi resources and packages are ignored. Use /trust to save a trust decision, then restart pi.",
|
||||
),
|
||||
1,
|
||||
0,
|
||||
@@ -3966,6 +3966,7 @@ export class InteractiveMode {
|
||||
doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(),
|
||||
treeFilterMode: this.settingsManager.getTreeFilterMode(),
|
||||
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
|
||||
defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(),
|
||||
editorPaddingX: this.settingsManager.getEditorPaddingX(),
|
||||
autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),
|
||||
quietStartup: this.settingsManager.getQuietStartup(),
|
||||
@@ -4059,6 +4060,9 @@ export class InteractiveMode {
|
||||
onQuietStartupChange: (enabled) => {
|
||||
this.settingsManager.setQuietStartup(enabled);
|
||||
},
|
||||
onDefaultProjectTrustChange: (defaultProjectTrust) => {
|
||||
this.settingsManager.setDefaultProjectTrust(defaultProjectTrust);
|
||||
},
|
||||
onDoubleEscapeActionChange: (action) => {
|
||||
this.settingsManager.setDoubleEscapeAction(action);
|
||||
},
|
||||
@@ -4213,17 +4217,17 @@ export class InteractiveMode {
|
||||
private showTrustSelector(): void {
|
||||
const cwd = this.sessionManager.getCwd();
|
||||
const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir);
|
||||
const savedDecision = trustStore.get(cwd);
|
||||
const savedDecision = trustStore.getEntry(cwd);
|
||||
this.showSelector((done) => {
|
||||
const selector = new TrustSelectorComponent({
|
||||
cwd,
|
||||
savedDecision,
|
||||
projectTrusted: this.settingsManager.isProjectTrusted(),
|
||||
onSelect: (trusted) => {
|
||||
trustStore.set(cwd, trusted);
|
||||
onSelect: (selection) => {
|
||||
trustStore.setMany(selection.updates);
|
||||
done();
|
||||
this.showStatus(
|
||||
`Saved trust decision: ${trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`,
|
||||
`Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`,
|
||||
);
|
||||
},
|
||||
onCancel: () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { selectConfig } from "./cli/config-selector.ts";
|
||||
import { createProjectTrustContext } from "./cli/project-trust.ts";
|
||||
import {
|
||||
APP_NAME,
|
||||
detectInstallMethod,
|
||||
@@ -12,7 +13,10 @@ import {
|
||||
type SelfUpdateCommand,
|
||||
VERSION,
|
||||
} from "./config.ts";
|
||||
import type { ExtensionFactory } from "./core/extensions/types.ts";
|
||||
import { DefaultPackageManager } from "./core/package-manager.ts";
|
||||
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
|
||||
import { DefaultResourceLoader } from "./core/resource-loader.ts";
|
||||
import { SettingsManager } from "./core/settings-manager.ts";
|
||||
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { spawnProcess } from "./utils/child-process.ts";
|
||||
@@ -425,22 +429,82 @@ function parseProjectTrustOverride(args: readonly string[]): boolean | undefined
|
||||
return trustOverride;
|
||||
}
|
||||
|
||||
function resolveProjectTrusted(cwd: string, agentDir: string, trustOverride: boolean | undefined): boolean {
|
||||
if (trustOverride !== undefined) {
|
||||
return trustOverride;
|
||||
}
|
||||
return !hasProjectTrustInputs(cwd) || new ProjectTrustStore(agentDir).get(cwd) === true;
|
||||
export interface PackageCommandRuntimeOptions {
|
||||
extensionFactories?: ExtensionFactory[];
|
||||
}
|
||||
|
||||
export async function handleConfigCommand(args: string[]): Promise<boolean> {
|
||||
interface CommandSettingsResult {
|
||||
settingsManager: SettingsManager;
|
||||
projectTrustWarnings: string[];
|
||||
}
|
||||
|
||||
function getCommandAppMode(): AppMode {
|
||||
return process.stdin.isTTY && process.stdout.isTTY ? "interactive" : "print";
|
||||
}
|
||||
|
||||
function reportProjectTrustWarnings(warnings: readonly string[]): void {
|
||||
for (const warning of warnings) {
|
||||
console.error(chalk.yellow(`Warning: ${warning}`));
|
||||
}
|
||||
}
|
||||
|
||||
async function createCommandSettingsManager(options: {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
projectTrustOverride?: boolean;
|
||||
extensionFactories?: ExtensionFactory[];
|
||||
}): Promise<CommandSettingsResult> {
|
||||
const settingsManager = SettingsManager.create(options.cwd, options.agentDir, { projectTrusted: false });
|
||||
const projectTrustWarnings: string[] = [];
|
||||
const appMode = getCommandAppMode();
|
||||
const extensionsResult =
|
||||
options.projectTrustOverride === undefined && hasProjectTrustInputs(options.cwd)
|
||||
? await new DefaultResourceLoader({
|
||||
cwd: options.cwd,
|
||||
agentDir: options.agentDir,
|
||||
settingsManager,
|
||||
extensionFactories: options.extensionFactories,
|
||||
}).loadProjectTrustExtensions()
|
||||
: undefined;
|
||||
for (const error of extensionsResult?.errors ?? []) {
|
||||
projectTrustWarnings.push(`Failed to load extension "${error.path}": ${error.error}`);
|
||||
}
|
||||
|
||||
const projectTrusted = await resolveProjectTrusted({
|
||||
cwd: options.cwd,
|
||||
trustStore: new ProjectTrustStore(options.agentDir),
|
||||
trustOverride: options.projectTrustOverride,
|
||||
defaultProjectTrust: settingsManager.getDefaultProjectTrust(),
|
||||
extensionsResult,
|
||||
projectTrustContext: createProjectTrustContext({
|
||||
cwd: options.cwd,
|
||||
mode: appMode,
|
||||
settingsManager,
|
||||
hasUI: appMode === "interactive",
|
||||
}),
|
||||
onExtensionError: (message) => projectTrustWarnings.push(message),
|
||||
});
|
||||
settingsManager.setProjectTrusted(projectTrusted);
|
||||
return { settingsManager, projectTrustWarnings };
|
||||
}
|
||||
|
||||
export async function handleConfigCommand(
|
||||
args: string[],
|
||||
runtimeOptions: PackageCommandRuntimeOptions = {},
|
||||
): Promise<boolean> {
|
||||
if (args[0] !== "config") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
const agentDir = getAgentDir();
|
||||
const projectTrusted = parseProjectTrustOverride(args) ?? true;
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
|
||||
const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({
|
||||
cwd,
|
||||
agentDir,
|
||||
projectTrustOverride: parseProjectTrustOverride(args),
|
||||
extensionFactories: runtimeOptions.extensionFactories,
|
||||
});
|
||||
reportProjectTrustWarnings(projectTrustWarnings);
|
||||
reportSettingsErrors(settingsManager, "config command");
|
||||
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||
const resolvedPaths = await packageManager.resolve();
|
||||
@@ -455,7 +519,10 @@ export async function handleConfigCommand(args: string[]): Promise<boolean> {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export async function handlePackageCommand(args: string[]): Promise<boolean> {
|
||||
export async function handlePackageCommand(
|
||||
args: string[],
|
||||
runtimeOptions: PackageCommandRuntimeOptions = {},
|
||||
): Promise<boolean> {
|
||||
const options = parsePackageCommand(args);
|
||||
if (!options) {
|
||||
return false;
|
||||
@@ -505,13 +572,18 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
|
||||
const cwd = process.cwd();
|
||||
const agentDir = getAgentDir();
|
||||
const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local;
|
||||
const projectTrusted = resolveProjectTrusted(cwd, agentDir, options.projectTrustOverride);
|
||||
if (!projectTrusted && writesProjectPackageConfig) {
|
||||
const { settingsManager, projectTrustWarnings } = await createCommandSettingsManager({
|
||||
cwd,
|
||||
agentDir,
|
||||
projectTrustOverride: options.projectTrustOverride,
|
||||
extensionFactories: runtimeOptions.extensionFactories,
|
||||
});
|
||||
reportProjectTrustWarnings(projectTrustWarnings);
|
||||
if (!settingsManager.isProjectTrusted() && writesProjectPackageConfig) {
|
||||
console.error(chalk.red("Project is not trusted. Use --approve to modify local package config."));
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
|
||||
reportSettingsErrors(settingsManager, "package command");
|
||||
const selfUpdateNpmCommand = settingsManager.getGlobalSettings().npmCommand;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user