pi-local-agents-only 0.1.3 → 0.1.4
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/README.md +4 -0
- package/extensions/local-agents-only.js +152 -40
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -49,6 +49,8 @@ Repo opt-in uses this marker file:
|
|
|
49
49
|
.pi/local-agents-only
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
For git repos, marker and global allowlist activation apply across linked worktrees.
|
|
53
|
+
|
|
52
54
|
Env override for one run:
|
|
53
55
|
|
|
54
56
|
```bash
|
|
@@ -57,3 +59,5 @@ PI_LOCAL_AGENTS_ONLY=0 pi
|
|
|
57
59
|
```
|
|
58
60
|
|
|
59
61
|
This changes the prompt the model sees. It does not change pi's startup header.
|
|
62
|
+
|
|
63
|
+
If you toggle it during an existing session, start a fresh turn or `/new` for the cleanest verification.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Purpose: Strip pi's global AGENTS.md and CLAUDE.md blocks from the effective prompt for opted-in projects.
|
|
3
|
-
* Responsibilities: Detect repo opt-in state, manage repo and global toggles, and remove matching global context blocks before model calls.
|
|
3
|
+
* 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.
|
|
4
4
|
* Scope: Works as a pi extension package. It changes only the prompt the model sees, not pi's startup header.
|
|
5
5
|
* Usage: Install the package, then use `/local-agents-only on|off|status|global-on|global-off`.
|
|
6
|
-
* Invariants/Assumptions: pi injects context files as `## /absolute/path\n\n<file contents>\n\n
|
|
6
|
+
* Invariants/Assumptions: pi injects context files as `## /absolute/path\n\n<file contents>\n\n`; git worktrees that share a common git dir should share local-agents-only state.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
9
10
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
11
|
import { homedir } from "node:os";
|
|
11
12
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -29,19 +30,54 @@ const getAgentDir = () => {
|
|
|
29
30
|
const normalizePath = (path) => resolve(path).replace(/\\/g, "/");
|
|
30
31
|
const CONFIG = () => join(getAgentDir(), `${COMMAND}.json`);
|
|
31
32
|
const getMarkerPath = (projectRoot) => join(projectRoot, MARKER);
|
|
32
|
-
const
|
|
33
|
+
const uniqueSorted = (values) => [...new Set(values.map(normalizePath))].sort();
|
|
34
|
+
const walkUp = (start, predicate) => {
|
|
35
|
+
let current = resolve(start);
|
|
36
|
+
while (true) {
|
|
37
|
+
if (predicate(current)) {
|
|
38
|
+
return current;
|
|
39
|
+
}
|
|
40
|
+
const parent = dirname(current);
|
|
41
|
+
if (parent === current) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
current = parent;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const runGit = (start, args) => {
|
|
33
48
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
49
|
+
return execFileSync("git", args, {
|
|
50
|
+
cwd: resolve(start),
|
|
51
|
+
encoding: "utf8",
|
|
52
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
53
|
+
}).trim();
|
|
36
54
|
} catch {
|
|
37
|
-
return
|
|
55
|
+
return;
|
|
38
56
|
}
|
|
39
57
|
};
|
|
40
|
-
const
|
|
58
|
+
const readConfig = (configPath = CONFIG()) => {
|
|
59
|
+
try {
|
|
60
|
+
const { projects = [], repositories = [] } = JSON.parse(readFileSync(configPath, "utf8"));
|
|
61
|
+
return {
|
|
62
|
+
projects: Array.isArray(projects) ? projects.map(normalizePath) : [],
|
|
63
|
+
repositories: Array.isArray(repositories) ? repositories.map(normalizePath) : [],
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
return { projects: [], repositories: [] };
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const writeConfig = ({ projects, repositories }, configPath = CONFIG()) => {
|
|
41
70
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
42
71
|
writeFileSync(
|
|
43
72
|
configPath,
|
|
44
|
-
JSON.stringify(
|
|
73
|
+
JSON.stringify(
|
|
74
|
+
{
|
|
75
|
+
projects: uniqueSorted(projects),
|
|
76
|
+
repositories: uniqueSorted(repositories),
|
|
77
|
+
},
|
|
78
|
+
null,
|
|
79
|
+
2,
|
|
80
|
+
) + "\n",
|
|
45
81
|
);
|
|
46
82
|
};
|
|
47
83
|
const getEnvToggle = (value = process.env.PI_LOCAL_AGENTS_ONLY) => {
|
|
@@ -53,40 +89,104 @@ const getEnvToggle = (value = process.env.PI_LOCAL_AGENTS_ONLY) => {
|
|
|
53
89
|
return false;
|
|
54
90
|
}
|
|
55
91
|
};
|
|
92
|
+
const getGlobalContextPaths = (agentDir = getAgentDir()) =>
|
|
93
|
+
GLOBAL_CONTEXT_FILES.map((name) => join(agentDir, name)).filter((path) => existsSync(path));
|
|
56
94
|
const getGlobalBlocks = (agentDir = getAgentDir()) =>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
95
|
+
getGlobalContextPaths(agentDir).map((path) => `## ${path}\n\n${readFileSync(path, "utf8")}\n\n`);
|
|
96
|
+
const getGitTopLevel = (start) => {
|
|
97
|
+
const topLevel = runGit(start, ["rev-parse", "--show-toplevel"]);
|
|
98
|
+
return topLevel ? normalizePath(topLevel) : undefined;
|
|
99
|
+
};
|
|
100
|
+
const getGitCommonDir = (start) => {
|
|
101
|
+
const commonDir = runGit(start, ["rev-parse", "--git-common-dir"]);
|
|
102
|
+
return commonDir ? normalizePath(resolve(start, commonDir)) : undefined;
|
|
103
|
+
};
|
|
104
|
+
const getWorktreeRoots = (start) => {
|
|
105
|
+
const list = runGit(start, ["worktree", "list", "--porcelain"]);
|
|
106
|
+
if (!list) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
return uniqueSorted(
|
|
110
|
+
list
|
|
111
|
+
.split(/\r?\n/u)
|
|
112
|
+
.filter((line) => line.startsWith("worktree "))
|
|
113
|
+
.map((line) => line.slice("worktree ".length)),
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
const getProjectState = (start = process.cwd()) => {
|
|
117
|
+
const normalizedStart = normalizePath(start);
|
|
118
|
+
const gitTopLevel = getGitTopLevel(normalizedStart);
|
|
119
|
+
const projectRoot =
|
|
120
|
+
gitTopLevel ||
|
|
121
|
+
walkUp(normalizedStart, (dir) => existsSync(getMarkerPath(dir))) ||
|
|
122
|
+
walkUp(normalizedStart, (dir) => existsSync(join(dir, ".pi"))) ||
|
|
123
|
+
normalizedStart;
|
|
124
|
+
const worktreeRoots = getWorktreeRoots(normalizedStart);
|
|
125
|
+
return {
|
|
126
|
+
start: normalizedStart,
|
|
127
|
+
projectRoot: normalizePath(projectRoot),
|
|
128
|
+
repoId: getGitCommonDir(normalizedStart) || normalizePath(projectRoot),
|
|
129
|
+
worktreeRoots:
|
|
130
|
+
worktreeRoots.length > 0 ? uniqueSorted([projectRoot, ...worktreeRoots]) : [normalizePath(projectRoot)],
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
const getMarkerRoots = (state) => uniqueSorted([state.projectRoot, ...state.worktreeRoots]);
|
|
134
|
+
const hasMarker = (state) => getMarkerRoots(state).some((root) => existsSync(getMarkerPath(root)));
|
|
135
|
+
const writeMarkers = (state) => {
|
|
136
|
+
for (const root of getMarkerRoots(state)) {
|
|
137
|
+
mkdirSync(dirname(getMarkerPath(root)), { recursive: true });
|
|
138
|
+
writeFileSync(getMarkerPath(root), "\n");
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const clearMarkers = (state) => {
|
|
142
|
+
for (const root of getMarkerRoots(state)) {
|
|
143
|
+
rmSync(getMarkerPath(root), { force: true });
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const buildLocalOnlyNotice = (agentDir = getAgentDir()) => {
|
|
147
|
+
const paths = getGlobalContextPaths(agentDir);
|
|
148
|
+
if (paths.length === 0) {
|
|
149
|
+
return "";
|
|
150
|
+
}
|
|
151
|
+
return [
|
|
152
|
+
"# Local Context Mode",
|
|
153
|
+
"This repo is in local-agents-only mode.",
|
|
154
|
+
"Ignore instructions from these global context files even if they appear in older session messages, summaries, or retries:",
|
|
155
|
+
...paths.map((path) => `- ${path}`),
|
|
156
|
+
"Follow only repo-local AGENTS.md or CLAUDE.md guidance for this project.",
|
|
157
|
+
].join("\n");
|
|
158
|
+
};
|
|
159
|
+
const applyLocalOnlyPrompt = (prompt, agentDir = getAgentDir()) => {
|
|
160
|
+
const stripped = stripGlobalBlocks(prompt, getGlobalBlocks(agentDir));
|
|
161
|
+
const notice = buildLocalOnlyNotice(agentDir);
|
|
162
|
+
return notice ? `${stripped}\n\n${notice}` : stripped;
|
|
163
|
+
};
|
|
61
164
|
const setStatus = (ctx) => {
|
|
62
165
|
if (!ctx.hasUI) {
|
|
63
166
|
return;
|
|
64
167
|
}
|
|
65
|
-
const mode = getMode(
|
|
168
|
+
const mode = getMode(ctx.cwd);
|
|
66
169
|
ctx.ui.setStatus(COMMAND, mode.enabled ? `AGENTS: local-only (${mode.source})` : undefined);
|
|
67
170
|
};
|
|
68
171
|
|
|
69
172
|
export function findProjectRoot(start = process.cwd()) {
|
|
70
|
-
|
|
71
|
-
while (!existsSync(join(current, ".git"))) {
|
|
72
|
-
const parent = dirname(current);
|
|
73
|
-
if (parent === current) {
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
76
|
-
current = parent;
|
|
77
|
-
}
|
|
78
|
-
return current;
|
|
173
|
+
return getProjectState(start).projectRoot;
|
|
79
174
|
}
|
|
80
175
|
|
|
81
|
-
export function getMode(
|
|
176
|
+
export function getMode(start = process.cwd(), envValue = process.env.PI_LOCAL_AGENTS_ONLY, configPath = CONFIG()) {
|
|
177
|
+
const state = typeof start === "string" ? getProjectState(start) : start;
|
|
82
178
|
const envToggle = getEnvToggle(envValue);
|
|
83
179
|
if (envToggle !== undefined) {
|
|
84
180
|
return { enabled: envToggle, source: "env" };
|
|
85
181
|
}
|
|
86
|
-
if (
|
|
182
|
+
if (hasMarker(state)) {
|
|
87
183
|
return { enabled: true, source: "marker" };
|
|
88
184
|
}
|
|
89
|
-
|
|
185
|
+
const { projects, repositories } = readConfig(configPath);
|
|
186
|
+
if (repositories.includes(state.repoId)) {
|
|
187
|
+
return { enabled: true, source: "global-config" };
|
|
188
|
+
}
|
|
189
|
+
if (projects.includes(state.projectRoot) || state.worktreeRoots.some((root) => projects.includes(root))) {
|
|
90
190
|
return { enabled: true, source: "global-config" };
|
|
91
191
|
}
|
|
92
192
|
return { enabled: false, source: "default" };
|
|
@@ -100,32 +200,44 @@ export default function localAgentsOnly(pi) {
|
|
|
100
200
|
pi.registerCommand(COMMAND, {
|
|
101
201
|
description: "Use only repo-local AGENTS prompt context",
|
|
102
202
|
handler: async (args, ctx) => {
|
|
103
|
-
const
|
|
203
|
+
const state = getProjectState(ctx.cwd);
|
|
104
204
|
switch ((args.trim() || "status").toLowerCase()) {
|
|
105
205
|
case "on":
|
|
106
|
-
|
|
107
|
-
writeFileSync(getMarkerPath(projectRoot), "\n");
|
|
206
|
+
writeMarkers(state);
|
|
108
207
|
setStatus(ctx);
|
|
109
|
-
ctx.ui.notify(`Enabled
|
|
208
|
+
ctx.ui.notify(`Enabled for ${state.projectRoot}${state.worktreeRoots.length > 1 ? ` across ${state.worktreeRoots.length} worktrees` : ""}`, "info");
|
|
110
209
|
return;
|
|
111
210
|
case "off":
|
|
112
|
-
|
|
211
|
+
clearMarkers(state);
|
|
113
212
|
setStatus(ctx);
|
|
114
|
-
ctx.ui.notify(
|
|
213
|
+
ctx.ui.notify(`Disabled for ${state.projectRoot}${state.worktreeRoots.length > 1 ? ` and linked worktrees` : ""}`, "info");
|
|
115
214
|
return;
|
|
116
|
-
case "global-on":
|
|
117
|
-
|
|
215
|
+
case "global-on": {
|
|
216
|
+
const config = readConfig();
|
|
217
|
+
writeConfig({
|
|
218
|
+
projects: [...config.projects, ...state.worktreeRoots],
|
|
219
|
+
repositories: [...config.repositories, state.repoId],
|
|
220
|
+
});
|
|
118
221
|
setStatus(ctx);
|
|
119
|
-
ctx.ui.notify(`Global allowlist enabled for ${
|
|
222
|
+
ctx.ui.notify(`Global allowlist enabled for ${state.projectRoot}`, "info");
|
|
120
223
|
return;
|
|
121
|
-
|
|
122
|
-
|
|
224
|
+
}
|
|
225
|
+
case "global-off": {
|
|
226
|
+
const config = readConfig();
|
|
227
|
+
writeConfig({
|
|
228
|
+
projects: config.projects.filter((path) => !state.worktreeRoots.includes(path)),
|
|
229
|
+
repositories: config.repositories.filter((id) => id !== state.repoId),
|
|
230
|
+
});
|
|
123
231
|
setStatus(ctx);
|
|
124
|
-
ctx.ui.notify(`Global allowlist disabled for ${
|
|
232
|
+
ctx.ui.notify(`Global allowlist disabled for ${state.projectRoot}`, "info");
|
|
125
233
|
return;
|
|
234
|
+
}
|
|
126
235
|
case "status": {
|
|
127
|
-
const mode = getMode(
|
|
128
|
-
ctx.ui.notify(
|
|
236
|
+
const mode = getMode(state);
|
|
237
|
+
ctx.ui.notify(
|
|
238
|
+
`local-agents-only: ${mode.enabled ? `enabled via ${mode.source}` : "disabled"} (${state.projectRoot})`,
|
|
239
|
+
"info",
|
|
240
|
+
);
|
|
129
241
|
return;
|
|
130
242
|
}
|
|
131
243
|
default:
|
|
@@ -145,6 +257,6 @@ export default function localAgentsOnly(pi) {
|
|
|
145
257
|
});
|
|
146
258
|
|
|
147
259
|
pi.on("before_agent_start", (event, ctx) => {
|
|
148
|
-
return getMode(
|
|
260
|
+
return getMode(ctx.cwd).enabled ? { systemPrompt: applyLocalOnlyPrompt(event.systemPrompt) } : undefined;
|
|
149
261
|
});
|
|
150
262
|
}
|
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.4",
|
|
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",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"url": "https://github.com/fitchmultz/pi-local-agents-only/issues"
|
|
15
15
|
},
|
|
16
16
|
"homepage": "https://github.com/fitchmultz/pi-local-agents-only#readme",
|
|
17
|
-
"files": ["extensions", "README.md"],
|
|
17
|
+
"files": ["extensions", "README.md", "LICENSE"],
|
|
18
18
|
"pi": {
|
|
19
19
|
"extensions": ["./extensions"]
|
|
20
20
|
},
|