pi-local-agents-only 0.1.4 → 0.1.6

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 ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.6 - 2026-04-07
4
+
5
+ - add dev-time static checking with `tsc --noEmit`
6
+ - add a local `check` script that runs tests plus typechecking
7
+ - keep pi core typing support in `devDependencies` only so this stays a lightweight published package
8
+ - annotate the extension with `// @ts-check` and JSDoc types for earlier local error detection
9
+
10
+ ## 0.1.5 - 2026-04-07
11
+
12
+ - strip already-loaded global `AGENTS.md` / `CLAUDE.md` context reliably instead of rereading live files from disk
13
+ - remove the now-empty `# Project Context` section when only global context was loaded
14
+ - handle prompts whose custom prompt text also mentions the `# Project Context` heading
15
+ - make `/local-agents-only off` report when the repo is still enabled via the global allowlist or `PI_LOCAL_AGENTS_ONLY`
16
+ - document the repo-marker-only behavior of `/local-agents-only off`
17
+ - add integration tests against pi's real system prompt builder and command UX regressions
package/README.md CHANGED
@@ -30,6 +30,8 @@ Disable for the current repo:
30
30
  /local-agents-only off
31
31
  ```
32
32
 
33
+ `/local-agents-only off` clears the repo marker only. If the repo is still enabled via `/local-agents-only global-on` or `PI_LOCAL_AGENTS_ONLY=1`, it remains enabled until you also run `/local-agents-only global-off` or unset the env var.
34
+
33
35
  Enable or disable via the global allowlist:
34
36
 
35
37
  ```bash
@@ -1,3 +1,5 @@
1
+ // @ts-check
2
+
1
3
  /**
2
4
  * Purpose: Strip pi's global AGENTS.md and CLAUDE.md blocks from the effective prompt for opted-in projects.
3
5
  * Responsibilities: Detect repo and worktree opt-in state, manage repo and global toggles, add a local-only guardrail, and remove matching global context blocks before model calls.
@@ -11,12 +13,25 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
11
13
  import { homedir } from "node:os";
12
14
  import { dirname, join, resolve } from "node:path";
13
15
 
16
+ /** @typedef {import("@mariozechner/pi-coding-agent").ExtensionAPI} ExtensionAPI */
17
+ /** @typedef {import("@mariozechner/pi-coding-agent").ExtensionContext} ExtensionContext */
18
+ /** @typedef {{ projects: string[]; repositories: string[] }} LocalAgentsOnlyConfig */
19
+ /** @typedef {{ start: string; projectRoot: string; repoId: string; worktreeRoots: string[] }} ProjectState */
20
+ /** @typedef {{ enabled: boolean; source: "env" | "marker" | "global-config" | "default" }} Mode */
21
+ /** @typedef {{ path: string; start: number; end: number }} ContextBlock */
22
+ /** @typedef {{ prompt: string; removedPaths: string[] }} StripResult */
23
+
14
24
  const COMMAND = "local-agents-only";
15
25
  const MARKER = join(".pi", COMMAND);
16
26
  const GLOBAL_CONTEXT_FILES = ["AGENTS.md", "CLAUDE.md"];
17
27
  const ENV_TRUE = ["1", "true", "yes", "on"];
18
28
  const ENV_FALSE = ["0", "false", "no", "off"];
29
+ const PROJECT_CONTEXT_HEADER = "\n\n# Project Context\n\nProject-specific instructions and guidelines:\n\n";
30
+ const SKILLS_HEADER = "\n\nThe following skills provide specialized instructions for specific tasks.";
31
+ const DATE_HEADER = "\nCurrent date:";
32
+ const CONTEXT_BLOCK_HEADER = /^## ([^\n]+(?:AGENTS|CLAUDE)\.md)\n\n/gm;
19
33
 
