pi-local-agents-only 0.1.5 → 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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
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
+
3
10
  ## 0.1.5 - 2026-04-07
4
11
 
5
12
  - strip already-loaded global `AGENTS.md` / `CLAUDE.md` context reliably instead of rereading live files from disk
@@ -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,6 +13,14 @@ 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"];
@@ -21,6 +31,7 @@ const SKILLS_HEADER = "\n\nThe following skills provide specialized instructions
21
31
  const DATE_HEADER = "\nCurrent date:";
22
32
  const CONTEXT_BLOCK_HEADER = /^## ([^\n]+(?:AGENTS|CLAUDE)\.md)\n\n/gm;
23
33
 
34
+ /** @returns {string} */
24
35
  const getAgentDir = () => {
25
36
  const env = process.env.PI_CODING_AGENT_DIR;
26
37
  if (env === "~") {
@@ -31,10 +42,24 @@ const getAgentDir = () => {
31
42
  }
32
43
  return env || join(homedir(), ".pi", "agent");
33
44
  };
45
+
46
+ /** @param {string} path */
34
47
  const normalizePath = (path) => resolve(path).replace(/\\/g, "/");
48
+
49
+ /** @returns {string} */
35
50
  const CONFIG = () => join(getAgentDir(), `${COMMAND}.json`);
51
+
52
+ /** @param {string} projectRoot */
36
53
  const getMarkerPath = (projectRoot) => join(projectRoot, MARKER);
54
+
55
+ /** @param {string[]} values */
37
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
+ */
38
63
  const walkUp = (start, predicate) => {
39
64
  let current = resolve(start);
40
65
  while (true) {
@@ -43,11 +68,17 @@ const walkUp = (start, predicate) => {
43
68
  }
44
69
  const parent = dirname(current);
45
70
  if (parent === current) {
46
- return;
71
+ return undefined;
47
72
  }
48
73
  current = parent;
49
74
  }
50
75
  };
76
+
77
+ /**
78
+ * @param {string} start
79
+ * @param {string[]} args
80
+ * @returns {string | undefined}
81
+ */
51
82
  const runGit = (start, args) => {
52
83
  try {
53
84
  return execFileSync("git", args, {
@@ -56,20 +87,33 @@ const runGit = (start, args) => {
56
87
  stdio: ["ignore", "pipe", "ignore"],
57
88
  }).trim();
58
89
  } catch {
59
- return;
90
+ return undefined;
60
91
  }
61
92
  };
93
+
94
+ /**
95
+ * @param {string} [configPath]
96
+ * @returns {LocalAgentsOnlyConfig}
97
+ */
62
98
  const readConfig = (configPath = CONFIG()) => {
63
99
  try {
64
- 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;
65
104
  return {
66
- projects: Array.isArray(projects) ? projects.map(normalizePath) : [],
67
- 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))) : [],
68
107
  };
69
108
  } catch {
70
109
  return { projects: [], repositories: [] };
71
110
  }
72
111
  };
112
+
113
+ /**
114
+ * @param {LocalAgentsOnlyConfig} config
115
+ * @param {string} [configPath]
116
+ */
73
117
  const writeConfig = ({ projects, repositories }, configPath = CONFIG()) => {
74
118
  mkdirSync(dirname(configPath), { recursive: true });
75
119
  writeFileSync(
@@ -84,6 +128,11 @@ const writeConfig = ({ projects, repositories }, configPath = CONFIG()) => {
84
128
  ) + "\n",
85
129
  );
86
130
  };
