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 +8 -0
- package/extensions/local-agents-only.js +158 -23
- package/package.json +3 -2
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
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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.
|
|
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",
|