my-pi 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-pi",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Composable pi coding agent with MCP, LSP, prompt presets, and local eval telemetry",
5
5
  "keywords": [
6
6
  "cli",
@@ -9,7 +9,6 @@
9
9
  "lsp",
10
10
  "mcp",
11
11
  "pi",
12
- "pi-package",
13
12
  "sqlite",
14
13
  "telemetry"
15
14
  ],
@@ -44,18 +43,19 @@
44
43
  "@mariozechner/pi-tui": "^0.70.6",
45
44
  "citty": "^0.2.2",
46
45
  "typebox": "^1.1.34",
47
- "@spences10/pi-lsp": "0.0.6",
48
- "@spences10/pi-confirm-destructive": "0.0.5",
49
- "@spences10/pi-mcp": "0.0.7",
50
- "@spences10/pi-recall": "0.0.3",
51
- "@spences10/pi-nopeek": "0.0.3",
46
+ "@spences10/pi-lsp": "0.0.7",
52
47
  "@spences10/pi-child-env": "0.1.1",
48
+ "@spences10/pi-nopeek": "0.0.3",
49
+ "@spences10/pi-confirm-destructive": "0.0.5",
50
+ "@spences10/pi-mcp": "0.0.8",
53
51
  "@spences10/pi-omnisearch": "0.0.3",
52
+ "@spences10/pi-project-trust": "0.0.2",
53
+ "@spences10/pi-recall": "0.0.3",
54
54
  "@spences10/pi-redact": "0.0.3",
55
55
  "@spences10/pi-skills": "0.0.4",
56
+ "@spences10/pi-team-mode": "0.0.5",
56
57
  "@spences10/pi-sqlite-tools": "0.0.3",
57
- "@spences10/pi-telemetry": "0.0.3",
58
- "@spences10/pi-team-mode": "0.0.3"
58
+ "@spences10/pi-telemetry": "0.0.3"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@changesets/cli": "^2.31.0",
@@ -66,11 +66,6 @@
66
66
  "engines": {
67
67
  "node": ">=22.0.0"
68
68
  },