34
+ /** @returns {string} */
20
35
  const getAgentDir = () => {
21
36
  const env = process.env.PI_CODING_AGENT_DIR;
22
37
  if (env === "~") {
@@ -27,10 +42,24 @@ const getAgentDir = () => {
27
42
  }
28
43
  return env || join(homedir(), ".pi", "agent");
29
44
  };
45
+
46
+ /** @param {string} path */
30
47
  const normalizePath = (path) => resolve(path).replace(/\\/g, "/");
48
+
49
+ /** @returns {string} */
31
50
  const CONFIG = () => join(getAgentDir(), `${COMMAND}.json`);
51
+
52
+ /** @param {string} projectRoot */
32
53
  const getMarkerPath = (projectRoot) => join(projectRoot, MARKER);
54
+
55
+ /** @param {string[]} values */
33
56
  const uniqueSorted = (values) => [...new Set(values.map(normalizePath))].sort();
57
+
58
+ /**
59
+ * @param {string} start
60
+ * @param {(dir: string) => boolean} predicate
61
+ * @returns {string | undefined}
62
+ */
34
63
  const walkUp = (start, predicate) => {
35
64
  let current = resolve(start);
36
65
  while (true) {
@@ -39,11 +68,17 @@ const walkUp = (start, predicate) => {
39
68
  }
40
69
  const parent = dirname(current);
41
70
  if (parent === current) {
42
- return;
71
+ return undefined;
43
72
  }
44
73
  current = parent;
45
74
  }
46
75
  };
76
+
77
+ /**
78
+ * @param {string} start
79
+ * @param {string[]} args
80
+ * @returns {string | undefined}
81
+ */
47
82
  const runGit = (start, args) => {
48
83
  try {
49
84
  return execFileSync("git", args, {
@@ -52,20 +87,33 @@ const runGit = (start, args) => {
52
87
  stdio: ["ignore", "pipe", "ignore"],
53
88
  }).trim();
54
89
  } catch {
55
- return;
90
+ return undefined;
56
91
  }
57
92
  };
93
+
94
+ /**
95
+ * @param {string} [configPath]
96
+ * @returns {LocalAgentsOnlyConfig}
97
+ */
58
98
  const readConfig = (configPath = CONFIG()) => {
59
99
  try {
60
- const { projects = [], repositories = [] } = JSON.parse(readFileSync(configPath, "utf8"));
100
+ const parsed = /** @type {{ projects?: unknown; repositories?: unknown }} */ (
101
+ JSON.parse(readFileSync(configPath, "utf8"))
102
+ );
103
+ const { projects = [], repositories = [] } = parsed;
61
104
  return {
62
- projects: Array.isArray(projects) ? projects.map(normalizePath) : [],
63
- repositories: Array.isArray(repositories) ? repositories.map(normalizePath) : [],
105
+ projects: Array.isArray(projects) ? projects.map((value) => normalizePath(String(value))) : [],
106
+ repositories: Array.isArray(repositories) ? repositories.map((value) => normalizePath(String(value))) : [],
64
107
  };
65
108
  } catch {
66
109
  return { projects: [], repositories: [] };
67
110
  }
68
111
  };
112
+
113
+ /**
114
+ * @param {LocalAgentsOnlyConfig} config
115
+ * @param {string} [configPath]
116
+ */
69
117
  const writeConfig = ({ projects, repositories }, configPath = CONFIG()) => {
70
118
  mkdirSync(dirname(configPath), { recursive: true });
71
119
  writeFileSync(
@@ -80,6 +128,11 @@ const writeConfig = ({ projects, repositories }, configPath = CONFIG()) => {
80
128
  ) + "\n",
81
129
  );
82
130
  };
131
+
132
+ /**
133
+ * @param {string | undefined} [value]
134
+ * @returns {boolean | undefined}
135
+ */
83
136
  const getEnvToggle = (value = process.env.PI_LOCAL_AGENTS_ONLY) => {
84
137
  const toggle = `${value ?? ""}`.trim().toLowerCase();
85
138
  if (ENV_TRUE.includes(toggle)) {
@@ -88,19 +141,107 @@ const getEnvToggle = (value = process.env.PI_LOCAL_AGENTS_ONLY) => {
88
141
  if (ENV_FALSE.includes(toggle)) {
89
142
  return false;
90
143
  }
144
+ return undefined;
145
+ };
146
+
147
+ /** @param {string} [agentDir] */
148
+ const getGlobalContextPaths = (agentDir = getAgentDir()) => GLOBAL_CONTEXT_FILES.map((name) => join(agentDir, name));
149
+
150
+ /** @param {string} [agentDir] */
151
+ const getExistingGlobalContextPaths = (agentDir = getAgentDir()) =>
152
+ getGlobalContextPaths(agentDir).filter((path) => existsSync(path));
153
+
154
+ /**
155
+ * @param {string} prompt
156
+ * @param {number} offset
157
+ * @returns {number}
158
+ */
159
+ const getContextSectionEnd = (prompt, offset) => {
160
+ const candidates = [prompt.indexOf(SKILLS_HEADER, offset), prompt.indexOf(DATE_HEADER, offset)].filter(
161
+ (index) => index !== -1,
162
+ );
163
+ return candidates.length > 0 ? Math.min(...candidates) : prompt.length;
164
+ };
165
+
166
+ /**
167
+ * @param {string} contextSection
168
+ * @returns {ContextBlock[]}
169
+ */
170
+ const getContextBlocks = (contextSection) => {
171
+ const matches = [...contextSection.matchAll(CONTEXT_BLOCK_HEADER)];
172
+ return matches.map((match, index) => ({
173
+ path: match[1],
174
+ start: match.index ?? 0,
175
+ end: index + 1 < matches.length ? (matches[index + 1].index ?? contextSection.length) : contextSection.length,
176
+ }));
177
+ };
178
+
179
+ /**
180
+ * @param {string} prompt
181
+ * @param {string[]} [globalPaths]
182
+ * @returns {StripResult}
183
+ */
184
+ const stripGlobalContext = (prompt, globalPaths = getGlobalContextPaths()) => {
185
+ const sectionStart = prompt.lastIndexOf(PROJECT_CONTEXT_HEADER);
186
+ if (sectionStart === -1) {
187
+ return { prompt, removedPaths: [] };
188
+ }
189
+ const contextStart = sectionStart + PROJECT_CONTEXT_HEADER.length;
190
+ const sectionEnd = getContextSectionEnd(prompt, contextStart);
191
+ const contextSection = prompt.slice(contextStart, sectionEnd);
192
+ const blocks = getContextBlocks(contextSection);
193
+ if (blocks.length === 0) {
194
+ return { prompt, removedPaths: [] };
195
+ }
196
+ const globalPathKeys = new Set(globalPaths.map(normalizePath));
197
+ /** @type {string[]} */
198
+ const keptBlocks = [];
199
+ /** @type {string[]} */
200
+ const removedPaths = [];
201
+ for (const block of blocks) {
202
+ const blockText = contextSection.slice(block.start, block.end);
203
+ if (globalPathKeys.has(normalizePath(block.path))) {
204
+ removedPaths.push(block.path);
205
+ } else {
206
+ keptBlocks.push(blockText);
207
+ }
208
+ }
209
+ if (removedPaths.length === 0) {
210
+ return { prompt, removedPaths: [] };
211
+ }
212
+ const prefix = prompt.slice(0, sectionStart);
213
+ const suffix = prompt.slice(sectionEnd);
214
+ if (keptBlocks.length === 0) {
215
+ return { prompt: `${prefix}${suffix}`, removedPaths: uniqueSorted(removedPaths) };
216
+ }
217
+ return {
218
+ prompt: `${prefix}${PROJECT_CONTEXT_HEADER}${keptBlocks.join("")}${suffix}`,
219
+ removedPaths: uniqueSorted(removedPaths),
220
+ };
91
221
  };
92
- const getGlobalContextPaths = (agentDir = getAgentDir()) =>
93
- GLOBAL_CONTEXT_FILES.map((name) => join(agentDir, name)).filter((path) => existsSync(path));
94
- const getGlobalBlocks = (agentDir = getAgentDir()) =>
95
- getGlobalContextPaths(agentDir).map((path) => `## ${path}\n\n${readFileSync(path, "utf8")}\n\n`);
222
+
223
+ /**
224
+ * @param {string} start
225
+ * @returns {string | undefined}
226
+ */
96
227
  const getGitTopLevel = (start) => {
97
228
  const topLevel = runGit(start, ["rev-parse", "--show-toplevel"]);
98
229
  return topLevel ? normalizePath(topLevel) : undefined;
99
230
  };
231
+
232
+ /**
233
+ * @param {string} start
234
+ * @returns {string | undefined}
235
+ */
100
236
  const getGitCommonDir = (start) => {
101
237
  const commonDir = runGit(start, ["rev-parse", "--git-common-dir"]);
102
238
  return commonDir ? normalizePath(resolve(start, commonDir)) : undefined;
103
239
  };
240
+
241
+ /**
242
+ * @param {string} start
243
+ * @returns {string[]}
244
+ */
104
245
  const getWorktreeRoots = (start) => {
105
246
  const list = runGit(start, ["worktree", "list", "--porcelain"]);
106
247
  if (!list) {
@@ -113,6 +254,11 @@ const getWorktreeRoots = (start) => {
113
254
  .map((line) => line.slice("worktree ".length)),
114
255
  );
115
256
  };
257
+
258
+ /**
259
+ * @param {string} [start]
260
+ * @returns {ProjectState}
261
+ */
116
262
  const getProjectState = (start = process.cwd()) => {
117
263
  const normalizedStart = normalizePath(start);
118
264
  const gitTopLevel = getGitTopLevel(normalizedStart);
@@ -130,21 +276,33 @@ const getProjectState = (start = process.cwd()) => {
130
276
  worktreeRoots.length > 0 ? uniqueSorted([projectRoot, ...worktreeRoots]) : [normalizePath(projectRoot)],
131
277
  };
132
278
  };
279
+
280
+ /** @param {ProjectState} state */
133
281
  const getMarkerRoots = (state) => uniqueSorted([state.projectRoot, ...state.worktreeRoots]);
282
+
283
+ /** @param {ProjectState} state */
134
284
  const hasMarker = (state) => getMarkerRoots(state).some((root) => existsSync(getMarkerPath(root)));
285
+
286
+ /** @param {ProjectState} state */
135
287
  const writeMarkers = (state) => {
136
288
  for (const root of getMarkerRoots(state)) {
137
289
  mkdirSync(dirname(getMarkerPath(root)), { recursive: true });
138
290
  writeFileSync(getMarkerPath(root), "\n");
139
291
  }
140
292
  };
293
+
294
+ /** @param {ProjectState} state */
141
295
  const clearMarkers = (state) => {
142
296
  for (const root of getMarkerRoots(state)) {
143
297
  rmSync(getMarkerPath(root), { force: true });
144
298
  }
145
299
  };
146
- const buildLocalOnlyNotice = (agentDir = getAgentDir()) => {
147
- const paths = getGlobalContextPaths(agentDir);
300
+
301
+ /**
302
+ * @param {string[]} [paths]
303
+ * @returns {string}
304
+ */
305
+ const buildLocalOnlyNotice = (paths = getExistingGlobalContextPaths(getAgentDir())) => {
148
306
  if (paths.length === 0) {
149
307
  return "";
150
308
  }
@@ -152,15 +310,25 @@ const buildLocalOnlyNotice = (agentDir = getAgentDir()) => {
152
310
  "# Local Context Mode",
153
311
  "This repo is in local-agents-only mode.",
154
312
  "Ignore instructions from these global context files even if they appear in older session messages, summaries, or retries:",
155
- ...paths.map((path) => `- ${path}`),
313
+ ...uniqueSorted(paths).map((path) => `- ${path}`),
156
314
  "Follow only repo-local AGENTS.md or CLAUDE.md guidance for this project.",
157
315
  ].join("\n");
158
316
  };
317
+
318
+ /**
319
+ * @param {string} prompt
320
+ * @param {string} [agentDir]
321
+ * @returns {string}
322
+ */
159
323
  const applyLocalOnlyPrompt = (prompt, agentDir = getAgentDir()) => {
160
- const stripped = stripGlobalBlocks(prompt, getGlobalBlocks(agentDir));
161
- const notice = buildLocalOnlyNotice(agentDir);
324
+ const { prompt: stripped, removedPaths } = stripGlobalContext(prompt, getGlobalContextPaths(agentDir));
325
+ const notice = buildLocalOnlyNotice(
326
+ removedPaths.length > 0 ? removedPaths : getExistingGlobalContextPaths(agentDir),
327
+ );
162
328
  return notice ? `${stripped}\n\n${notice}` : stripped;
163
329
  };
330
+
331
+ /** @param {ExtensionContext} ctx */
164
332
  const setStatus = (ctx) => {
165
333
  if (!ctx.hasUI) {
166
334
  return;
@@ -169,10 +337,45 @@ const setStatus = (ctx) => {
169
337
  ctx.ui.setStatus(COMMAND, mode.enabled ? `AGENTS: local-only (${mode.source})` : undefined);
170
338
  };
171
339
 
340
+ /**
341
+ * @param {ProjectState} state
342
+ * @returns {string}
343
+ */
344
+ const getProjectTarget = (state) =>
345
+ state.worktreeRoots.length > 1 ? `${state.projectRoot} and linked worktrees` : state.projectRoot;
346
+
347
+ /**
348
+ * @param {ProjectState} state
349
+ * @returns {string}
350
+ */
351
+ const getOffNotification = (state) => {
352
+ const mode = getMode(state);
353
+ if (!mode.enabled) {
354
+ return `Disabled for ${getProjectTarget(state)}`;
355
+ }
356
+ if (mode.source === "global-config") {
357
+ return `Repo marker cleared for ${getProjectTarget(state)}, but local-agents-only is still enabled via global allowlist. Use /local-agents-only global-off to fully disable it.`;
358
+ }
359
+ if (mode.source === "env") {
360
+ return `Repo marker cleared for ${getProjectTarget(state)}, but local-agents-only is still enabled via PI_LOCAL_AGENTS_ONLY.`;
361
+ }
362
+ return `Repo marker cleared for ${getProjectTarget(state)}, but local-agents-only is still enabled via ${mode.source}.`;
363
+ };
364
+
365
+ /**
366
+ * @param {string} [start]
367
+ * @returns {string}
368
+ */
172
369
  export function findProjectRoot(start = process.cwd()) {
173
370
  return getProjectState(start).projectRoot;
174
371
  }
175
372
 
373
+ /**
374
+ * @param {string | ProjectState} [start]
375
+ * @param {string | undefined} [envValue]
376
+ * @param {string} [configPath]
377
+ * @returns {Mode}
378
+ */
176
379
  export function getMode(start = process.cwd(), envValue = process.env.PI_LOCAL_AGENTS_ONLY, configPath = CONFIG()) {
177
380
  const state = typeof start === "string" ? getProjectState(start) : start;
178
381
  const envToggle = getEnvToggle(envValue);
@@ -192,10 +395,16 @@ export function getMode(start = process.cwd(), envValue = process.env.PI_LOCAL_A
192
395
  return { enabled: false, source: "default" };
193
396
  }
194
397
 
195
- export function stripGlobalBlocks(prompt, blocks = getGlobalBlocks()) {
196
- return blocks.reduce((nextPrompt, block) => nextPrompt.replace(block, ""), prompt);
398
+ /**
399
+ * @param {string} prompt
400
+ * @param {string[]} [globalPaths]
401
+ * @returns {string}
402
+ */
403
+ export function stripGlobalBlocks(prompt, globalPaths = getGlobalContextPaths()) {
404
+ return stripGlobalContext(prompt, globalPaths).prompt;
197
405
  }
198
406
 
407
+ /** @param {ExtensionAPI} pi */
199
408
  export default function localAgentsOnly(pi) {
200
409
  pi.registerCommand(COMMAND, {
201
410
  description: "Use only repo-local AGENTS prompt context",
@@ -205,12 +414,15 @@ export default function localAgentsOnly(pi) {
205
414
  case "on":
206
415
  writeMarkers(state);
207
416
  setStatus(ctx);
208
- ctx.ui.notify(`Enabled for ${state.projectRoot}${state.worktreeRoots.length > 1 ? ` across ${state.worktreeRoots.length} worktrees` : ""}`, "info");
417
+ ctx.ui.notify(
418
+ `Enabled for ${state.projectRoot}${state.worktreeRoots.length > 1 ? ` across ${state.worktreeRoots.length} worktrees` : ""}`,
419
+ "info",
420
+ );
209
421
  return;
210
422
  case "off":
211
423
  clearMarkers(state);
212
424
  setStatus(ctx);
213
- ctx.ui.notify(`Disabled for ${state.projectRoot}${state.worktreeRoots.length > 1 ? ` and linked worktrees` : ""}`, "info");
425
+ ctx.ui.notify(getOffNotification(state), "info");
214
426
  return;
215
427
  case "global-on": {
216
428
  const config = readConfig();
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "pi-local-agents-only",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",
7
7
  "type": "module",
8
- "keywords": ["pi-package", "pi", "pi-extension", "extension"],
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "pi-extension",
12
+ "extension"
13
+ ],
9
14
  "repository": {
10
15
  "type": "git",
11
16
  "url": "git+https://github.com/fitchmultz/pi-local-agents-only.git"
@@ -14,11 +19,25 @@
14
19
  "url": "https://github.com/fitchmultz/pi-local-agents-only/issues"
15
20
  },
16
21
  "homepage": "https://github.com/fitchmultz/pi-local-agents-only#readme",
17
- "files": ["extensions", "README.md", "LICENSE"],
22
+ "files": [
23
+ "extensions",
24
+ "README.md",
25
+ "CHANGELOG.md",
26
+ "LICENSE"
27
+ ],
18
28
  "pi": {
19
- "extensions": ["./extensions"]
29
+ "extensions": [
30
+ "./extensions"
31
+ ]
20
32
  },
21
33
  "scripts": {
22
- "test": "node --test"
34
+ "test": "node --test",
35
+ "typecheck": "tsc --noEmit",
36
+ "check": "npm test && npm run typecheck"
37
+ },
38
+ "devDependencies": {
39
+ "@mariozechner/pi-coding-agent": "^0.65.2",
40
+ "@types/node": "^24.3.0",
41
+ "typescript": "^5.7.3"
23
42
  }
24
43
  }