feat(coding-agent): add project approval flags

This commit is contained in:
Armin Ronacher 2026-06-03 15:44:22 +02:00
parent 37f160cab6
commit c3e91ceac0
10 changed files with 124 additions and 47 deletions

View file

@ -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 <text>` | Replace default prompt (context files and skills still appended) |
| `--append-system-prompt <text>` | 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 |

View file

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

View file

@ -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 <text>` | Replace default prompt; context files and skills are still appended |
| `--append-system-prompt <text>` | 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 |

View file

@ -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 <source> [-l] Remove extension source from settings
${APP_NAME} uninstall <source> [-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} <command> --help Show help for install/remove/uninstall/update/list
${chalk.bold("Options:")}
@ -269,7 +273,8 @@ ${chalk.bold("Options:")}
--export <file> 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

View file

@ -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<boolean> {
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();

View file

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

View file

@ -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 <source> [-l]`;
case "update":
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--force]`;
return `${APP_NAME} update [source|self|pi] [--self] [--extensions] [--extension <source>] [--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 <source> [-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 <source> 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<boolean> {
if (args[0] !== "config") {
return false;
@ -399,11 +433,9 @@ export async function handleConfigCommand(args: string[]): Promise<boolean> {
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<boolean> {
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;
}

View file

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

View file

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

View file

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