@xynogen/pix-gate 0.1.3 → 0.1.5

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.
Files changed (3) hide show
  1. package/README.md +9 -1
  2. package/package.json +4 -1
  3. package/src/prompt.ts +30 -111
package/README.md CHANGED
@@ -12,6 +12,12 @@ Intercepts every `bash` tool call and classifies the command against a set of se
12
12
  pi install npm:@xynogen/pix-gate
13
13
  ```
14
14
 
15
+ > Also included in [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core):
16
+ >
17
+ > ```bash
18
+ > pi install npm:@xynogen/pix-core
19
+ > ```
20
+
15
21
  ## Reusable exports
16
22
 
17
23
  The gate is split into a pure rule engine and the interactive prompt, so the
@@ -20,7 +26,9 @@ classification logic can be reused without the TUI:
20
26
  - `@xynogen/pix-gate/lib` — pure rules: `DEFAULT_RULES`, `buildRules`,
21
27
  `classify`, `loadUserConfig`, `isSudoCommand`. No Pi/TUI dependency.
22
28
  - `@xynogen/pix-gate/prompt` — `promptGateDecision()`, the confirm/deny dialog
23
- (depends on `pi-tui`).
29
+ (depends on `pi-tui`). This is now a thin adapter over the shared
30
+ `@xynogen/pix-pretty/gate-overlay` component, so the gate and `sudo_run`
31
+ dialogs share one implementation.
24
32
 
25
33
  `pix-skills` imports `./lib` to gate skill `` !`cmd` `` directives with the same
26
34
  rules as the bash tool (auto-deny on match, no prompt).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-gate",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Pi extension — permission gate for dangerous bash commands (confirm/block with TUI dialog)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -41,6 +41,9 @@
41
41
  "publishConfig": {
42
42
  "access": "public"
43
43
  },
44
+ "dependencies": {
45
+ "@xynogen/pix-pretty": "*"
46
+ },
44
47
  "peerDependencies": {
45
48
  "@earendil-works/pi-coding-agent": "*",
46
49
  "@earendil-works/pi-tui": "*"
package/src/prompt.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  /**
2
- * pix-gate — Part 2: the prompt.
2
+ * pix-gate — prompt.ts
3
3
  *
4
- * The interactive confirm/deny dialog, decoupled from the rule engine (lib.ts).
5
- * Returns a pure decision; the caller maps it to a tool-call result and emits
6
- * any notifications. This lets the rule engine be reused without the TUI dep.
4
+ * Thin adapter: maps gate severity/rule → showOverlay (pix-pretty).
5
+ * All dialog logic lives in @xynogen/pix-pretty/gate-overlay.
7
6
  */
8
7
 
9
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
11
- import { Box, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
9
+ import {
10
+ type OverlayResult,
11
+ showOverlay,
12
+ } from "@xynogen/pix-pretty/gate-overlay";
12
13
  import type { Rule } from "./lib.ts";
13
14
 
14
15
  export interface GateDecision {
@@ -17,7 +18,6 @@ export interface GateDecision {
17
18
  reason: string;
18
19
  }
19
20
 
20
- /** The UI surface the dialog needs — the extension context's `ui`. */
21
21
  export type GatePromptUI = ExtensionContext["ui"];
22
22
 
23
23
  const TIMEOUT_MS: Record<Rule["severity"], number> = {
@@ -26,34 +26,33 @@ const TIMEOUT_MS: Record<Rule["severity"], number> = {
26
26
  risky: 60_000,
27
27
  };
28
28
 
29
- const SEVERITY_COLOR = {
29
+ const SEVERITY_COLOR: Record<Rule["severity"], string> = {
30
30
  critical: "error",
31
31
  dangerous: "warning",
32
32
  risky: "accent",
33
- } as const satisfies Record<Rule["severity"], string>;
33
+ };
34
34
 
35
- const SEVERITY_ICON: Record<Rule["severity"], string> = {
35
+ export const SEVERITY_ICON: Record<Rule["severity"], string> = {
36
36
  critical: "🛑",
37
37
  dangerous: "⚠️ ",
38
38
  risky: "❓",
39
39
  };
40
40
 
41
41
  /**
42
- * Show the confirm/deny dialog for a matched command and resolve the decision.
43
- * Critical defaults to deny-first; dangerous/risky default to allow-first.
44
- * Times out to a denial after a severity-scaled deadline.
42
+ * Show the confirm/deny dialog for a matched command.
43
+ * Critical is deny-first; dangerous/risky are allow-first.
45
44
  */
46
45
  export async function promptGateDecision(
47
46
  ui: GatePromptUI,
48
47
  hit: Rule,
49
48
  command: string,
50
49
  ): Promise<GateDecision> {
51
- const timeoutMs = TIMEOUT_MS[hit.severity];
52
- const severityColor = SEVERITY_COLOR[hit.severity];
53
50
  const icon = SEVERITY_ICON[hit.severity];
54
51
  const label = hit.severity.toUpperCase();
52
+ const accent = SEVERITY_COLOR[hit.severity];
55
53
 
56
- const choices: SelectItem[] =
54
+ // critical: deny listed first so it's the default selected item
55
+ const choices =
57
56
  hit.severity === "critical"
58
57
  ? [
59
58
  {
@@ -68,11 +67,7 @@ export async function promptGateDecision(
68
67
  },
69
68
  ]
70
69
  : [
71
- {
72
- value: "yes",
73
- label: "Yes, allow",
74
- description: "Run the command",
75
- },
70
+ { value: "yes", label: "Yes, allow", description: "Run the command" },
76
71
  {
77
72
  value: "no",
78
73
  label: "No, block it",
@@ -80,97 +75,21 @@ export async function promptGateDecision(
80
75
  },
81
76
  ];
82
77
 
83
- const controller = new AbortController();
84
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
85
-
86
- const choice = await ui.custom<string | null>(
87
- (tui, theme, _kb, done) => {
88
- const container = new Box(0, 0, (s) => theme.bg("customMessageBg", s));
89
-
90
- container.addChild(
91
- new DynamicBorder((s: string) => theme.fg(severityColor, s)),
92
- );
93
-
94
- container.addChild(
95
- new Text(
96
- `${icon} ` +
97
- theme.fg(severityColor, theme.bold(label)) +
98
- theme.fg("muted", ` — ${hit.reason}`),
99
- 1,
100
- 0,
101
- ),
102
- );
103
-
104
- container.addChild(new Text(theme.fg("toolOutput", command), 2, 0));
105
-
106
- const deadlineMs = Date.now() + timeoutMs;
107
- const countdownText = new Text("", 1, 0);
108
- const updateCountdown = () => {
109
- const remaining = Math.max(
110
- 0,
111
- Math.ceil((deadlineMs - Date.now()) / 1000),
112
- );
113
- countdownText.setText(
114
- theme.fg("dim", "Auto-deny in ") +
115
- theme.fg(remaining <= 5 ? severityColor : "muted", `${remaining}s`),
116
- );
117
- };
118
- updateCountdown();
119
- const ticker = setInterval(() => {
120
- updateCountdown();
121
- tui.requestRender();
122
- }, 1000);
123
- container.addChild(countdownText);
124
-
125
- const list = new SelectList(choices, choices.length, {
126
- selectedPrefix: (t) => theme.fg(severityColor, t),
127
- selectedText: (t) => theme.fg(severityColor, t),
128
- description: (t) => theme.fg("muted", t),
129
- scrollInfo: (t) => theme.fg("dim", t),
130
- noMatch: (t) => theme.fg("warning", t),
131
- });
132
- const finish = (v: string | null) => {
133
- clearInterval(ticker);
134
- done(v);
135
- };
136
- list.onSelect = (item) => finish(item.value);
137
- list.onCancel = () => finish(null);
138
- container.addChild(list);
139
-
140
- container.addChild(
141
- new Text(
142
- theme.fg("dim", "↑↓ navigate • enter select • esc cancel"),
143
- 1,
144
- 0,
145
- ),
146
- );
147
-
148
- container.addChild(
149
- new DynamicBorder((s: string) => theme.fg(severityColor, s)),
150
- );
151
-
152
- controller.signal.addEventListener("abort", () => finish(null));
153
-
154
- return {
155
- render: (w: number) => container.render(w),
156
- invalidate: () => container.invalidate(),
157
- handleInput: (data: string) => {
158
- list.handleInput(data);
159
- tui.requestRender();
160
- },
161
- };
78
+ const result: OverlayResult = await showOverlay(
79
+ ui as Parameters<typeof showOverlay>[0],
80
+ {
81
+ mode: "confirm",
82
+ title: `${icon} ${label} — ${hit.reason}`,
83
+ body: [command],
84
+ accent,
85
+ timeoutMs: TIMEOUT_MS[hit.severity],
86
+ choices,
162
87
  },
163
- { overlay: true },
164
88
  );
165
89
 
166
- clearTimeout(timeoutId);
167
-
168
- const approved = choice === "yes";
169
- if (approved) return { approved: true, reason: "Approved" };
170
- return {
171
- approved: false,
172
- reason: controller.signal.aborted ? "Timed out" : "Blocked by user",
173
- };
90
+ if (result.action === "approved")
91
+ return { approved: true, reason: "Approved" };
92
+ if (result.action === "timeout")
93
+ return { approved: false, reason: "Timed out" };
94
+ return { approved: false, reason: "Blocked by user" };
174
95
  }
175
-
176
- export { SEVERITY_ICON };