131
+
132
+ /**
133
+ * @param {string | undefined} [value]
134
+ * @returns {boolean | undefined}
135
+ */
87
136
  const getEnvToggle = (value = process.env.PI_LOCAL_AGENTS_ONLY) => {
88
137
  const toggle = `${value ?? ""}`.trim().toLowerCase();
89
138
  if (ENV_TRUE.includes(toggle)) {
@@ -92,24 +141,46 @@ const getEnvToggle = (value = process.env.PI_LOCAL_AGENTS_ONLY) => {
92
141
  if (ENV_FALSE.includes(toggle)) {
93
142
  return false;
94
143
  }
144
+ return undefined;
95
145
  };
146
+
147
+ /** @param {string} [agentDir] */
96
148
  const getGlobalContextPaths = (agentDir = getAgentDir()) => GLOBAL_CONTEXT_FILES.map((name) => join(agentDir, name));
149
+
150
+ /** @param {string} [agentDir] */
97
151
  const getExistingGlobalContextPaths = (agentDir = getAgentDir()) =>
98
152
  getGlobalContextPaths(agentDir).filter((path) => existsSync(path));
153
+
154
+ /**
155
+ * @param {string} prompt
156
+ * @param {number} offset
157
+ * @returns {number}
158
+ */
99
159
  const getContextSectionEnd = (prompt, offset) => {
100
160
  const candidates = [prompt.indexOf(SKILLS_HEADER, offset), prompt.indexOf(DATE_HEADER, offset)].filter(
101
161
  (index) => index !== -1,
102
162
  );
103
163
  return candidates.length > 0 ? Math.min(...candidates) : prompt.length;
104
164
  };
165
+
166
+ /**
167
+ * @param {string} contextSection
168
+ * @returns {ContextBlock[]}
169
+ */
105
170
  const getContextBlocks = (contextSection) => {
106
171
  const matches = [...contextSection.matchAll(CONTEXT_BLOCK_HEADER)];
107
172
  return matches.map((match, index) => ({
108
173
  path: match[1],
109
- start: match.index,
110
- end: index + 1 < matches.length ? matches[index + 1].index : contextSection.length,
174
+ start: match.index ?? 0,
175
+ end: index + 1 < matches.length ? (matches[index + 1].index ?? contextSection.length) : contextSection.length,
111
176
  }));
112
177
  };
178
+
179
+ /**
180
+ * @param {string} prompt
181
+ * @param {string[]} [globalPaths]
182
+ * @returns {StripResult}
183
+ */
113
184
  const stripGlobalContext = (prompt, globalPaths = getGlobalContextPaths()) => {
114
185
  const sectionStart = prompt.lastIndexOf(PROJECT_CONTEXT_HEADER);
115
186
  if (sectionStart === -1) {
@@ -123,7 +194,9 @@ const stripGlobalContext = (prompt, globalPaths = getGlobalContextPaths()) => {
123
194
  return { prompt, removedPaths: [] };
124
195
  }
125
196
  const globalPathKeys = new Set(globalPaths.map(normalizePath));
197
+ /** @type {string[]} */
126
198
  const keptBlocks = [];
199
+ /** @type {string[]} */
127
200
  const removedPaths = [];
128
201
  for (const block of blocks) {
129
202
  const blockText = contextSection.slice(block.start, block.end);
@@ -146,14 +219,29 @@ const stripGlobalContext = (prompt, globalPaths = getGlobalContextPaths()) => {
146
219
  removedPaths: uniqueSorted(removedPaths),
147
220
  };
148
221
  };
222
+
223
+ /**
224
+ * @param {string} start
225
+ * @returns {string | undefined}
226
+ */
149
227
  const getGitTopLevel = (start) => {
150
228
  const topLevel = runGit(start, ["rev-parse", "--show-toplevel"]);
151
229
  return topLevel ? normalizePath(topLevel) : undefined;
152
230
  };
231
+
232
+ /**
233
+ * @param {string} start
234
+ * @returns {string | undefined}
235
+ */
153
236
  const getGitCommonDir = (start) => {
154
237
  const commonDir = runGit(start, ["rev-parse", "--git-common-dir"]);
155
238
  return commonDir ? normalizePath(resolve(start, commonDir)) : undefined;
156
239
  };
240
+
241
+ /**
242
+ * @param {string} start
243
+ * @returns {string[]}
244
+ */
157
245
  const getWorktreeRoots = (start) => {
158
246
  const list = runGit(start, ["worktree", "list", "--porcelain"]);
159
247
  if (!list) {
@@ -166,6 +254,11 @@ const getWorktreeRoots = (start) => {
166
254
  .map((line) => line.slice("worktree ".length)),
167
255
  );
168
256
  };
257
+
258
+ /**
259
+ * @param {string} [start]
260
+ * @returns {ProjectState}
261
+ */
169
262
  const getProjectState = (start = process.cwd()) => {
170
263
  const normalizedStart = normalizePath(start);
171
264
  const gitTopLevel = getGitTopLevel(normalizedStart);
@@ -183,19 +276,32 @@ const getProjectState = (start = process.cwd()) => {
183
276
  worktreeRoots.length > 0 ? uniqueSorted([projectRoot, ...worktreeRoots]) : [normalizePath(projectRoot)],
184
277
  };
185
278
  };
279
+
280
+ /** @param {ProjectState} state */
186
281
  const getMarkerRoots = (state) => uniqueSorted([state.projectRoot, ...state.worktreeRoots]);
282
+
283
+ /** @param {ProjectState} state */
187
284
  const hasMarker = (state) => getMarkerRoots(state).some((root) => existsSync(getMarkerPath(root)));
285
+
286
+ /** @param {ProjectState} state */
188
287
  const writeMarkers = (state) => {
189
288
  for (const root of getMarkerRoots(state)) {
190
289
  mkdirSync(dirname(getMarkerPath(root)), { recursive: true });
191
290
  writeFileSync(getMarkerPath(root), "\n");
192
291
  }
193
292
  };
293
+
294
+ /** @param {ProjectState} state */
194
295
  const clearMarkers = (state) => {
195
296
  for (const root of getMarkerRoots(state)) {
196
297
  rmSync(getMarkerPath(root), { force: true });
197
298
  }
198
299
  };
300
+
301
+ /**
302
+ * @param {string[]} [paths]
303
+ * @returns {string}
304
+ */
199
305
  const buildLocalOnlyNotice = (paths = getExistingGlobalContextPaths(getAgentDir())) => {
200
306
  if (paths.length === 0) {
201
307
  return "";
@@ -208,6 +314,12 @@ const buildLocalOnlyNotice = (paths = getExistingGlobalContextPaths(getAgentDir(
208
314
  "Follow only repo-local AGENTS.md or CLAUDE.md guidance for this project.",
209
315
  ].join("\n");
210
316
  };
317
+
318
+ /**
319
+ * @param {string} prompt
320
+ * @param {string} [agentDir]
321
+ * @returns {string}
322
+ */
211
323
  const applyLocalOnlyPrompt = (prompt, agentDir = getAgentDir()) => {
212
324
  const { prompt: stripped, removedPaths } = stripGlobalContext(prompt, getGlobalContextPaths(agentDir));
213
325
  const notice = buildLocalOnlyNotice(
@@ -215,6 +327,8 @@ const applyLocalOnlyPrompt = (prompt, agentDir = getAgentDir()) => {
215
327
  );
216
328
  return notice ? `${stripped}\n\n${notice}` : stripped;
217
329
  };
330
+
331
+ /** @param {ExtensionContext} ctx */
218
332
  const setStatus = (ctx) => {
219
333
  if (!ctx.hasUI) {
220
334
  return;
@@ -222,8 +336,18 @@ const setStatus = (ctx) => {
222
336
  const mode = getMode(ctx.cwd);
223
337
  ctx.ui.setStatus(COMMAND, mode.enabled ? `AGENTS: local-only (${mode.source})` : undefined);
224
338
  };
339
+
340
+ /**
341
+ * @param {ProjectState} state
342
+ * @returns {string}
343
+ */
225
344
  const getProjectTarget = (state) =>
226
345
  state.worktreeRoots.length > 1 ? `${state.projectRoot} and linked worktrees` : state.projectRoot;
346
+
347
+ /**
348
+ * @param {ProjectState} state
349
+ * @returns {string}
350
+ */
227
351
  const getOffNotification = (state) => {
228
352
  const mode = getMode(state);
229
353
  if (!mode.enabled) {
@@ -238,10 +362,20 @@ const getOffNotification = (state) => {
238
362
  return `Repo marker cleared for ${getProjectTarget(state)}, but local-agents-only is still enabled via ${mode.source}.`;
239
363
  };
240
364
 
365
+ /**
366
+ * @param {string} [start]
367
+ * @returns {string}
368
+ */
241
369
  export function findProjectRoot(start = process.cwd()) {
242
370
  return getProjectState(start).projectRoot;
243
371
  }
244
372
 
373
+ /**
374
+ * @param {string | ProjectState} [start]
375
+ * @param {string | undefined} [envValue]
376
+ * @param {string} [configPath]
377
+ * @returns {Mode}
378
+ */
245
379
  export function getMode(start = process.cwd(), envValue = process.env.PI_LOCAL_AGENTS_ONLY, configPath = CONFIG()) {
246
380
  const state = typeof start === "string" ? getProjectState(start) : start;
247
381
  const envToggle = getEnvToggle(envValue);
@@ -261,10 +395,16 @@ export function getMode(start = process.cwd(), envValue = process.env.PI_LOCAL_A
261
395
  return { enabled: false, source: "default" };
262
396
  }
263
397
 
398
+ /**
399
+ * @param {string} prompt
400
+ * @param {string[]} [globalPaths]
401
+ * @returns {string}
402
+ */
264
403
  export function stripGlobalBlocks(prompt, globalPaths = getGlobalContextPaths()) {
265
404
  return stripGlobalContext(prompt, globalPaths).prompt;
266
405
  }
267
406
 
407
+ /** @param {ExtensionAPI} pi */
268
408
  export default function localAgentsOnly(pi) {
269
409
  pi.registerCommand(COMMAND, {
270
410
  description: "Use only repo-local AGENTS prompt context",
@@ -274,7 +414,10 @@ export default function localAgentsOnly(pi) {
274
414
  case "on":
275
415
  writeMarkers(state);
276
416
  setStatus(ctx);
277
- 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
+ );
278
421
  return;
279
422
  case "off":
280
423
  clearMarkers(state);
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "pi-local-agents-only",
3
- "version": "0.1.5",
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", "CHANGELOG.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
  }