pi-local-agents-only 0.1.6 → 0.1.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.7 - 2026-04-11
4
+
5
+ - fix non-git project-root detection so the global `~/.pi` directory no longer hijacks repo inference under a user's home directory
6
+ - make the integration tests hermetic by resolving the repo-local `@mariozechner/pi-coding-agent` install instead of a global npm install
7
+ - refuse to overwrite malformed global allowlist config and write config updates atomically
8
+ - add regression tests for the homedir root bug and config-write hardening
9
+ - add GitHub Actions CI plus a `prepublishOnly` guard that reruns `npm run check` before publish
10
+
3
11
  ## 0.1.6 - 2026-04-07
4
12
 
5
13
  - add dev-time static checking with `tsc --noEmit`
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { execFileSync } from "node:child_process";
12
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { homedir } from "node:os";
14
14
  import { dirname, join, resolve } from "node:path";
15
15
 
@@ -21,6 +21,17 @@ import { dirname, join, resolve } from "node:path";
21
21
  /** @typedef {{ path: string; start: number; end: number }} ContextBlock */
22
22
  /** @typedef {{ prompt: string; removedPaths: string[] }} StripResult */
23
23
 
24
+ class ConfigError extends Error {
25
+ /**
26
+ * @param {string} message
27
+ * @param {unknown} [cause]
28
+ */
29
+ constructor(message, cause) {
30
+ super(message, cause === undefined ? undefined : { cause });
31
+ this.name = "ConfigError";
32
+ }
33
+ }
34
+
24
35
  const COMMAND = "local-agents-only";
25
36
  const MARKER = join(".pi", COMMAND);
26
37
  const GLOBAL_CONTEXT_FILES = ["AGENTS.md", "CLAUDE.md"];
@@ -30,6 +41,7 @@ const PROJECT_CONTEXT_HEADER = "\n\n# Project Context\n\nProject-specific instru
30
41
  const SKILLS_HEADER = "\n\nThe following skills provide specialized instructions for specific tasks.";
31
42
  const DATE_HEADER = "\nCurrent date:";
32
43
  const CONTEXT_BLOCK_HEADER = /^## ([^\n]+(?:AGENTS|CLAUDE)\.md)\n\n/gm;
44
+ const emptyConfig = () => ({ projects: [], repositories: [] });
33
45
 
34
46
  /** @returns {string} */
35
47
  const getAgentDir = () => {
@@ -46,6 +58,13 @@ const getAgentDir = () => {
46
58
  /** @param {string} path */
47
59
  const normalizePath = (path) => resolve(path).replace(/\\/g, "/");
48
60
 
61
+ /** @param {string} path */
62
+ const isGlobalPiDirectory = (path) => {
63
+ const normalizedPath = normalizePath(path);
64
+ const agentDir = normalizePath(getAgentDir());
65
+ return normalizedPath === agentDir || normalizedPath === normalizePath(dirname(agentDir));
66
+ };
67
+
49
68
  /** @returns {string} */
50
69
  const CONFIG = () => join(getAgentDir(), `${COMMAND}.json`);
51
70
 
@@ -91,22 +110,105 @@ const runGit = (start, args) => {
91
110
  }
92
111
  };
93
112
 
113
+ /**
114
+ * @param {string} name
115
+ * @param {unknown} value
116
+ * @param {string} configPath
117
+ * @returns {string[]}
118
+ */
119
+ const parseConfigList = (name, value, configPath) => {
120
+ if (value === undefined) {
121
+ return [];
122
+ }
123
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
124
+ throw new ConfigError(
125
+ `Malformed local-agents-only config at ${normalizePath(configPath)}. Expected "${name}" to be an array of strings. Fix or remove the file, then retry.`,
126
+ );
127
+ }
128
+ return uniqueSorted(value);
129
+ };
130
+
131
+ /**
132
+ * @param {string} rawConfig
133
+ * @param {string} configPath
134
+ * @returns {LocalAgentsOnlyConfig}
135
+ */
136
+ const parseConfig = (rawConfig, configPath) => {
137
+ let parsed;
138
+ try {
139
+ parsed = /** @type {{ projects?: unknown; repositories?: unknown }} */ (JSON.parse(rawConfig));
140
+ } catch (error) {
141
+ throw new ConfigError(
142
+ `Malformed local-agents-only config at ${normalizePath(configPath)}. Fix or remove the file, then retry.`,
143
+ error,
144
+ );
145
+ }
146
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
147
+ throw new ConfigError(
148
+ `Malformed local-agents-only config at ${normalizePath(configPath)}. Expected a JSON object. Fix or remove the file, then retry.`,
149
+ );
150
+ }
151
+ return {
152
+ projects: parseConfigList("projects", parsed.projects, configPath),
153
+ repositories: parseConfigList("repositories", parsed.repositories, configPath),
154
+ };
155
+ };
156
+
157
+ /**
158
+ * @param {string} [configPath]
159
+ * @returns {LocalAgentsOnlyConfig}
160
+ */
161
+ const readConfigForMutation = (configPath = CONFIG()) => {
162
+ if (!existsSync(configPath)) {
163
+ return emptyConfig();
164
+ }
165
+ return parseConfig(readFileSync(configPath, "utf8"), configPath);
166
+ };
167
+
94
168
  /**
95
169
  * @param {string} [configPath]
96
170
  * @returns {LocalAgentsOnlyConfig}
97
171
  */