69
- "pi": {
70
- "themes": [
71
- "./themes"
72
- ]
73
- },
74
69
  "scripts": {
75
70
  "build": "pnpm --filter \"./packages/*\" run build && vp pack",
76
71
  "dev": "vp pack --watch",
@@ -272,6 +272,42 @@ describe('hooks-resolution extension', () => {
272
272
  warn.mockRestore();
273
273
  });
274
274
 
275
+ it('loads untrusted hook config once when env allows it', async () => {
276
+ const previous = process.env.MY_PI_HOOKS_CONFIG;
277
+ process.env.MY_PI_HOOKS_CONFIG = 'allow';
278
+ try {
279
+ const dir = create_temp_dir();
280
+ mkdirSync(join(dir, '.git'));
281
+ mkdirSync(join(dir, '.pi'));
282
+ writeFileSync(
283
+ join(dir, '.pi', 'hooks.json'),
284
+ JSON.stringify({
285
+ hooks: {
286
+ PostToolUse: [{ command: 'echo allowed' }],
287
+ },
288
+ }),
289
+ );
290
+ const { pi, events } = create_test_pi();
291
+ const load_hooks_impl = vi
292
+ .fn<(cwd: string) => HookState>()
293
+ .mockReturnValue({ project_dir: dir, hooks: [] });
294
+
295
+ await create_hooks_resolution_extension({
296
+ load_hooks: load_hooks_impl,
297
+ })(pi);
298
+
299
+ const start = events.get('session_start');
300
+ const { ctx } = create_context({ cwd: dir, hasUI: false });
301
+ await start({}, ctx);
302
+
303
+ expect(load_hooks_impl).toHaveBeenCalledWith(dir);
304
+ } finally {
305
+ if (previous === undefined)
306
+ delete process.env.MY_PI_HOOKS_CONFIG;
307
+ else process.env.MY_PI_HOOKS_CONFIG = previous;
308
+ }
309
+ });
310
+
275
311
  it('runs matching hooks once per unique command and notifies on success', async () => {
276
312
  const { pi, events } = create_test_pi();
277
313
  const run_command_hook = vi
@@ -6,14 +6,18 @@ import type {
6
6
  ExtensionFactory,
7
7
  ToolResultEvent,
8
8
  } from '@mariozechner/pi-coding-agent';
9
+ import {
10
+ resolve_project_trust,
11
+ type ProjectTrustSubject,
12
+ } from '@spences10/pi-project-trust';
9
13
  import { spawn } from 'node:child_process';
10
14
  import { createHash } from 'node:crypto';
11
15
  import { existsSync, readFileSync, statSync } from 'node:fs';
12
16
  import { basename, dirname, join, resolve } from 'node:path';
13
17
  import { create_child_process_env } from './env.js';
14
18
  import {
19
+ default_hooks_trust_store_path,
15
20
  is_hooks_config_trusted,
16
- trust_hooks_config,
17
21
  } from './trust.js';
18
22
 
19
23
  const HOOK_TIMEOUT_MS = 10 * 60 * 1000;
@@ -526,24 +530,9 @@ export function hook_name(command: string): string {
526
530
  return basename(first_token);
527
531
  }
528
532
 
529
- type HooksConfigDecision = 'allow' | 'trust' | 'skip';
530
-
531
- function normalize_hooks_config_decision(
532
- value: string | undefined,
533
- ): HooksConfigDecision | undefined {
534
- const normalized = value?.trim().toLowerCase();
535
- if (!normalized) return undefined;
536
- if (['1', 'true', 'yes', 'allow'].includes(normalized)) {
537
- return 'allow';
538
- }
539
- if (normalized === 'trust') return 'trust';
540
- if (['0', 'false', 'no', 'skip', 'disable'].includes(normalized)) {
541
- return 'skip';
542
- }
543
- return undefined;
544
- }
545
-
546
- function format_hooks_config_prompt(info: HooksConfigInfo): string {
533
+ function create_hooks_trust_subject(
534
+ info: HooksConfigInfo,
535
+ ): ProjectTrustSubject {
547
536
  const source_lines = info.sources.map((source) => `- ${source}`);
548
537
  const hook_lines =
549
538
  info.hooks.length === 0
@@ -554,15 +543,27 @@ function format_hooks_config_prompt(info: HooksConfigInfo): string {
554
543
  : '';
555
544
  return `- ${hook.event_name}${matcher}: ${hook.command}`;
556
545
  });
557
- return [
558
- 'Project hook config can execute shell commands after tool use. Trust these hooks?',
559
- `Project: ${info.project_dir}`,
560
- `SHA-256: ${info.hash}`,
561
- 'Sources:',
562
- ...source_lines,
563
- 'Commands:',
564
- ...hook_lines,
565
- ].join('\n');
546
+ return {
547
+ kind: 'hooks-config',
548
+ id: info.project_dir,
549
+ store_key: info.project_dir,
550
+ hash: info.hash,
551
+ env_key: HOOKS_CONFIG_ENV,
552
+ prompt_title:
553
+ 'Project hook config can execute shell commands after tool use. Trust these hooks?',
554
+ summary_lines: [
555
+ 'Sources:',
556
+ ...source_lines,
557
+ 'Commands:',
558
+ ...hook_lines,
559
+ ],
560
+ choices: {
561
+ allow_once: 'Allow once for this session',
562
+ trust: 'Trust this repo until hook config changes',
563
+ skip: 'Skip project hooks',
564
+ },
565
+ headless_warning: `Skipping untrusted hook config in ${info.project_dir}. Set ${HOOKS_CONFIG_ENV}=allow to enable hooks for this run.`,
566
+ };
566
567
  }
567
568
 
568
569
  async function should_load_hooks_config(
@@ -574,36 +575,27 @@ async function should_load_hooks_config(
574
575
  if (is_hooks_config_trusted(info.project_dir, info.hash))
575
576
  return true;
576
577
 
577
- const env_decision = normalize_hooks_config_decision(
578
- process.env[HOOKS_CONFIG_ENV],
578
+ const decision = await resolve_project_trust(
579
+ create_hooks_trust_subject(info),
580
+ {
581
+ has_ui: ctx?.hasUI,
582
+ select: ctx?.hasUI
583
+ ? async (
584
+ message: string,
585
+ choices: string[],
586
+ ): Promise<string> => {
587
+ const selected = await ctx.ui.select(message, choices);
588
+ return selected ?? '';
589
+ }
590
+ : undefined,
591
+ env: process.env,
592
+ trust_store_path: default_hooks_trust_store_path(),
593
+ },
579
594
  );
580
- if (env_decision === 'trust') {
581
- trust_hooks_config(info.project_dir, info.hash);
582
- return true;
583
- }
584
- if (env_decision === 'allow') return true;
585
- if (env_decision === 'skip') return false;
586
-
587
- if (!ctx?.hasUI) {
588
- console.warn(
589
- `Skipping untrusted hook config in ${info.project_dir}. Set ${HOOKS_CONFIG_ENV}=allow to enable hooks for this run.`,
590
- );
591
- return false;
592
- }
593
-
594
- const choice = await ctx.ui.select(
595
- format_hooks_config_prompt(info),
596
- [
597
- 'Allow once for this session',
598
- 'Trust this repo until hook config changes',
599
- 'Skip project hooks',
600
- ],
595
+ return (
596
+ decision.action === 'allow-once' ||
597
+ decision.action === 'trust-persisted'
601
598
  );
602
- if (choice === 'Trust this repo until hook config changes') {
603
- trust_hooks_config(info.project_dir, info.hash);
604
- return true;
605
- }
606
- return choice === 'Allow once for this session';
607
599
  }
608
600
 
609
601
  export interface HooksResolutionOptions {
@@ -1,32 +1,52 @@
1
1
  import {
2
- existsSync,
3
- mkdirSync,
4
- readFileSync,
5
- writeFileSync,
6
- } from 'node:fs';
2
+ is_project_subject_trusted,
3
+ read_project_trust_store,
4
+ trust_project_subject,
5
+ type ProjectTrustSubject,
6
+ } from '@spences10/pi-project-trust';
7
7
  import { homedir } from 'node:os';
8
- import { dirname, join } from 'node:path';
8
+ import { join } from 'node:path';
9
9
 
10
- interface TrustedHooksEntry {
11
- project_dir: string;
12
- hash: string;
13
- trusted_at: string;
14
- }
15
-
16
- type TrustedHooks = Record<string, TrustedHooksEntry>;
10
+ const HOOKS_CONFIG_ENV = 'MY_PI_HOOKS_CONFIG';
17
11
 
18
12
  export function default_hooks_trust_store_path(): string {
19
13
  return join(homedir(), '.pi', 'agent', 'trusted-hooks.json');
20
14
  }
21
15
 
16
+ export function create_hooks_config_trust_subject(
17
+ project_dir: string,
18
+ hash: string,
19
+ ): ProjectTrustSubject {
20
+ return {
21
+ kind: 'hooks-config',
22
+ id: project_dir,
23
+ store_key: project_dir,
24
+ hash,
25
+ env_key: HOOKS_CONFIG_ENV,
26
+ prompt_title:
27
+ 'Project hook config can execute shell commands after tool use. Trust these hooks?',
28
+ };
29
+ }
30
+
22
31
  export function is_hooks_config_trusted(
23
32
  project_dir: string,
24
33
  hash: string,
25
34
  trust_store_path = default_hooks_trust_store_path(),
26
35
  ): boolean {
27
- const trusted_hooks = read_trusted_hooks(trust_store_path);
28
- const entry = trusted_hooks[project_dir];
29
- return entry?.hash === hash;
36
+ const subject = create_hooks_config_trust_subject(
37
+ project_dir,
38
+ hash,
39
+ );
40
+ if (is_project_subject_trusted(subject, trust_store_path))
41
+ return true;
42
+
43
+ const legacy_entry = read_project_trust_store(trust_store_path)[
44
+ project_dir
45
+ ] as { project_dir?: unknown; hash?: unknown } | undefined;
46
+ return (
47
+ legacy_entry?.project_dir === project_dir &&
48
+ legacy_entry.hash === hash
49
+ );
30
50
  }
31
51
 
32
52
  export function trust_hooks_config(
@@ -34,30 +54,8 @@ export function trust_hooks_config(
34
54
  hash: string,
35
55
  trust_store_path = default_hooks_trust_store_path(),
36
56
  ): void {
37
- const trusted_hooks = read_trusted_hooks(trust_store_path);
38
- trusted_hooks[project_dir] = {
39
- project_dir,
40
- hash,
41
- trusted_at: new Date().toISOString(),
42
- };
43
- mkdirSync(dirname(trust_store_path), { recursive: true });
44
- writeFileSync(
57
+ trust_project_subject(
58
+ create_hooks_config_trust_subject(project_dir, hash),
45
59
  trust_store_path,
46
- JSON.stringify(trusted_hooks, null, '\t') + '\n',
47
- {
48
- encoding: 'utf8',
49
- mode: 0o600,
50
- },
51
60
  );
52
61
  }
53
-
54
- function read_trusted_hooks(trust_store_path: string): TrustedHooks {
55
- if (!existsSync(trust_store_path)) return {};
56
- try {
57
- const raw = readFileSync(trust_store_path, 'utf-8');
58
- const parsed = JSON.parse(raw) as TrustedHooks;
59
- return parsed && typeof parsed === 'object' ? parsed : {};
60
- } catch {
61
- return {};
62
- }
63
- }