@ericrisco/rsc 3.0.9 → 3.0.11
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 +67 -6
- package/manifest.json +10 -2
- package/package.json +1 -1
- package/scripts/doctor.js +15 -1
- package/scripts/install-apply.js +41 -4
- package/scripts/lib/manifest-file.js +10 -1
- package/scripts/lib/onboarding-apply.js +58 -4
- package/scripts/lib/review-findings.js +175 -0
- package/scripts/rsc.js +160 -12
- package/skills/code-review/SKILL.md +2 -0
- package/skills/review/SKILL.md +24 -3
- package/targets/agents.js +181 -19
- package/targets/git-permissions.js +254 -16
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Git permissions: on a harness installed from scratch, the agent may `git commit`, `git push` and
|
|
2
2
|
// `gh pr create` without asking — the three steps that close every lane (branch → commit → push →
|
|
3
|
-
// PR). A force-push still asks wherever the assistant can say so.
|
|
4
|
-
// on a closed default branch is still denied by branch-guard, whatever the
|
|
3
|
+
// PR). A force-push still asks wherever the assistant can say so. In Claude Code the guards are
|
|
4
|
+
// untouched: a commit on a closed default branch is still denied by branch-guard, whatever the
|
|
5
|
+
// permission says. No other assistant runs rsc's guards, and `on|off|status` says so (#298).
|
|
5
6
|
//
|
|
6
7
|
// A PROJECT decision, recorded as `gitPermissions` in `.rsc.json`: true on a brand-new project, absent
|
|
7
8
|
// on one adopted before this existed (nothing changes there until `rsc git-permissions on`), false
|
|
@@ -25,6 +26,9 @@ const OPENCODE_BASH = [
|
|
|
25
26
|
['git commit', 'allow'], ['git commit *', 'allow'], ['git push', 'allow'], ['git push *', 'allow'], ['gh pr create *', 'allow'],
|
|
26
27
|
['git push --force*', 'ask'], ['git push -f*', 'ask'], ['git push * --force*', 'ask'], ['git push * -f*', 'ask'],
|
|
27
28
|
];
|
|
29
|
+
// OpenCode 2 reads an ordered top-level `permissions` array (bash → `shell`); same rules, same order.
|
|
30
|
+
const OPENCODE_V2 = OPENCODE_BASH.map(([resource, effect]) => ({ action: 'shell', resource, effect }));
|
|
31
|
+
const FORCE_PATTERNS = OPENCODE_BASH.filter(([, a]) => a === 'ask').map(([p]) => p);
|
|
28
32
|
const CODEX_RULES = `# rsc-git-permissions:managed — written by rsc; \`rsc git-permissions off\` removes it.
|
|
29
33
|
# Commit, push and open a PR without asking; a force-push still asks (the strictest match wins).
|
|
30
34
|
prefix_rule(pattern = ["git", "commit"], decision = "allow", justification = "rsc: close the lane — commit")
|
|
@@ -66,6 +70,28 @@ function readJson(path) {
|
|
|
66
70
|
} catch { return null; }
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
/** JSONC → JSON, for READING only: drops comments and trailing commas outside strings. */
|
|
74
|
+
export function parseJsonc(text) {
|
|
75
|
+
let out = '';
|
|
76
|
+
for (let i = 0; i < text.length; i++) {
|
|
77
|
+
const ch = text[i];
|
|
78
|
+
if (ch === '"') {
|
|
79
|
+
let j = i + 1;
|
|
80
|
+
while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
|
|
81
|
+
out += text.slice(i, j + 1); i = j;
|
|
82
|
+
} else if (ch === '/' && text[i + 1] === '/') {
|
|
83
|
+
while (i < text.length && text[i] !== '\n') i++;
|
|
84
|
+
out += '\n';
|
|
85
|
+
} else if (ch === '/' && text[i + 1] === '*') {
|
|
86
|
+
const end = text.indexOf('*/', i + 2);
|
|
87
|
+
i = end < 0 ? text.length : end + 1;
|
|
88
|
+
} else out += ch;
|
|
89
|
+
}
|
|
90
|
+
// Trailing commas: only outside strings matter, and strings were copied verbatim — so strip them
|
|
91
|
+
// with a pass that also skips strings.
|
|
92
|
+
return JSON.parse(out.replace(/("(?:[^"\\]|\\.)*")|,(\s*[}\]])/g, (m, str, close) => str ?? close));
|
|
93
|
+
}
|
|
94
|
+
|
|
69
95
|
const writeJson = (path, value) => { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); };
|
|
70
96
|
const addAll = (list, items) => [...(Array.isArray(list) ? list : []), ...items.filter((i) => !(list || []).includes(i))];
|
|
71
97
|
const dropAll = (list, items) => (Array.isArray(list) ? list.filter((i) => !items.includes(i)) : list);
|
|
@@ -74,10 +100,125 @@ export function gitPermissionsPath(target, cwd = process.cwd()) {
|
|
|
74
100
|
return FILES[target] ? join(cwd, ...FILES[target].split('/')) : null;
|
|
75
101
|
}
|
|
76
102
|
|
|
77
|
-
|
|
103
|
+
// ── OpenCode: V1 (`permission.bash` map) or V2 (`permissions` array) ────────────────────────────
|
|
104
|
+
// OpenCode 2 still reads the V1 map; OpenCode 1.x does not know `permissions`. So V2 is written only
|
|
105
|
+
// when OpenCode 2 is seen on this machine (or the file is already V2); otherwise the V1 map, which
|
|
106
|
+
// both read. `RSC_OPENCODE_MAJOR` pins the version (tests, or a machine where the probe cannot run).
|
|
107
|
+
|
|
108
|
+
/** Installed OpenCode major version, or null when it cannot be told. */
|
|
109
|
+
export function opencodeMajor() {
|
|
110
|
+
const pinned = process.env.RSC_OPENCODE_MAJOR;
|
|
111
|
+
if (pinned !== undefined) return /^\d+$/.test(pinned.trim()) ? Number(pinned.trim()) : null;
|
|
112
|
+
try {
|
|
113
|
+
// `opencode` is an npm .cmd shim on Windows, which only a shell resolves.
|
|
114
|
+
const win = process.platform === 'win32';
|
|
115
|
+
const out = execFileSync(win ? 'opencode --version' : 'opencode', win ? [] : ['--version'], {
|
|
116
|
+
windowsHide: true, timeout: 3000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: win,
|
|
117
|
+
});
|
|
118
|
+
const m = /(\d+)\.\d+/.exec(out);
|
|
119
|
+
return m ? Number(m[1]) : null;
|
|
120
|
+
} catch { return null; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const sameRule = (a, b) => a && typeof a === 'object' && a.action === b.action && a.resource === b.resource && a.effect === b.effect;
|
|
124
|
+
const isRscV2 = (rule) => OPENCODE_V2.some((r) => sameRule(rule, r));
|
|
125
|
+
const hasV1 = (config) => OPENCODE_BASH.every(([p, a]) => config?.permission?.bash?.[p] === a);
|
|
126
|
+
const hasV2 = (config) => Array.isArray(config?.permissions) && OPENCODE_V2.every((r) => config.permissions.some((x) => sameRule(x, r)));
|
|
127
|
+
const jsoncOnly = (cwd) => !existsSync(join(cwd, 'opencode.json')) && existsSync(join(cwd, 'opencode.jsonc'));
|
|
128
|
+
|
|
129
|
+
function opencodeFormat(config) {
|
|
130
|
+
const major = opencodeMajor();
|
|
131
|
+
if (major !== null) return major >= 2 ? 'v2' : 'v1';
|
|
132
|
+
return Array.isArray(config?.permissions) ? 'v2' : 'v1';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function dropV1(config) {
|
|
136
|
+
const bash = config.permission?.bash;
|
|
137
|
+
if (!bash || typeof bash !== 'object') return;
|
|
138
|
+
for (const [pattern, action] of OPENCODE_BASH) if (bash[pattern] === action) delete bash[pattern];
|
|
139
|
+
if (!Object.keys(bash).length) delete config.permission.bash;
|
|
140
|
+
if (!Object.keys(config.permission).length) delete config.permission;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function dropV2(config) {
|
|
144
|
+
if (!Array.isArray(config.permissions)) return;
|
|
145
|
+
config.permissions = config.permissions.filter((r) => !isRscV2(r));
|
|
146
|
+
if (!config.permissions.length) delete config.permissions;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Where rsc's V2 block goes. The last matching rule wins, so a rule placed AFTER rsc's beats it. rsc
|
|
150
|
+
// must never loosen what the person decided, so its block goes BEFORE their rules: any rule of theirs
|
|
151
|
+
// that matches the same command later (a `deny` on `git push *`, an `ask` on `git *`) still wins.
|
|
152
|
+
// The one exception is a leading catch-all baseline (`resource: "*"` with `allow` or `ask`, the usual
|
|
153
|
+
// "ask for everything" default): rsc goes after it, or that default would make rsc's allows dead —
|
|
154
|
+
// what the V1 map has always done too. A catch-all `deny` is never stepped over.
|
|
155
|
+
function v2InsertAt(list) {
|
|
156
|
+
let i = 0;
|
|
157
|
+
while (i < list.length && list[i]?.resource === '*' && ['*', 'shell'].includes(list[i]?.action) && list[i]?.effect !== 'deny') i++;
|
|
158
|
+
return i;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function wireOpencode(config, format) {
|
|
162
|
+
if (format === 'v2') {
|
|
163
|
+
if (config.permissions !== undefined && !Array.isArray(config.permissions)) return false;
|
|
164
|
+
dropV1(config); // migrating: rsc's old V1 entries go, the person's stay
|
|
165
|
+
const list = (config.permissions || []).filter((r) => !isRscV2(r));
|
|
166
|
+
const at = v2InsertAt(list);
|
|
167
|
+
config.permissions = [...list.slice(0, at), ...OPENCODE_V2.map((r) => ({ ...r })), ...list.slice(at)];
|
|
168
|
+
} else {
|
|
169
|
+
if (config.permission !== undefined && (typeof config.permission !== 'object' || config.permission === null)) return false;
|
|
170
|
+
config.permission ||= {};
|
|
171
|
+
if (typeof config.permission.bash === 'string') return false; // a blanket rule is the person's call
|
|
172
|
+
dropV2(config); // back on 1.x (or unknown on a V1 file): one block, the one this OpenCode reads
|
|
173
|
+
const bash = config.permission.bash && typeof config.permission.bash === 'object' ? config.permission.bash : {};
|
|
174
|
+
// Re-inserted so the asks stay after the allows (OpenCode: the last matching rule wins).
|
|
175
|
+
for (const [pattern] of OPENCODE_BASH) delete bash[pattern];
|
|
176
|
+
for (const [pattern, action] of OPENCODE_BASH) bash[pattern] = action;
|
|
177
|
+
config.permission.bash = bash;
|
|
178
|
+
}
|
|
179
|
+
config.$schema ||= 'https://opencode.ai/config.json';
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// One rule per line: short enough to read in a terminal, valid JSON to paste.
|
|
184
|
+
function snippet(format, pairs) {
|
|
185
|
+
if (format === 'v2') {
|
|
186
|
+
const rows = pairs.map(([resource, effect]) => ` ${JSON.stringify({ action: 'shell', resource, effect })}`);
|
|
187
|
+
return `"permissions": [\n${rows.join(',\n')}\n]`;
|
|
188
|
+
}
|
|
189
|
+
const rows = pairs.map(([p, a]) => ` ${JSON.stringify(p)}: ${JSON.stringify(a)}`);
|
|
190
|
+
return `"permission": {\n "bash": {\n${rows.join(',\n')}\n }\n}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The exact rules rsc would write, to paste into an OpenCode config. */
|
|
194
|
+
export const opencodeRulesSnippet = (format) => snippet(format, OPENCODE_BASH);
|
|
195
|
+
|
|
196
|
+
/** A stricter policy only OpenCode itself can enforce: refuse every force-push (`ask` also works). */
|
|
197
|
+
export const opencodeForcePushSnippet = (format) => snippet(format, FORCE_PATTERNS.map((p) => [p, 'deny']));
|
|
198
|
+
|
|
199
|
+
function readJsonc(path) {
|
|
200
|
+
try {
|
|
201
|
+
const value = parseJsonc(readFileSync(path, 'utf8'));
|
|
202
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
203
|
+
} catch { return null; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Add rsc's entries. `{ mode: 'wired' | 'manual' | 'unsupported' | 'config-invalid', path }`; OpenCode
|
|
208
|
+
* adds `format` ('v1' | 'v2'), and on 'manual' (an opencode.jsonc rsc will not rewrite) the `snippet`.
|
|
209
|
+
*/
|
|
78
210
|
export function wireGitPermissions(target, cwd = process.cwd()) {
|
|
79
211
|
const path = gitPermissionsPath(target, cwd);
|
|
80
212
|
if (!path) return { mode: 'unsupported', path: null };
|
|
213
|
+
if (target === 'opencode' && jsoncOnly(cwd)) {
|
|
214
|
+
// Rewriting a JSONC file would drop the person's comments: read it, never write it.
|
|
215
|
+
const jsonc = join(cwd, 'opencode.jsonc');
|
|
216
|
+
const config = readJsonc(jsonc);
|
|
217
|
+
if (config && hasV2(config)) return { mode: 'wired', path: jsonc, format: 'v2' };
|
|
218
|
+
if (config && hasV1(config)) return { mode: 'wired', path: jsonc, format: 'v1' };
|
|
219
|
+
const format = opencodeFormat(config);
|
|
220
|
+
return { mode: 'manual', path: jsonc, format, snippet: opencodeRulesSnippet(format) };
|
|
221
|
+
}
|
|
81
222
|
if (target === 'codex') {
|
|
82
223
|
mkdirSync(dirname(path), { recursive: true });
|
|
83
224
|
writeFileSync(path, CODEX_RULES);
|
|
@@ -95,14 +236,11 @@ export function wireGitPermissions(target, cwd = process.cwd()) {
|
|
|
95
236
|
config.tools ||= {};
|
|
96
237
|
config.tools.allowed = addAll(config.tools.allowed, GEMINI_ALLOW);
|
|
97
238
|
} else if (target === 'opencode') {
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
for (const [pattern, action] of OPENCODE_BASH) bash[pattern] = action;
|
|
104
|
-
config.permission.bash = bash;
|
|
105
|
-
config.$schema ||= 'https://opencode.ai/config.json';
|
|
239
|
+
const format = opencodeFormat(config);
|
|
240
|
+
if (!wireOpencode(config, format)) return { mode: 'config-invalid', path, format };
|
|
241
|
+
writeJson(path, config);
|
|
242
|
+
if (!existed) excludeIfNew(cwd, path);
|
|
243
|
+
return { mode: 'wired', path, format };
|
|
106
244
|
}
|
|
107
245
|
writeJson(path, config);
|
|
108
246
|
if (!existed && target !== 'claude') excludeIfNew(cwd, path);
|
|
@@ -129,10 +267,11 @@ export function unwireGitPermissions(target, cwd = process.cwd()) {
|
|
|
129
267
|
config.tools.allowed = dropAll(config.tools.allowed, GEMINI_ALLOW);
|
|
130
268
|
if (Array.isArray(config.tools.allowed) && !config.tools.allowed.length) delete config.tools.allowed;
|
|
131
269
|
if (!Object.keys(config.tools).length) delete config.tools;
|
|
132
|
-
} else if (target === 'opencode'
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
270
|
+
} else if (target === 'opencode') {
|
|
271
|
+
const before = JSON.stringify(config);
|
|
272
|
+
dropV1(config); // both formats: machines on OpenCode 1.x and 2 may share this file
|
|
273
|
+
dropV2(config);
|
|
274
|
+
if (JSON.stringify(config) === before) return [];
|
|
136
275
|
} else return [];
|
|
137
276
|
writeJson(path, config);
|
|
138
277
|
return [path];
|
|
@@ -146,6 +285,105 @@ export function gitPermissionsWired(target, cwd = process.cwd()) {
|
|
|
146
285
|
const config = readJson(path) || {};
|
|
147
286
|
if (target === 'claude') return CLAUDE_ALLOW.every((r) => config.permissions?.allow?.includes(r));
|
|
148
287
|
if (target === 'gemini') return GEMINI_ALLOW.every((r) => config.tools?.allowed?.includes(r));
|
|
149
|
-
if (target === 'opencode') return
|
|
288
|
+
if (target === 'opencode') return gitPermissionsState(target, cwd).wired;
|
|
150
289
|
return false;
|
|
151
290
|
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* `{ wired, format, path }` for `status`. format: Claude/Codex/Gemini → 'native'; OpenCode → 'v1' or
|
|
294
|
+
* 'v2' (where rsc's rules are, or where they would go) or 'manual' (opencode.jsonc, rules not pasted).
|
|
295
|
+
*/
|
|
296
|
+
export function gitPermissionsState(target, cwd = process.cwd()) {
|
|
297
|
+
const path = gitPermissionsPath(target, cwd);
|
|
298
|
+
if (!path) return { wired: false, format: null, path: null };
|
|
299
|
+
if (target !== 'opencode') return { wired: gitPermissionsWired(target, cwd), format: 'native', path };
|
|
300
|
+
if (jsoncOnly(cwd)) {
|
|
301
|
+
const jsonc = join(cwd, 'opencode.jsonc');
|
|
302
|
+
const config = readJsonc(jsonc);
|
|
303
|
+
if (config && hasV2(config)) return { wired: true, format: 'v2', path: jsonc };
|
|
304
|
+
if (config && hasV1(config)) return { wired: true, format: 'v1', path: jsonc };
|
|
305
|
+
return { wired: false, format: 'manual', path: jsonc };
|
|
306
|
+
}
|
|
307
|
+
const config = (existsSync(path) && readJson(path)) || {};
|
|
308
|
+
if (hasV2(config)) return { wired: true, format: 'v2', path };
|
|
309
|
+
if (hasV1(config)) return { wired: true, format: 'v1', path };
|
|
310
|
+
return { wired: false, format: opencodeFormat(config), path };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── What `rsc git-permissions on|off|status` says, per assistant (#298) ─────────────────────────
|
|
314
|
+
// Three things, always: what rsc controls there, what stops when it is off, and what is left to the
|
|
315
|
+
// assistant itself. The guards are named only where they exist: rsc's PreToolUse guards
|
|
316
|
+
// (branch-guard, danger-guard) run in Claude Code alone; no other assistant runs them.
|
|
317
|
+
|
|
318
|
+
const LABEL = { claude: 'Claude Code', codex: 'Codex', gemini: 'Gemini CLI', opencode: 'OpenCode', cursor: 'Cursor', deepseek: 'DeepSeek Harness' };
|
|
319
|
+
const RULES = 'allow git commit, git push and gh pr create without asking, and ask before a force-push';
|
|
320
|
+
|
|
321
|
+
const WHEN_OFF = {
|
|
322
|
+
claude: 'rsc removes its rules; Claude Code asks again for commit, push and PR, unless your own permissions allow them.',
|
|
323
|
+
codex: 'rsc deletes its rules file; Codex applies its own approval policy and any rules of yours in .codex/rules.',
|
|
324
|
+
gemini: 'rsc removes its entries; Gemini CLI asks before those commands unless your own tools.allowed lists them.',
|
|
325
|
+
opencode: 'rsc removes its rules; only your own OpenCode rules apply. With no rule matching, OpenCode 2 asks and OpenCode 1.x runs the command.',
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const indent = (text, pad = ' ') => text.split('\n').map((l) => pad + l).join('\n');
|
|
329
|
+
|
|
330
|
+
function claudeGuards(cwd) {
|
|
331
|
+
const rsc = join(cwd, '.rsc');
|
|
332
|
+
const out = [];
|
|
333
|
+
if (existsSync(join(rsc, 'branch-guard.mjs'))) out.push('branch-guard (refuses a commit on a closed main)');
|
|
334
|
+
if (existsSync(join(rsc, 'danger-guard.mjs')) && !existsSync(join(rsc, '.no-danger-guard'))) out.push('danger-guard (stops a force-push, reset --hard and similar)');
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The OpenCode format the snippets should use: where rsc's rules are, or where they would go.
|
|
339
|
+
function opencodeSnippetFormat(state) {
|
|
340
|
+
return state.format === 'manual' ? opencodeFormat(readJsonc(state.path)) : state.format;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function stateLine(target, cwd, s) {
|
|
344
|
+
const where = s.path ? rel(cwd, s.path) : '';
|
|
345
|
+
if (s.format === 'manual') return ` Now: not written. ${where} has comments rsc will not rewrite; paste these rules into it:\n${indent(opencodeRulesSnippet(opencodeSnippetFormat(s)))}`;
|
|
346
|
+
const fmt = target === 'opencode' ? ` (${s.format === 'v2' ? 'OpenCode 2 `permissions`' : 'OpenCode 1.x `permission.bash`, read by 2 too'})` : '';
|
|
347
|
+
return ` Now: ${s.wired ? 'on' : 'off'} in ${where}${fmt}.`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function block(target, cwd) {
|
|
351
|
+
const label = LABEL[target] || target;
|
|
352
|
+
const path = gitPermissionsPath(target, cwd);
|
|
353
|
+
if (!path) {
|
|
354
|
+
return `${label}\n rsc controls: nothing here. ${target === 'cursor'
|
|
355
|
+
? 'Its CLI matches only the first word, so allowing a push would allow every git command.'
|
|
356
|
+
: 'It has no per-command allow list.'}\n ${label}'s job: every git permission.`;
|
|
357
|
+
}
|
|
358
|
+
const state = gitPermissionsState(target, cwd);
|
|
359
|
+
const lines = [label, stateLine(target, cwd, state), ` rsc controls: rules in ${rel(cwd, state.path)} that ${RULES}.`];
|
|
360
|
+
if (target === 'claude') {
|
|
361
|
+
const guards = claudeGuards(cwd);
|
|
362
|
+
lines.push(guards.length
|
|
363
|
+
? ` Also rsc, whatever this switch says: ${guards.join('; ')}.`
|
|
364
|
+
: ' rsc\'s guards (branch-guard, danger-guard) are not installed in this project.');
|
|
365
|
+
}
|
|
366
|
+
lines.push(` When off: ${state.path.endsWith('.jsonc')
|
|
367
|
+
? 'rsc never edits opencode.jsonc, so remove the pasted rules yourself; then only your own OpenCode rules apply.'
|
|
368
|
+
: WHEN_OFF[target]}`);
|
|
369
|
+
if (target === 'claude') lines.push(` ${label}'s job: anything its own permissions decide beyond rsc's rules and guards.`);
|
|
370
|
+
else {
|
|
371
|
+
lines.push(` ${label}'s job: there is no rsc guard in ${label}, so nothing from rsc stops a commit on main or a force-push there. A stricter policy (refuse a force-push, a human approval) goes in ${label}'s own configuration.`);
|
|
372
|
+
if (target === 'opencode') {
|
|
373
|
+
const fmt = opencodeSnippetFormat(state);
|
|
374
|
+
lines.push(` To refuse every force-push in OpenCode ${fmt === 'v2' ? '2' : '1.x'} (use "ask" for a human approval), add at the END of ${fmt === 'v2' ? '`permissions`' : '`permission.bash`'} (the last matching rule wins):`);
|
|
375
|
+
lines.push(indent(opencodeForcePushSnippet(fmt)));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return lines.join('\n');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The human explanation for `on`, `off` and `status`, one block per assistant in `targets`. */
|
|
382
|
+
export function explainGitPermissions({ targets, cwd = process.cwd(), mode = 'status', declared } = {}) {
|
|
383
|
+
const head = {
|
|
384
|
+
on: 'rsc git-permissions on. Saved in .rsc.json: commit it so the team gets the same decision.',
|
|
385
|
+
off: 'rsc git-permissions off. Saved in .rsc.json: commit it so the team gets the same decision.',
|
|
386
|
+
status: `rsc git-permissions: ${declared === undefined ? 'undecided (this project was adopted before; nothing is changed until `rsc git-permissions on`)' : declared ? 'on' : 'off'}.`,
|
|
387
|
+
}[mode];
|
|
388
|
+
return [head, ...targets.map((t) => block(t, cwd))].join('\n\n');
|
|
389
|
+
}
|