98
172
  const readConfig = (configPath = CONFIG()) => {
99
173
  try {
100
- const parsed = /** @type {{ projects?: unknown; repositories?: unknown }} */ (
101
- JSON.parse(readFileSync(configPath, "utf8"))
102
- );
103
- const { projects = [], repositories = [] } = parsed;
104
- return {
105
- projects: Array.isArray(projects) ? projects.map((value) => normalizePath(String(value))) : [],
106
- repositories: Array.isArray(repositories) ? repositories.map((value) => normalizePath(String(value))) : [],
107
- };
174
+ return readConfigForMutation(configPath);
108
175
  } catch {
109
- return { projects: [], repositories: [] };
176
+ return emptyConfig();
177
+ }
178
+ };
179
+
180
+ /**
181
+ * @param {string} path
182
+ * @param {string} content
183
+ */
184
+ const writeFileAtomically = (path, content) => {
185
+ mkdirSync(dirname(path), { recursive: true });
186
+ const tempPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
187
+ /** @type {number | undefined} */
188
+ let fileDescriptor;
189
+ try {
190
+ fileDescriptor = openSync(tempPath, "wx", 0o600);
191
+ writeFileSync(fileDescriptor, content, "utf8");
192
+ fsyncSync(fileDescriptor);
193
+ closeSync(fileDescriptor);
194
+ fileDescriptor = undefined;
195
+ renameSync(tempPath, path);
196
+ try {
197
+ const directoryDescriptor = openSync(dirname(path), "r");
198
+ try {
199
+ fsyncSync(directoryDescriptor);
200
+ } finally {
201
+ closeSync(directoryDescriptor);
202
+ }
203
+ } catch {
204
+ // Best effort: directory fsync is not available on every platform.
205
+ }
206
+ } catch (error) {
207
+ if (fileDescriptor !== undefined) {
208
+ closeSync(fileDescriptor);
209
+ }
210
+ rmSync(tempPath, { force: true });
211
+ throw error;
110
212
  }
111
213
  };
112
214
 
