pi-gitbox 0.1.0

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/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gabriel Sanhueza (https://github.com/gsanhueza)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # pi-gitbox
2
+
3
+ A [Pi Coding Agent](https://pi.dev/) extension that automatically redirects gitignored files and directories into an isolated **gitbox** — a local impersonation layer that makes them accessible to the AI agent without exposing your secrets. (_"gitbox"_ is a portmanteau of _"git"_ + _"sandbox"_.)
4
+
5
+ > **⚠️ DISCLAIMER:** This extension uses best-effort impersonation of gitignored paths. It is **your responsibility** to verify that secrets are not exposed to the agent. If absolute isolation is required, consider using a local model, [bubblewrap](https://github.com/containers/bubblewrap), or a fully isolated environment.
6
+
7
+ ## Security considerations
8
+
9
+ After enabling gitbox, verify that impersonations are working correctly:
10
+
11
+ 1. **Check impersonated files** — Open `~/.pi/agent/gitbox/<project>/` and confirm that gitignored files appear as empty placeholders (or `{}` for `.json` files).
12
+ 2. **Test with a local model** — Ask the agent to read a known-secret file. It should report the file as empty (or `{}` if JSON), confirming the impersonation is active.
13
+
14
+ ## Features
15
+
16
+ - **Gitignored file impersonation** — gitignored files and directories are automatically mirrored into a private gitbox directory
17
+ - **Command & path interception** — bash commands and file operations (read, edit, write, find, grep, ls) are internally redirected to the impersonated paths
18
+ - **Directory access control** — restricts agent access to allowed directories by default; prompts for approval when accessing paths outside the allowed list
19
+ - **Configurable directory bypass** — optionally disable directory restrictions
20
+ - **Status bar indicators** — color-coded status showing whether the gitbox is enabled, available, not required, unavailable or bypassed
21
+ - **Auto cleanup** — optionally delete the gitbox when the session exits
22
+
23
+ ## Status Bar
24
+
25
+ The status bar displays `📦 Gitbox:` followed by the current status:
26
+
27
+ | Status | Meaning | Color |
28
+ | ------------ | ----------------------------------------------- | ------------------ |
29
+ | Enabled | Gitbox active — gitignored paths exist | `#00ff88` (green) |
30
+ | Available | Gitbox created but no gitignored paths detected | `#ffaa00` (orange) |
31
+ | Not required | Current directory is not a git repository | `#ff8800` (orange) |
32
+ | Unavailable | `git` command not found | `#ff4444` (red) |
33
+ | Bypassed | Impersonation disabled by configuration | `#44ddff` (cyan) |
34
+
35
+ When `bypassPaths` is enabled, the status bar appends ` (unrestricted)` to indicate that directory access restrictions are disabled.
36
+
37
+ ## Installation
38
+
39
+ This package is a Pi extension. Install it with
40
+
41
+ ```bash
42
+ npm install pi-gitbox
43
+ ```
44
+
45
+ or
46
+
47
+ ```bash
48
+ pi install https://github.com/gsanhueza/pi-gitbox
49
+ ```
50
+
51
+ ## Configuration
52
+
53
+ You can customize Gitbox options via the interactive menu (`/gitbox`) for common settings, or by adding a `gitbox` section to your `~/.pi/agent/settings.json` for all options:
54
+
55
+ ```json
56
+ {
57
+ "gitbox": {
58
+ "baseDir": "~/.pi/agent/gitbox",
59
+ "statusBar": true,
60
+ "deleteOnExit": false,
61
+ "bypassGitbox": false,
62
+ "bypassPaths": false,
63
+ "allowedPaths": []
64
+ }
65
+ }
66
+ ```
67
+
68
+ ### Configuration Options
69
+
70
+ | Option | Type | Default | Description |
71
+ | -------------- | -------- | -------------------- | ----------------------------------------- |
72
+ | `baseDir` | string | `~/.pi/agent/gitbox` | Base directory where gitboxes are created |
73
+ | `statusBar` | boolean | `true` | Show gitbox status in the status bar |
74
+ | `deleteOnExit` | boolean | `false` | Delete the gitbox when the session exits |
75
+ | `bypassGitbox` | boolean | `false` | Skip impersonation of gitignored paths |
76
+ | `bypassPaths` | boolean | `false` | Bypass path access restrictions entirely |
77
+ | `allowedPaths` | string[] | `[]` | Additional paths to allow access to |
78
+
79
+ > **Note:** The interactive menu (`/gitbox`) exposes `statusBar`, `deleteOnExit`, `bypassGitbox`, and `bypassPaths`. The remaining options (`baseDir`, `allowedPaths`) must be configured directly in `settings.json`.
80
+
81
+ ### Directory Access
82
+
83
+ By default, the extension allows access to:
84
+
85
+ - The current working directory (`process.cwd()`)
86
+ - The Pi agent directory (`~/.pi/agent`)
87
+ - The extension package directory
88
+ - `/dev/null`
89
+
90
+ If the agent attempts to access a path outside these allowed directories, a confirmation dialog appears:
91
+
92
+ ```
93
+ [pi-gitbox]: Allow "/some/path" to be accessed?
94
+ ```
95
+
96
+ Options:
97
+
98
+ - **Allow** — Access the path for this session
99
+ - **Deny** — Block access
100
+ - **Bypass (session only)** — Add the path to allowed paths for this session
101
+ - **Bypass (saved globally)** — Add the path to allowed paths permanently
102
+
103
+ Set `bypassPaths: true` to skip this check entirely.
104
+
105
+ > **Note:** When Pi doesn't have access to a UI, access will be automatically blocked.
106
+
107
+ ## Commands
108
+
109
+ | Command | Description |
110
+ | --------------- | ------------------------------------------------- |
111
+ | `/gitbox` | Open settings menu — configure gitbox options |
112
+ | `/gitbox paths` | Show impersonated paths (source → target mapping) |
113
+
114
+ ## How It Works
115
+
116
+ 1. **Session Start** — On `session_start`, the extension verifies that `git` is available and checks if the current directory is a git repository
117
+ 2. **Gitignored Path Detection** — Uses git-specific commands to discover all gitignored files and directories
118
+ 3. **Gitbox Creation** — Creates a private directory at `~/.pi/agent/gitbox/<project-name>` and mirrors gitignored paths into it: files get placeholder content (`{}` for `.json` and ` ` (empty space) for others)
119
+ 4. **Path Mapping** — Builds a mapper from original absolute paths to their impersonated counterparts
120
+ 5. **Event Interception** — On every `tool_call` event:
121
+ - **Bash commands** — Extracts paths from the command using `shell-quote`, checks directory restrictions, then rewrites paths to their impersonated versions
122
+ - **Path-based tools** (read, edit, write, find, grep, ls) — Checks directory restrictions, then resolves the path to its impersonated equivalent
123
+ 6. **Status Bar** — Updates the status bar with the current gitbox state (enabled, available, not required, or unavailable)
124
+ 7. **Session Shutdown** — Optionally removes the gitbox directory if `deleteOnExit` is enabled
125
+
126
+ ## Dependencies
127
+
128
+ | Peer dependency | Purpose |
129
+ | --------------------------------- | ------------------- |
130
+ | `@earendil-works/pi-coding-agent` | Pi Coding Agent SDK |
131
+ | `@earendil-works/pi-tui` | Pi TUI SDK |
132
+
133
+ | Dependency | Purpose |
134
+ | ------------- | ------------------- |
135
+ | `shell-quote` | Parse bash commands |
package/index.ts ADDED
@@ -0,0 +1,77 @@
1
+ import {
2
+ ExtensionCommandContext,
3
+ ExtensionContext,
4
+ ToolCallEvent,
5
+ type ExtensionAPI,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { CommandManager } from "./src/commands";
8
+ import { BASE_ALLOWED_PATHS } from "./src/defaults";
9
+ import { Detector } from "./src/detector";
10
+ import { Gitbox } from "./src/gitbox";
11
+ import { Impersonator } from "./src/impersonator";
12
+ import { askUserOrBlock } from "./src/prompts";
13
+ import { settings } from "./src/settings";
14
+
15
+ export default async (pi: ExtensionAPI) => {
16
+ const impersonator = new Impersonator();
17
+ const gitbox = new Gitbox(impersonator);
18
+
19
+ const commandManager = new CommandManager(gitbox);
20
+
21
+ // Command registration
22
+ pi.registerCommand("gitbox", {
23
+ description: "Open settings menu to configure Gitbox options",
24
+ getArgumentCompletions: commandManager.getArgumentCompletions,
25
+ handler: async (args: string, ctx: ExtensionCommandContext) =>
26
+ await commandManager.runGitbox(args, ctx),
27
+ });
28
+
29
+ // Events
30
+ pi.on("session_start", async (_, ctx: ExtensionContext) => {
31
+ await gitbox.initialize(ctx);
32
+ });
33
+
34
+ pi.on("session_shutdown", async (_, ctx: ExtensionContext) => {
35
+ await gitbox.shutdown(ctx);
36
+ });
37
+
38
+ pi.on("tool_call", async (event: ToolCallEvent, ctx: ExtensionContext) => {
39
+ const { config } = await settings.getConfig();
40
+
41
+ const resolvedDirs = [...BASE_ALLOWED_PATHS, ...config.allowedPaths];
42
+
43
+ if (gitbox.isBashEvent(event)) {
44
+ const { command } = event.input;
45
+
46
+ // First, scan if we can even access the paths
47
+ if (!config.bypassPaths) {
48
+ const paths = await impersonator.extractFromCommand(command);
49
+
50
+ for (const path of paths) {
51
+ if (Detector.isPathAllowed(resolvedDirs, path, ctx)) continue;
52
+
53
+ const response = await askUserOrBlock(ctx, path);
54
+ if (response.block) return response;
55
+ }
56
+ }
57
+
58
+ // Then, impersonate the command
59
+ if (!config.bypassGitbox)
60
+ event.input.command = await gitbox.resolveCommand(command, ctx);
61
+ } else if (gitbox.isPathEvent(event)) {
62
+ const { path } = event.input as { path: string };
63
+
64
+ // First, scan if we can even access the paths
65
+ if (!config.bypassPaths) {
66
+ if (!Detector.isPathAllowed(resolvedDirs, path, ctx)) {
67
+ const response = await askUserOrBlock(ctx, path);
68
+ if (response.block) return response;
69
+ }
70
+ }
71
+
72
+ // Then, impersonate the path
73
+ if (!config.bypassGitbox)
74
+ event.input.path = await gitbox.resolvePath(path, ctx);
75
+ }
76
+ });
77
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "pi-gitbox",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that impersonates gitignored paths to reduce secrets exposure.",
5
+ "keywords": [
6
+ "pi",
7
+ "pi-package",
8
+ "pi-extension"
9
+ ],
10
+ "author": "Gabriel Sanhueza",
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/gsanhueza/pi-gitbox.git"
15
+ },
16
+ "homepage": "https://github.com/gsanhueza/pi-gitbox#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/gsanhueza/pi-gitbox/issues"
19
+ },
20
+ "pi": {
21
+ "extensions": [
22
+ "./index.ts"
23
+ ]
24
+ },
25
+ "prettier": {
26
+ "plugins": [
27
+ "prettier-plugin-organize-imports"
28
+ ]
29
+ },
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "@earendil-works/pi-tui": "*"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^25.9.3",
36
+ "@types/shell-quote": "^1.7.5",
37
+ "prettier-plugin-organize-imports": "^4.3.0"
38
+ },
39
+ "dependencies": {
40
+ "shell-quote": "^1.8.4"
41
+ }
42
+ }
@@ -0,0 +1,168 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ AutocompleteItem,
5
+ SettingsList,
6
+ type SettingItem,
7
+ } from "@earendil-works/pi-tui";
8
+ import { GitboxConfig } from "./config-types";
9
+ import { Gitbox } from "./gitbox";
10
+ import { settings } from "./settings";
11
+
12
+ /**
13
+ * Configuration options
14
+ */
15
+ enum Options {
16
+ STATUS_BAR = "statusBar",
17
+ DELETE_ON_EXIT = "deleteOnExit",
18
+ BYPASS_GITBOX = "bypassGitbox",
19
+ BYPASS_PATHS = "bypassPaths",
20
+ }
21
+
22
+ /**
23
+ * Handles commands for the pi-gitbox extension.
24
+ */
25
+ export class CommandManager {
26
+ constructor(private readonly gitbox: Gitbox) {}
27
+
28
+ /**
29
+ * Sets up the argument completions for the `/gitbox` command
30
+ *
31
+ * @param prefix Prefix written by the user
32
+ * @returns Completions with that prefix
33
+ */
34
+ getArgumentCompletions(prefix: string): AutocompleteItem[] | null {
35
+ const available = [
36
+ {
37
+ value: "paths",
38
+ label: "paths",
39
+ description: "Show impersonated paths",
40
+ },
41
+ ];
42
+ const filtered = available.filter((a) => a.value.startsWith(prefix));
43
+ return filtered.length > 0 ? filtered : null;
44
+ }
45
+
46
+ /**
47
+ * Handles the `/gitbox` command — opens a SettingsList or shows impersonated paths
48
+ *
49
+ * @param args The subcommand argument (e.g. "paths")
50
+ * @param ctx The extension context
51
+ */
52
+ async runGitbox(args: string, ctx: ExtensionCommandContext): Promise<void> {
53
+ if (args === "paths") {
54
+ return await this.runGitboxPaths(ctx);
55
+ }
56
+
57
+ const { config } = await settings.getConfig();
58
+ const items = this.buildSettingsItems(config);
59
+
60
+ await ctx.ui.custom<void>((_tui, _theme, _kb, done) =>
61
+ this.createSettingsList(
62
+ items,
63
+ async (id, newValue) => this.handleSettingChange(id, newValue, ctx),
64
+ done,
65
+ ),
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Handles the `/gitbox paths` subcommand — shows impersonated paths
71
+ *
72
+ * @param ctx The extension context
73
+ */
74
+ private async runGitboxPaths(ctx: ExtensionCommandContext): Promise<void> {
75
+ const mapper = this.gitbox.getMapper(ctx);
76
+
77
+ const lines = Object.entries(mapper)
78
+ .map(([source, target]) => ` ${source} -> ${target}`)
79
+ .join("\n");
80
+
81
+ const content = lines
82
+ ? `Impersonated paths:\n${lines}`
83
+ : "No impersonated paths available.";
84
+
85
+ ctx.ui.notify(content);
86
+ }
87
+
88
+ /**
89
+ * Handles a settings value change — writes the new value and re-renders.
90
+ *
91
+ * @param id The setting identifier
92
+ * @param newValue The new value to apply
93
+ * @param ctx The extension command context
94
+ */
95
+ private async handleSettingChange(
96
+ id: string,
97
+ newValue: string,
98
+ ctx: ExtensionCommandContext,
99
+ ): Promise<void> {
100
+ const key: string = Object.values(Options).find((o) => o === id)!;
101
+ await settings.setConfig({ [key]: newValue === "on" });
102
+
103
+ // Update the status bar with the new status
104
+ await this.gitbox.setStatus(ctx);
105
+ }
106
+
107
+ /**
108
+ * Creates the SettingsList for the menu.
109
+ *
110
+ * @param items The settings items to display
111
+ * @param onChange Callback when a setting value changes
112
+ * @param onClose Callback when the dialog closes
113
+ * @returns The configured SettingsList instance
114
+ */
115
+ private createSettingsList(
116
+ items: SettingItem[],
117
+ onChange: (id: string, newValue: string) => void,
118
+ onClose: () => void,
119
+ ): SettingsList {
120
+ return new SettingsList(
121
+ items,
122
+ items.length,
123
+ getSettingsListTheme(),
124
+ onChange,
125
+ onClose,
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Builds the SettingsList items for the menu.
131
+ *
132
+ * @param config The resolved configuration
133
+ * @returns The array of SettingItem objects
134
+ */
135
+ private buildSettingsItems(config: GitboxConfig): SettingItem[] {
136
+ return [
137
+ {
138
+ id: Options.STATUS_BAR,
139
+ label: "Show in status bar",
140
+ description: "Shows gitbox status in the status bar",
141
+ currentValue: config.statusBar ? "on" : "off",
142
+ values: ["on", "off"],
143
+ },
144
+ {
145
+ id: Options.DELETE_ON_EXIT,
146
+ label: "Delete on exit",
147
+ description: "When exiting Pi, delete the gitbox",
148
+ currentValue: config.deleteOnExit ? "on" : "off",
149
+ values: ["on", "off"],
150
+ },
151
+ {
152
+ id: Options.BYPASS_GITBOX,
153
+ label: "Bypass impersonation",
154
+ description:
155
+ "Skip impersonation of gitignored paths (keeps original paths)",
156
+ currentValue: config.bypassGitbox ? "on" : "off",
157
+ values: ["on", "off"],
158
+ },
159
+ {
160
+ id: Options.BYPASS_PATHS,
161
+ label: "Bypass directories",
162
+ description: "Bypass the restrictions on allowed directories",
163
+ currentValue: config.bypassPaths ? "on" : "off",
164
+ values: ["on", "off"],
165
+ },
166
+ ];
167
+ }
168
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Configuration for the pi-gitbox extension.
3
+ * All fields can be overridden via ~/.pi/agent/settings.json under the "gitbox" key.
4
+ */
5
+ export interface GitboxConfig {
6
+ baseDir: string;
7
+ statusBar: boolean;
8
+ deleteOnExit: boolean;
9
+ bypassGitbox: boolean;
10
+ bypassPaths: boolean;
11
+ allowedPaths: string[];
12
+ }
13
+
14
+ /**
15
+ * Texts for the status bar
16
+ */
17
+ export enum Status {
18
+ ENABLED = "Enabled",
19
+ AVAILABLE = "Available",
20
+ NOT_REQUIRED = "Not required",
21
+ UNAVAILABLE = "Unavailable",
22
+ BYPASSED = "Bypassed",
23
+ }
@@ -0,0 +1,55 @@
1
+ import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Identifier for the status bar entry
6
+ */
7
+ export const STATUS_KEY = "gitbox";
8
+
9
+ /**
10
+ * Default base directory for gitboxes
11
+ */
12
+ export const GITBOX_BASEDIR = join(getAgentDir(), "gitbox");
13
+
14
+ /**
15
+ * Whether to add a text in the status bar
16
+ */
17
+ export const GITBOX_STATUSBAR = true;
18
+
19
+ /**
20
+ * Whether to delete the gitbox when the extension exits
21
+ */
22
+ export const DELETE_ON_EXIT = false;
23
+
24
+ /**
25
+ * Default paths that are always allowed.
26
+ */
27
+ export const BASE_ALLOWED_PATHS: string[] = [
28
+ // The current working directory
29
+ process.cwd(),
30
+
31
+ // Pi's agent library location,
32
+ getPackageDir(),
33
+
34
+ // User settings
35
+ getAgentDir(),
36
+
37
+ // Common paths
38
+ "/dev/null",
39
+ ];
40
+
41
+ /**
42
+ * Allowed paths, configurable by the user
43
+ * Extra paths can be added via `allowedPaths` in settings.
44
+ */
45
+ export const ALLOWED_PATHS: string[] = [];
46
+
47
+ /**
48
+ * Whether to bypass path restrictions
49
+ */
50
+ export const BYPASS_PATHS = false;
51
+
52
+ /**
53
+ * Whether to bypass impersonation entirely
54
+ */
55
+ export const BYPASS_GITBOX = false;
@@ -0,0 +1,131 @@
1
+ import { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { execSync } from "child_process";
3
+ import { access } from "fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { resolve, sep } from "node:path";
6
+
7
+ export class Detector {
8
+ /**
9
+ * Determines if the user has `git` as a usable command
10
+ *
11
+ * @returns True if git exists
12
+ */
13
+ static isGitAvailable(): boolean {
14
+ try {
15
+ execSync("git -v", { stdio: "pipe" });
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Determines if the current working directory is a git repository
24
+ * @returns True if it's a git repo
25
+ */
26
+ static isGitProject(): boolean {
27
+ try {
28
+ execSync("git rev-parse --is-inside-work-tree", { stdio: "pipe" });
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Returns all gitignored files
37
+ *
38
+ * @returns Relative paths for files
39
+ */
40
+ static getGitignoredFiles = () => {
41
+ const paths = Detector.getGitignoredPaths();
42
+
43
+ return paths.filter((path) => !path.endsWith("/"));
44
+ };
45
+
46
+ /**
47
+ * Returns all gitignored directories
48
+ *
49
+ * @returns Relative paths for directories
50
+ */
51
+ static getGitignoredDirectories = () => {
52
+ const paths = this.getGitignoredPaths();
53
+
54
+ return paths.filter((path) => path.endsWith("/"));
55
+ };
56
+
57
+ /**
58
+ * Runs the git command to get all git-ignored paths (both files and directories).
59
+ * Returns an array of paths that are ignored by git.
60
+ *
61
+ * @returns Array of git-ignored paths (relative to repo root)
62
+ */
63
+ private static getGitignoredPaths(): string[] {
64
+ try {
65
+ const command =
66
+ "git ls-files --directory --no-empty-directory --others --ignored --exclude-standard";
67
+ const output = execSync(command, {
68
+ encoding: "utf-8",
69
+ stdio: ["pipe", "pipe", "ignore"],
70
+ });
71
+ const lines = output
72
+ .trim()
73
+ .split("\n")
74
+ .filter((line) => line.length > 0);
75
+ return lines;
76
+ } catch (error) {
77
+ // Silently ignore if not a git repository
78
+ return [];
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Checks if a path exists
84
+ *
85
+ * @param path The path to check
86
+ * @returns True if path exists
87
+ */
88
+ static async pathExists(path: string): Promise<boolean> {
89
+ try {
90
+ await access(path);
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Checks if a path is contained within any of the allowed directories.
99
+ * The check is recursive: if `/a` is allowed, then `/a/b/c` also passes.
100
+ *
101
+ * @param dirs Allowed directories
102
+ * @param path The path to check (will be resolved relative to cwd)
103
+ * @param ctx The extension context
104
+ * @returns True if the path is within at least one allowed directory
105
+ */
106
+ static isPathAllowed(
107
+ dirs: string[],
108
+ path: string,
109
+ ctx: ExtensionContext,
110
+ ): boolean {
111
+ // Expand ~ to home directory before resolving
112
+ const normalizedPath = Detector.normalizePath(path);
113
+ const absPath = resolve(ctx.cwd, normalizedPath);
114
+ const absDirs = dirs.map((dir) =>
115
+ resolve(ctx.cwd, Detector.normalizePath(dir)),
116
+ );
117
+
118
+ return absDirs.some(
119
+ (dir) => absPath === dir || absPath.startsWith(dir + sep),
120
+ );
121
+ }
122
+
123
+ /**
124
+ * Normalizes the path when it comes with a tilde (representing HOME)
125
+ * @param path The path
126
+ * @returns A normalized path
127
+ */
128
+ private static normalizePath(path: string): string {
129
+ return path.replace(/^~\//g, homedir() + sep);
130
+ }
131
+ }
package/src/gitbox.ts ADDED
@@ -0,0 +1,180 @@
1
+ import {
2
+ BashToolCallEvent,
3
+ ExtensionContext,
4
+ isToolCallEventType,
5
+ ToolCallEvent,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { mkdir, rm } from "node:fs/promises";
8
+ import { basename, resolve } from "node:path";
9
+ import { GitboxConfig, Status } from "./config-types";
10
+ import { Detector } from "./detector";
11
+ import { Impersonator } from "./impersonator";
12
+ import { Renderer } from "./renderer";
13
+ import { settings } from "./settings";
14
+
15
+ export class Gitbox {
16
+ constructor(private readonly impersonator: Impersonator) {}
17
+
18
+ async initialize(ctx: ExtensionContext) {
19
+ await this.verifySettings(ctx);
20
+
21
+ if (!Detector.isGitAvailable()) {
22
+ await this.setStatus(ctx);
23
+ return;
24
+ }
25
+
26
+ await this.impersonator.initialize(ctx);
27
+ await this.getOrCreate(ctx);
28
+ await this.setStatus(ctx);
29
+ }
30
+
31
+ async shutdown(ctx: ExtensionContext) {
32
+ const { config } = await settings.getConfig();
33
+ const { deleteOnExit } = config;
34
+
35
+ if (deleteOnExit) await this.removeGitbox(ctx);
36
+ }
37
+
38
+ /**
39
+ * Determines if the event is a "bash" tool call
40
+ * @param event The event
41
+ * @returns True if "bash" event
42
+ */
43
+ isBashEvent(event: ToolCallEvent): event is BashToolCallEvent {
44
+ return isToolCallEventType("bash", event);
45
+ }
46
+
47
+ /**
48
+ * Determines if the event is a tool call where "path" exists
49
+ * @param event The event
50
+ * @returns True if "path" exists
51
+ */
52
+ isPathEvent(event: ToolCallEvent): boolean {
53
+ const pathTools = ["read", "edit", "write", "find", "grep", "ls"];
54
+ return pathTools.some((tool) => isToolCallEventType(tool, event));
55
+ }
56
+
57
+ /**
58
+ * Impersonates the bash command, if possible
59
+ *
60
+ * @param cmd The bash command whose paths will be impersonated
61
+ * @param ctx The extension context
62
+ * @returns The command with impersonated paths
63
+ */
64
+ async resolveCommand(cmd: string, ctx: ExtensionContext): Promise<string> {
65
+ return await this.impersonator.resolveCommand(cmd, ctx);
66
+ }
67
+
68
+ /**
69
+ * Impersonates the path, if possible
70
+ *
71
+ * @param path The path to be impersonated
72
+ * @param ctx The extension context
73
+ * @returns The impersonated path, if available
74
+ */
75
+ async resolvePath(path: string, ctx: ExtensionContext): Promise<string> {
76
+ return await this.impersonator.resolvePath(path, ctx);
77
+ }
78
+
79
+ /**
80
+ * Returns the mapper used by the impersonator.
81
+ * Delegates to impersonator instance
82
+ *
83
+ * @param ctx The extension context
84
+ * @returns The source -> target path mapping
85
+ */
86
+ getMapper(ctx: ExtensionContext): Record<string, string> {
87
+ return this.impersonator.getMapper(ctx.cwd);
88
+ }
89
+
90
+ /**
91
+ * Validates the configuration settings
92
+ *
93
+ * @param ctx The extension context
94
+ * @returns The validated configuration
95
+ */
96
+ private async verifySettings(ctx: ExtensionContext): Promise<GitboxConfig> {
97
+ const { config, errors } = await settings.getConfig();
98
+ if (errors.length > 0) {
99
+ const message = ["[pi-gitbox]", ...errors].join("\n");
100
+ ctx.ui.notify(message, "warning");
101
+ }
102
+
103
+ return config;
104
+ }
105
+
106
+ /**
107
+ * Creates the base gitbox
108
+ *
109
+ * @param ctx The extension context
110
+ * @returns The path for the gitbox
111
+ */
112
+ private async getOrCreate(ctx: ExtensionContext): Promise<string> {
113
+ const { config } = await settings.getConfig();
114
+ const { baseDir } = config;
115
+ const cwd = basename(ctx.cwd);
116
+
117
+ const impersonationDir = resolve(baseDir, cwd);
118
+ if (await Detector.pathExists(impersonationDir)) return impersonationDir;
119
+
120
+ try {
121
+ await mkdir(impersonationDir, { recursive: true });
122
+ return impersonationDir;
123
+ } catch (error) {
124
+ throw new Error(`Failed to create gitbox: ${error}`);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Removes the gitbox
130
+ *
131
+ * @param ctx The extension context
132
+ */
133
+ private async removeGitbox(ctx: ExtensionContext): Promise<void> {
134
+ const gitboxPath = await this.getOrCreate(ctx);
135
+
136
+ if (!(await Detector.pathExists(gitboxPath))) return;
137
+ try {
138
+ await rm(gitboxPath, { recursive: true });
139
+ } catch (error) {
140
+ ctx.ui.notify(`Failed to remove gitbox: ${error}`, "error");
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Determines the current status of the gitbox based on its existence.
146
+ *
147
+ * @returns Status
148
+ * - "BYPASSED" if bypassGitbox is enabled
149
+ * - "ENABLED" if the gitbox was created and gitignored paths exist
150
+ * - "AVAILABLE" if the gitbox was created, but there are no gitignored paths
151
+ * - "NOT_REQUIRED" if the current working directory is not a git repository
152
+ * - "UNAVAILABLE" if `git` command is not found
153
+ */
154
+ private async getStatus(): Promise<Status> {
155
+ const { config } = await settings.getConfig();
156
+ const { bypassGitbox } = config;
157
+
158
+ // If bypass mode is enabled, return bypassed status
159
+ if (bypassGitbox) return Status.BYPASSED;
160
+
161
+ if (!Detector.isGitAvailable()) return Status.UNAVAILABLE;
162
+ if (!Detector.isGitProject()) return Status.NOT_REQUIRED;
163
+
164
+ const paths = Detector.getGitignoredFiles();
165
+ if (paths.length === 0) return Status.AVAILABLE;
166
+
167
+ return Status.ENABLED;
168
+ }
169
+
170
+ /**
171
+ * Sets the current status in the status bar.
172
+ * No-op if the settings prevent it.
173
+ *
174
+ * @param ctx The extension context
175
+ */
176
+ async setStatus(ctx: ExtensionContext) {
177
+ const status = await this.getStatus();
178
+ await Renderer.setStatus(ctx, status);
179
+ }
180
+ }
@@ -0,0 +1,264 @@
1
+ import { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import { parse } from "shell-quote";
5
+ import { Detector } from "./detector";
6
+ import { settings } from "./settings";
7
+
8
+ export class Impersonator {
9
+ private mapper: Record<string, string> = {};
10
+
11
+ /**
12
+ * Fills the mapper with paths that are gitignored
13
+ *
14
+ * @param ctx The extension context
15
+ */
16
+ async initialize(ctx: ExtensionContext): Promise<void> {
17
+ this.mapper = {};
18
+ const { config } = await settings.getConfig();
19
+
20
+ await this.initializeDirectories(config.baseDir, ctx);
21
+ await this.initializeFiles(config.baseDir, ctx);
22
+ }
23
+
24
+ /**
25
+ * Initializes the mapper for directories
26
+ *
27
+ * @param baseDir Gitbox basedir
28
+ * @param ctx The extension context
29
+ */
30
+ private async initializeDirectories(baseDir: string, ctx: ExtensionContext) {
31
+ const gitignoredDirectories = Detector.getGitignoredDirectories();
32
+ const projectDir = join(baseDir, basename(ctx.cwd));
33
+
34
+ for (const path of gitignoredDirectories) {
35
+ const impersonation = await this.createDirectory(path, projectDir, ctx);
36
+ const absPath = resolve(ctx.cwd, path);
37
+
38
+ if (impersonation) {
39
+ this.mapper[absPath] = impersonation;
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Initializes the mapper for files
46
+ *
47
+ * @param baseDir Gitbox basedir
48
+ * @param ctx The extension context
49
+ */
50
+ private async initializeFiles(baseDir: string, ctx: ExtensionContext) {
51
+ const gitignoredFiles = Detector.getGitignoredFiles();
52
+ const projectDir = join(baseDir, basename(ctx.cwd));
53
+
54
+ for (const path of gitignoredFiles) {
55
+ const impersonation = await this.createFile(path, projectDir, ctx);
56
+ const absPath = resolve(ctx.cwd, path);
57
+
58
+ if (impersonation) {
59
+ this.mapper[absPath] = impersonation;
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Creates an impersonated directory for a git-ignored path.
66
+ *
67
+ * @param relativePath The original path
68
+ * @param parentDir The parent path to use with relativePath
69
+ * @param ctx The extension context
70
+ * @returns Absolute path to impersonated directory
71
+ */
72
+ private async createDirectory(
73
+ relativePath: string,
74
+ parentDir: string,
75
+ ctx: ExtensionContext,
76
+ ): Promise<string> {
77
+ // Setup the gitbox for the project
78
+ const impersonatingPath = resolve(parentDir, relativePath);
79
+
80
+ // Create directory if it doesn't exist
81
+ if (!(await Detector.pathExists(impersonatingPath))) {
82
+ try {
83
+ await mkdir(impersonatingPath, { recursive: true });
84
+ } catch (error) {
85
+ ctx.ui.notify(
86
+ `Failed to create impersonated directory: ${error}`,
87
+ "error",
88
+ );
89
+ return relativePath;
90
+ }
91
+ }
92
+
93
+ return impersonatingPath;
94
+ }
95
+
96
+ /**
97
+ * Creates an impersonated file for a git-ignored path.
98
+ *
99
+ * @param relativePath The original path
100
+ * @param parentDir The parent path to use with relativePath
101
+ * @param ctx The extension context
102
+ * @returns Absolute path to impersonated file
103
+ */
104
+ private async createFile(
105
+ relativePath: string,
106
+ parentDir: string,
107
+ ctx: ExtensionContext,
108
+ ): Promise<string> {
109
+ // Create the impersonated file
110
+ const content = relativePath.endsWith(".json") ? "{}" : " ";
111
+
112
+ // Setup the gitbox for the project
113
+ const impersonatingPath = resolve(parentDir, relativePath);
114
+
115
+ // Create file if it doesn't exist
116
+ if (!(await Detector.pathExists(impersonatingPath))) {
117
+ try {
118
+ // Ensure parent directories exist first
119
+ const parentDir = resolve(dirname(impersonatingPath));
120
+ await mkdir(parentDir, { recursive: true });
121
+
122
+ await writeFile(impersonatingPath, content);
123
+ } catch (error) {
124
+ ctx.ui.notify(`Failed to create impersonated file: ${error}`, "error");
125
+ return relativePath;
126
+ }
127
+ }
128
+
129
+ return impersonatingPath;
130
+ }
131
+
132
+ /**
133
+ * Returns a copy of the current mapper of impersonated paths.
134
+ * Sources are relative paths from the base directory to the original file/directory.
135
+ *
136
+ * @param baseDir The base directory to resolve relative paths against
137
+ * @returns The source -> target path mapping
138
+ */
139
+ getMapper(baseDir: string): Record<string, string> {
140
+ const result: Record<string, string> = {};
141
+
142
+ for (const [absSource, target] of Object.entries(this.mapper)) {
143
+ result[relative(baseDir, absSource)] = target;
144
+ }
145
+
146
+ return result;
147
+ }
148
+
149
+ /**
150
+ * Impersonates the bash command, if possible
151
+ *
152
+ * @param cmd The bash command whose paths will be impersonated
153
+ * @param ctx The extension context
154
+ * @returns The command with impersonated paths
155
+ */
156
+ async resolveCommand(cmd: string, ctx: ExtensionContext): Promise<string> {
157
+ const paths = await this.extractFromCommand(cmd);
158
+
159
+ let response = cmd;
160
+ for (const path of paths) {
161
+ const impersonation = await this.resolvePath(path, ctx);
162
+ response = response.replace(path, impersonation);
163
+ }
164
+
165
+ return response;
166
+ }
167
+
168
+ /**
169
+ * Impersonates the path, if possible
170
+ *
171
+ * @param path The path to be impersonated
172
+ * @param ctx The extension context
173
+ * @returns The impersonated path, if available
174
+ */
175
+ async resolvePath(path: string, ctx: ExtensionContext): Promise<string> {
176
+ // Path could be a file
177
+ const absPath = resolve(ctx.cwd, path);
178
+ if (this.mapper[absPath]) return this.mapper[absPath];
179
+
180
+ // Part of the path could be in the mapper too
181
+ // E.g.: `.vscode/launch.json` when only `.vscode/` is gitignored)
182
+ const dirPath = dirname(absPath);
183
+
184
+ for (const [source, target] of Object.entries(this.mapper)) {
185
+ if (dirPath.includes(source)) {
186
+ // We've found a gitignored `source`
187
+ // Let's shoehorn it as the candidate
188
+ const candidateDir = dirPath.replace(source, target);
189
+ const baseName = basename(absPath);
190
+
191
+ // If the file was not in the mapper, we'll need to create it on-the-fly here
192
+ const response = await this.createFile(baseName, candidateDir, ctx);
193
+ this.mapper[absPath] = response;
194
+
195
+ return response;
196
+ }
197
+ }
198
+
199
+ // Couldn't find anything to impersonate, return the original path
200
+ return path;
201
+ }
202
+
203
+ /**
204
+ * Extracts potential file paths from a bash command string.
205
+ *
206
+ * @param command - The bash command string to parse
207
+ * @returns Array of path arguments found in the command
208
+ */
209
+ async extractFromCommand(command: string): Promise<string[]> {
210
+ // Tokenize the command using shell-quote
211
+ const tokens = parse(command);
212
+
213
+ // Collect tokens that are not operators or globs - these could be paths
214
+ const paths = tokens
215
+ .filter((token) => typeof token === "string")
216
+ .filter(this.isPathLike);
217
+
218
+ return paths;
219
+ }
220
+
221
+ /**
222
+ * Detects if a string is a possible path (heuristic approach)
223
+ *
224
+ * Takes a cautious approach, only rejecting candidates that are
225
+ * impossible to be paths.
226
+ *
227
+ * @param candidate Candidate path to check
228
+ * @returns True if it looks like a path
229
+ */
230
+ private isPathLike(candidate: string): boolean {
231
+ // Empty strings
232
+ if (!candidate) return false;
233
+
234
+ // Command flags and options
235
+ if (candidate.startsWith("-")) return false;
236
+
237
+ // Pure numbers (e.g., 42, 3.14, -0)
238
+ if (/^-?\d+(\.\d+)?$/.test(candidate)) return false;
239
+
240
+ // URLs (e.g., https://example.com, ftp://...)
241
+ if (/^[a-z]+:\/\//i.test(candidate)) return false;
242
+
243
+ // Environment variable assignments (e.g., NODE_ENV=production)
244
+ if (/^[A-Z_]+=/.test(candidate)) return false;
245
+
246
+ // Shell reserved words and syntax keywords that can never be file paths.
247
+ const shellReservedWords = new Set([
248
+ "then",
249
+ "else",
250
+ "fi",
251
+ "do",
252
+ "done",
253
+ "esac",
254
+ "endif",
255
+ "end",
256
+ "true",
257
+ "false",
258
+ ]);
259
+ if (shellReservedWords.has(candidate)) return false;
260
+
261
+ // Default: assume it's path-like (prefer false positives)
262
+ return true;
263
+ }
264
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { settings } from "./settings";
3
+
4
+ /**
5
+ * Prompt options
6
+ */
7
+ enum Options {
8
+ ALLOW = "Allow",
9
+ DENY = "Deny",
10
+ BYPASS_SESSION = "Bypass (session only)",
11
+ BYPASS_SAVE = "Bypass (saved globally)",
12
+ }
13
+
14
+ /**
15
+ * Asks the user if they want to allow access to a path outside the allowed directories.
16
+ * If the user denies, or if UI is not available, the function blocks with a reason.
17
+ *
18
+ * @param ctx The extension context
19
+ * @param path The path to check
20
+ * @returns An object with `block: true` and a `reason` if blocked, or `{ block: false }` if allowed
21
+ */
22
+ export async function askUserOrBlock(
23
+ ctx: ExtensionContext,
24
+ path: string,
25
+ ): Promise<{ block: boolean; reason?: string }> {
26
+ const reason = `Path "${path}" is outside allowed directories and was denied.`;
27
+
28
+ // Check if UI is available
29
+ const { config } = await settings.getConfig();
30
+ if (!ctx.hasUI) {
31
+ return { block: true, reason };
32
+ }
33
+
34
+ const prompt = `[pi-gitbox]: Allow "${path}" to be accessed?`;
35
+ const allowed = await ctx.ui.select(prompt, Object.values(Options));
36
+
37
+ // Process the selected option
38
+ if (!allowed || allowed === Options.DENY) return { block: true, reason };
39
+
40
+ // Handle the bypass options.
41
+ if (allowed === Options.BYPASS_SESSION) {
42
+ config.allowedPaths = [...config.allowedPaths, path];
43
+ } else if (allowed === Options.BYPASS_SAVE) {
44
+ await settings.setConfig({ allowedPaths: [...config.allowedPaths, path] });
45
+ }
46
+
47
+ return { block: false };
48
+ }
@@ -0,0 +1,65 @@
1
+ import { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Status } from "./config-types";
3
+ import { STATUS_KEY } from "./defaults";
4
+ import { settings } from "./settings";
5
+
6
+ export class Renderer {
7
+ private static lastStatus: string = "";
8
+
9
+ /**
10
+ * Sets the status of the gitbox in the extension UI.
11
+ * @param ctx The extension context.
12
+ * @param status The status to set
13
+ */
14
+ static async setStatus(ctx: ExtensionContext, status: Status): Promise<void> {
15
+ // Color the status with HEX codes
16
+ const colorMapper: Record<Status, string> = {
17
+ [Status.ENABLED]: "#00ff88",
18
+ [Status.AVAILABLE]: "#ffaa00",
19
+ [Status.NOT_REQUIRED]: "#ff8800",
20
+ [Status.UNAVAILABLE]: "#ff4444",
21
+ [Status.BYPASSED]: "#44ddff",
22
+ };
23
+
24
+ const theme = ctx.ui.theme;
25
+ const coloredStatus = Renderer.colorHex(status, colorMapper[status]);
26
+
27
+ Renderer.lastStatus = `${theme.fg("dim", "📦 Gitbox:")} ${coloredStatus}`;
28
+ await this.update(ctx);
29
+ }
30
+
31
+ /**
32
+ * Updates the status bar with the current status and any bypass indicators.
33
+ *
34
+ * @param ctx The extension context
35
+ */
36
+ static async update(ctx: ExtensionContext): Promise<void> {
37
+ const { config } = await settings.getConfig();
38
+ const { statusBar, bypassPaths } = config;
39
+
40
+ // Verify bypass status to announce it in status bar
41
+ let statusText = Renderer.lastStatus;
42
+ if (bypassPaths) statusText += " (unrestricted)";
43
+
44
+ if (statusBar) {
45
+ ctx.ui.setStatus(STATUS_KEY, statusText);
46
+ } else {
47
+ ctx.ui.setStatus(STATUS_KEY, undefined);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Applies a custom hex color using 24-bit truecolor ANSI escape codes.
53
+ *
54
+ * @param text The text to colorize
55
+ * @param hex The hex color string, e.g. "#abcdef"
56
+ * @returns The colored text
57
+ */
58
+ private static colorHex(text: string, hex: string): string {
59
+ const r = parseInt(hex.slice(1, 3), 16);
60
+ const g = parseInt(hex.slice(3, 5), 16);
61
+ const b = parseInt(hex.slice(5, 7), 16);
62
+
63
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m\u200b`;
64
+ }
65
+ }
@@ -0,0 +1,134 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { GitboxConfig } from "./config-types";
5
+ import {
6
+ ALLOWED_PATHS,
7
+ BYPASS_GITBOX,
8
+ BYPASS_PATHS,
9
+ DELETE_ON_EXIT,
10
+ GITBOX_BASEDIR,
11
+ GITBOX_STATUSBAR,
12
+ STATUS_KEY,
13
+ } from "./defaults";
14
+
15
+ /**
16
+ * Manages Gitbox configuration: defaults, user settings, validation,
17
+ * caching, and persistence to ~/.pi/agent/settings.json.
18
+ */
19
+ class Settings {
20
+ private cachedConfig: GitboxConfig | null = null;
21
+ private cachedErrors: string[] = [];
22
+
23
+ /**
24
+ * Manages Gitbox configuration: defaults, user settings, validation,
25
+ * caching, and persistence to ~/.pi/agent/settings.json.
26
+ *
27
+ * @internal Use the exported `settings` singleton instead.
28
+ */
29
+ constructor(
30
+ private readonly path: string = join(getAgentDir(), "settings.json"),
31
+ ) {}
32
+
33
+ /**
34
+ * Retrieves the default configuration object.
35
+ *
36
+ * @returns The default configuration.
37
+ */
38
+ private getDefaultConfig(): GitboxConfig {
39
+ return {
40
+ baseDir: GITBOX_BASEDIR,
41
+ statusBar: GITBOX_STATUSBAR,
42
+ deleteOnExit: DELETE_ON_EXIT,
43
+ bypassGitbox: BYPASS_GITBOX,
44
+ bypassPaths: BYPASS_PATHS,
45
+ allowedPaths: ALLOWED_PATHS,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Resolves the final config, merging user settings with built-in defaults,
51
+ * validating, and caching the result.
52
+ */
53
+ async getConfig(): Promise<{ config: GitboxConfig; errors: string[] }> {
54
+ if (this.cachedConfig)
55
+ return { config: this.cachedConfig, errors: this.cachedErrors };
56
+
57
+ const defaults = this.getDefaultConfig();
58
+ const userSettings = await this.readExtensionSettings();
59
+
60
+ this.cachedConfig = { ...defaults, ...userSettings };
61
+
62
+ return { config: this.cachedConfig, errors: this.cachedErrors };
63
+ }
64
+
65
+ /**
66
+ * Writes a partial GitboxConfig, invalidating the cache.
67
+ */
68
+ async setConfig(partial: Partial<GitboxConfig>): Promise<void> {
69
+ await this.writeExtensionSettings(partial);
70
+ this.resetConfigCache();
71
+ }
72
+
73
+ /**
74
+ * Resets the cached config, forcing a fresh read from disk on the next call.
75
+ */
76
+ resetConfigCache(): void {
77
+ this.cachedConfig = null;
78
+ this.cachedErrors = [];
79
+ }
80
+
81
+ /**
82
+ * Reads ~/.pi/agent/settings.json and extracts the "gitbox" settings block.
83
+ *
84
+ * @returns The Gitbox settings object.
85
+ */
86
+ private async readExtensionSettings(): Promise<GitboxConfig> {
87
+ const settings = await this.readSettings();
88
+ return (settings[STATUS_KEY] || {}) as GitboxConfig;
89
+ }
90
+
91
+ /**
92
+ * Writes a partial GitboxConfig to ~/.pi/agent/settings.json,
93
+ * merging it with existing values.
94
+ *
95
+ * @param partial The partial GitboxConfig to write.
96
+ */
97
+ private async writeExtensionSettings(
98
+ partial: Partial<GitboxConfig>,
99
+ ): Promise<void> {
100
+ const settings = await this.readSettings();
101
+ const current = (settings[STATUS_KEY] as Record<string, unknown>) || {};
102
+ settings[STATUS_KEY] = { ...current, ...partial };
103
+
104
+ await this.writeSettings(settings);
105
+ }
106
+
107
+ /**
108
+ * Reads and parses a JSON file.
109
+ *
110
+ * @returns The parsed JSON object, or an empty object on failure.
111
+ */
112
+ async readSettings(): Promise<Record<string, unknown>> {
113
+ try {
114
+ const raw = await readFile(this.path, "utf-8");
115
+ return JSON.parse(raw) as Record<string, unknown>;
116
+ } catch {
117
+ return {};
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Writes a JSON object to the file with 2-space indentation.
123
+ *
124
+ * @param data The object to serialize and write.
125
+ */
126
+ async writeSettings(data: Record<string, unknown>): Promise<void> {
127
+ await writeFile(this.path, JSON.stringify(data, null, 2), "utf-8");
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Shared singleton instance used across the extension.
133
+ */
134
+ export const settings = new Settings();
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true
10
+ },
11
+ "include": ["*.ts", "src/**/*.ts"]
12
+ }