diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 4a07483fd..342856314 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -290,7 +290,7 @@ See [docs/settings.md](docs/settings.md) for all options. ### Project Trust -Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project `.pi` for one run regardless of trust. +Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--approve`/`-a` to load project `.pi` for one run regardless of trust, or `--no-approve`/`-na` to skip it for one run. ### Telemetry and update checks @@ -589,7 +589,8 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set | `--system-prompt ` | Replace default prompt (context files and skills still appended) | | `--append-system-prompt ` | Append to system prompt | | `--verbose` | Force verbose startup | -| `-f`, `--force` | Force loading project `.pi` regardless of trust | +| `-a`, `--approve` | Load project `.pi` regardless of trust | +| `-na`, `--no-approve` | Skip project `.pi` regardless of trust | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 5618d2c46..8dd828e6d 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -11,7 +11,7 @@ Edit directly or use `/settings` for common options. ## Project Trust -Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project `.pi` for one run regardless of trust. +Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--approve`/`-a` to load project `.pi` for one run regardless of trust, or `--no-approve`/`-na` to skip it for one run. ## All Settings diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 2b235eea3..004c8c287 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -113,7 +113,7 @@ Append to the default prompt without replacing it with `APPEND_SYSTEM.md` in tho ### Project Trust -Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--force`/`-f` to load project `.pi` for one run regardless of trust. +Interactive startup asks before loading `.pi` in a working directory whose trust has not been set. Decisions are stored in `~/.pi/agent/trust.json` by CWD: `true` loads the project `.pi` directory, `false` skips all project `.pi` settings, resources, packages, and migrations, and a missing entry asks again. Use `/trust yes`, `/trust no`, `/trust reset`, or `/trust` to update the current CWD. Use `--approve`/`-a` to load project `.pi` for one run regardless of trust, or `--no-approve`/`-na` to skip it for one run. ## Exporting and Sharing Sessions @@ -224,7 +224,8 @@ pi --no-extensions -e ./my-extension.ts | `--system-prompt ` | Replace default prompt; context files and skills are still appended | | `--append-system-prompt ` | Append to system prompt | | `--verbose` | Force verbose startup | -| `-f`, `--force` | Force loading project `.pi` regardless of trust | +| `-a`, `--approve` | Load project `.pi` regardless of trust | +| `-na`, `--no-approve` | Skip project `.pi` regardless of trust | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index d1fef4908..147b4a90a 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -46,7 +46,7 @@ export interface Args { listModels?: string | true; offline?: boolean; verbose?: boolean; - force?: boolean; + projectPiApproval?: boolean; messages: string[]; fileArgs: string[]; /** Unknown flags (potentially extension flags) - map of flag name to value */ @@ -177,8 +177,10 @@ export function parseArgs(args: string[]): Args { } } else if (arg === "--verbose") { result.verbose = true; - } else if (arg === "--force" || arg === "-f") { - result.force = true; + } else if (arg === "--approve" || arg === "-a") { + result.projectPiApproval = true; + } else if (arg === "--no-approve" || arg === "-na") { + result.projectPiApproval = false; } else if (arg === "--offline") { result.offline = true; } else if (arg.startsWith("@")) { @@ -228,8 +230,10 @@ ${chalk.bold("Commands:")} ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} uninstall [-l] Alias for remove ${APP_NAME} update [source|self|pi] Update pi and installed extensions - ${APP_NAME} list [--force] List installed extensions from settings - ${APP_NAME} config [--force] Open TUI to enable/disable package resources + ${APP_NAME} list [--approve|--no-approve] + List installed extensions from settings + ${APP_NAME} config [--approve|--no-approve] + Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/uninstall/update/list ${chalk.bold("Options:")} @@ -269,7 +273,8 @@ ${chalk.bold("Options:")} --export Export session file to HTML and exit --list-models [search] List available models (with optional fuzzy search) --verbose Force verbose startup (overrides quietStartup setting) - --force, -f Force loading project .pi + --approve, -a Load project .pi for this run regardless of trust + --no-approve, -na Skip project .pi for this run regardless of trust --offline Disable startup network operations (same as PI_OFFLINE=1) --help, -h Show this help --version, -v Show version number diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 0be26b00b..bb2ec5fef 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -498,12 +498,12 @@ async function promptForProjectTrust(cwd: string, settingsManager: SettingsManag async function resolveProjectPiTrusted(options: { cwd: string; trustStore: ProjectTrustStore; - force: boolean; + projectPiApproval?: boolean; appMode: AppMode; settingsManagerForPrompt: SettingsManager; }): Promise { - if (options.force) { - return true; + if (options.projectPiApproval !== undefined) { + return options.projectPiApproval; } if (!hasProjectPiDirectory(options.cwd)) { return false; @@ -597,12 +597,12 @@ export async function main(args: string[], options?: MainOptions) { const agentDir = getAgentDir(); const promptSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: false }); const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; - const forceProjectPiTrust = parsed.force === true; + const projectPiApproval = parsed.projectPiApproval; const trustStore = new ProjectTrustStore(agentDir); const startupProjectPiTrusted = await resolveProjectPiTrusted({ cwd, trustStore, - force: forceProjectPiTrust, + projectPiApproval, appMode: trustPromptMode, settingsManagerForPrompt: promptSettingsManager, }); @@ -655,7 +655,7 @@ export async function main(args: string[], options?: MainOptions) { await resolveProjectPiTrusted({ cwd: initialRuntimeCwd, trustStore, - force: forceProjectPiTrust, + projectPiApproval, appMode: trustPromptMode, settingsManagerForPrompt: promptSettingsManager, }); @@ -671,7 +671,7 @@ export async function main(args: string[], options?: MainOptions) { sessionManager, sessionStartEvent, }) => { - const projectPiTrusted = forceProjectPiTrust || (hasProjectPiDirectory(cwd) && trustStore.get(cwd) === true); + const projectPiTrusted = projectPiApproval ?? (hasProjectPiDirectory(cwd) && trustStore.get(cwd) === true); const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: projectPiTrusted }); const services = await createAgentSessionServices({ cwd, @@ -832,7 +832,7 @@ export async function main(args: string[], options?: MainOptions) { initialImages, initialMessages: parsed.messages, verbose: parsed.verbose, - forceProjectPiTrust, + projectPiApproval, }); if (startupBenchmark) { await interactiveMode.init(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index dcd67074e..43ca5f404 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -256,8 +256,8 @@ export interface InteractiveModeOptions { initialMessages?: string[]; /** Force verbose startup (overrides quietStartup setting) */ verbose?: boolean; - /** Force project .pi trust for this process */ - forceProjectPiTrust?: boolean; + /** Override project .pi trust for this process */ + projectPiApproval?: boolean; } export class InteractiveMode { @@ -5000,7 +5000,7 @@ export class InteractiveMode { } trustStore.set(cwd, decision); - const projectPiTrusted = this.options.forceProjectPiTrust === true || decision === true; + const projectPiTrusted = this.options.projectPiApproval ?? decision === true; this.settingsManager.setProjectConfigTrusted(projectPiTrusted); await this.handleReloadCommand(); this.showStatus(`Project trust: ${this.formatTrustDecision(decision)}`); diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 04fd2d53e..bf58fbcea 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -49,6 +49,7 @@ interface PackageCommandOptions { updateTarget?: UpdateTarget; local: boolean; force: boolean; + projectPiApproval?: boolean; help: boolean; invalidOption?: string; invalidArgument?: string; @@ -73,9 +74,9 @@ function getPackageCommandUsage(command: PackageCommand): string { case "remove": return `${APP_NAME} remove [-l]`; case "update": - return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ] [--force]`; + return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension ] [--approve|--no-approve] [--force]`; case "list": - return `${APP_NAME} list [--force]`; + return `${APP_NAME} list [--approve|--no-approve]`; } } @@ -88,8 +89,9 @@ function printPackageCommandHelp(command: PackageCommand): void { Install a package and add it to settings. Options: - -l, --local Install project-locally (.pi/settings.json) - -f, --force Trust project .pi for this command + -l, --local Install project-locally (.pi/settings.json) + -a, --approve Load project .pi for this command + -na, --no-approve Skip project .pi for this command Examples: ${APP_NAME} install npm:@foo/bar @@ -109,8 +111,9 @@ Remove a package and its source from settings. Alias: ${APP_NAME} uninstall [-l] Options: - -l, --local Remove from project settings (.pi/settings.json) - -f, --force Trust project .pi for this command + -l, --local Remove from project settings (.pi/settings.json) + -a, --approve Load project .pi for this command + -na, --no-approve Skip project .pi for this command Examples: ${APP_NAME} remove npm:@foo/bar @@ -128,6 +131,8 @@ Options: --self Update pi only --extensions Update installed packages only --extension Update one package only + -a, --approve Load project .pi for this command + -na, --no-approve Skip project .pi for this command --force Reinstall pi even if the current version is latest Short forms: @@ -144,7 +149,8 @@ Short forms: List installed packages from user and project settings. Options: - -f, --force Trust project .pi for this command + -a, --approve Load project .pi for this command + -na, --no-approve Skip project .pi for this command `); return; } @@ -164,6 +170,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let local = false; let force = false; + let projectPiApproval: boolean | undefined; let help = false; let invalidOption: string | undefined; let invalidArgument: string | undefined; @@ -208,8 +215,22 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined continue; } - if (arg === "--force" || arg === "-f") { - force = true; + if (arg === "--approve" || arg === "-a") { + projectPiApproval = true; + continue; + } + + if (arg === "--no-approve" || arg === "-na") { + projectPiApproval = false; + continue; + } + + if (arg === "--force") { + if (command === "update") { + force = true; + } else { + invalidOption = invalidOption ?? arg; + } continue; } @@ -282,6 +303,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined updateTarget, local, force, + projectPiApproval, help, invalidOption, invalidArgument, @@ -391,6 +413,18 @@ function prepareWindowsNpmSelfUpdate(): void { quarantineWindowsNativeDependencies(packageDir); } +function parseProjectPiApproval(args: readonly string[]): boolean | undefined { + let approval: boolean | undefined; + for (const arg of args) { + if (arg === "--approve" || arg === "-a") { + approval = true; + } else if (arg === "--no-approve" || arg === "-na") { + approval = false; + } + } + return approval; +} + export async function handleConfigCommand(args: string[]): Promise { if (args[0] !== "config") { return false; @@ -399,11 +433,9 @@ export async function handleConfigCommand(args: string[]): Promise { const cwd = process.cwd(); const agentDir = getAgentDir(); const projectPiExists = hasProjectPiDirectory(cwd); + const projectPiApproval = parseProjectPiApproval(args); const projectPiTrusted = - !projectPiExists || - args.includes("--force") || - args.includes("-f") || - new ProjectTrustStore(agentDir).get(cwd) === true; + projectPiApproval ?? (!projectPiExists || new ProjectTrustStore(agentDir).get(cwd) === true); const settingsManager = SettingsManager.create(cwd, agentDir, { projectConfigTrusted: projectPiTrusted }); reportSettingsErrors(settingsManager, "config command"); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); @@ -470,14 +502,12 @@ export async function handlePackageCommand(args: string[]): Promise { const agentDir = getAgentDir(); const projectPiExists = hasProjectPiDirectory(cwd); const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; - const commandForcesProjectPiTrust = - options.force && (options.command === "install" || options.command === "remove" || options.command === "list"); const projectPiTrusted = - commandForcesProjectPiTrust || - (projectPiExists && new ProjectTrustStore(agentDir).get(cwd) === true) || - (writesProjectPackageConfig && !projectPiExists); - if (!projectPiTrusted && projectPiExists && writesProjectPackageConfig) { - console.error(chalk.red("Project .pi is not trusted. Use --force to modify local package config.")); + options.projectPiApproval ?? + ((projectPiExists && new ProjectTrustStore(agentDir).get(cwd) === true) || + (writesProjectPackageConfig && !projectPiExists)); + if (!projectPiTrusted && writesProjectPackageConfig) { + console.error(chalk.red("Project .pi is not trusted. Use --approve to modify local package config.")); process.exitCode = 1; return true; } diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 78fd832c8..92a4dc27a 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -293,6 +293,28 @@ describe("parseArgs", () => { }); }); + describe("project .pi approval flags", () => { + test("parses --approve", () => { + const result = parseArgs(["--approve"]); + expect(result.projectPiApproval).toBe(true); + }); + + test("parses -a shorthand", () => { + const result = parseArgs(["-a"]); + expect(result.projectPiApproval).toBe(true); + }); + + test("parses --no-approve", () => { + const result = parseArgs(["--no-approve"]); + expect(result.projectPiApproval).toBe(false); + }); + + test("parses -na shorthand", () => { + const result = parseArgs(["-na"]); + expect(result.projectPiApproval).toBe(false); + }); + }); + describe("--verbose flag", () => { test("parses --verbose flag", () => { const result = parseArgs(["--verbose"]); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 68605bdac..ce130dfff 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -102,13 +102,13 @@ describe("package commands", () => { } }); - it("forces project trust for list with --force", async () => { + it("approves project trust for list with --approve", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { - await expect(main(["list", "--force"])).resolves.toBeUndefined(); + await expect(main(["list", "--approve"])).resolves.toBeUndefined(); const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); expect(stdout).toContain("Project packages:"); @@ -139,6 +139,24 @@ describe("package commands", () => { } }); + it("disapproves remembered project trust for list with --no-approve", async () => { + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ packages: ["npm:@project/pkg"] })); + new ProjectTrustStore(agentDir).set(projectDir, true); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + await expect(main(["list", "--no-approve"])).resolves.toBeUndefined(); + + const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); + expect(stdout).toContain("No packages installed."); + expect(stdout).not.toContain("Project packages:"); + expect(process.exitCode).toBeUndefined(); + } finally { + logSpy.mockRestore(); + } + }); + it("blocks local package changes when project .pi is untrusted", async () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -147,7 +165,7 @@ describe("package commands", () => { await expect(main(["install", "-l", "./local-package"])).resolves.toBeUndefined(); const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n"); - expect(stderr).toContain("Project .pi is not trusted. Use --force to modify local package config."); + expect(stderr).toContain("Project .pi is not trusted. Use --approve to modify local package config."); expect(process.exitCode).toBe(1); } finally { errorSpy.mockRestore(); diff --git a/packages/coding-agent/test/stdout-cleanliness.test.ts b/packages/coding-agent/test/stdout-cleanliness.test.ts index 5d7f8f125..686ab88cf 100644 --- a/packages/coding-agent/test/stdout-cleanliness.test.ts +++ b/packages/coding-agent/test/stdout-cleanliness.test.ts @@ -81,7 +81,7 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; describe("stdout cleanliness in non-interactive modes", () => { it("keeps stdout empty for --mode json --help while routing startup chatter to stderr", async () => { - const result = await runCli(["--mode", "json", "--force", "--help"]); + const result = await runCli(["--mode", "json", "--approve", "--help"]); expect(result.code).toBe(0); expect(result.stdout).toBe(""); @@ -91,7 +91,7 @@ describe("stdout cleanliness in non-interactive modes", () => { }); it("keeps stdout empty for -p --help while routing startup chatter to stderr", async () => { - const result = await runCli(["-p", "--force", "--help"]); + const result = await runCli(["-p", "--approve", "--help"]); expect(result.code).toBe(0); expect(result.stdout).toBe("");