@@ -115,8 +217,7 @@ const readConfig = (configPath = CONFIG()) => {
115
217
  * @param {string} [configPath]
116
218
  */
117
219
  const writeConfig = ({ projects, repositories }, configPath = CONFIG()) => {
118
- mkdirSync(dirname(configPath), { recursive: true });
119
- writeFileSync(
220
+ writeFileAtomically(
120
221
  configPath,
121
222
  JSON.stringify(
122
223
  {
@@ -265,7 +366,10 @@ const getProjectState = (start = process.cwd()) => {
265
366
  const projectRoot =
266
367
  gitTopLevel ||
267
368
  walkUp(normalizedStart, (dir) => existsSync(getMarkerPath(dir))) ||
268
- walkUp(normalizedStart, (dir) => existsSync(join(dir, ".pi"))) ||
369
+ walkUp(normalizedStart, (dir) => {
370
+ const piDir = join(dir, ".pi");
371
+ return existsSync(piDir) && !isGlobalPiDirectory(piDir);
372
+ }) ||
269
373
  normalizedStart;
270
374
  const worktreeRoots = getWorktreeRoots(normalizedStart);
271
375
  return {
@@ -362,6 +466,29 @@ const getOffNotification = (state) => {
362
466
  return `Repo marker cleared for ${getProjectTarget(state)}, but local-agents-only is still enabled via ${mode.source}.`;
363
467
  };
364
468
 
469
+ /**
470
+ * @param {(config: LocalAgentsOnlyConfig) => LocalAgentsOnlyConfig} mutate
471
+ */
472
+ const mutateGlobalConfig = (mutate) => {
473
+ const configPath = CONFIG();
474
+ const config = readConfigForMutation(configPath);
475
+ writeConfig(mutate(config), configPath);
476
+ return configPath;
477
+ };
478
+
479
+ /**
480
+ * @param {unknown} error
481
+ * @param {string} [configPath]
482
+ * @returns {string}
483
+ */
484
+ const getGlobalConfigMutationError = (error, configPath = CONFIG()) => {
485
+ if (error instanceof ConfigError) {
486
+ return `Global allowlist unchanged: ${error.message}`;
487
+ }
488
+ const reason = error instanceof Error ? error.message : String(error);
489
+ return `Global allowlist unchanged: failed to update ${normalizePath(configPath)} (${reason}).`;
490
+ };
491
+
365
492
  /**
366
493
  * @param {string} [start]
367
494
  * @returns {string}
@@ -425,21 +552,29 @@ export default function localAgentsOnly(pi) {
425
552
  ctx.ui.notify(getOffNotification(state), "info");
426
553
  return;
427
554
  case "global-on": {
428
- const config = readConfig();
429
- writeConfig({
430
- projects: [...config.projects, ...state.worktreeRoots],
431
- repositories: [...config.repositories, state.repoId],
432
- });
555
+ try {
556
+ mutateGlobalConfig((config) => ({
557
+ projects: [...config.projects, ...state.worktreeRoots],
558
+ repositories: [...config.repositories, state.repoId],
559
+ }));
560
+ } catch (error) {
561
+ ctx.ui.notify(getGlobalConfigMutationError(error), "error");
562
+ return;
563
+ }
433
564
  setStatus(ctx);
434
565
  ctx.ui.notify(`Global allowlist enabled for ${state.projectRoot}`, "info");
435
566
  return;
436
567
  }
437
568
  case "global-off": {
438
- const config = readConfig();
439
- writeConfig({
440
- projects: config.projects.filter((path) => !state.worktreeRoots.includes(path)),
441
- repositories: config.repositories.filter((id) => id !== state.repoId),
442
- });
569
+ try {
570
+ mutateGlobalConfig((config) => ({
571
+ projects: config.projects.filter((path) => !state.worktreeRoots.includes(path)),
572
+ repositories: config.repositories.filter((id) => id !== state.repoId),
573
+ }));
574
+ } catch (error) {
575
+ ctx.ui.notify(getGlobalConfigMutationError(error), "error");
576
+ return;
577
+ }
443
578
  setStatus(ctx);
444
579
  ctx.ui.notify(`Global allowlist disabled for ${state.projectRoot}`, "info");
445
580
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-local-agents-only",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Pi extension that strips global AGENTS.md and CLAUDE.md from the effective prompt for selected projects.",
5
5
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
6
6
  "license": "MIT",
@@ -33,7 +33,8 @@
33
33
  "scripts": {
34
34
  "test": "node --test",
35
35
  "typecheck": "tsc --noEmit",
36
- "check": "npm test && npm run typecheck"
36
+ "check": "npm test && npm run typecheck",
37
+ "prepublishOnly": "npm run check"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@mariozechner/pi-coding-agent": "^0.65.2",