convene-cli 1.1.1 → 1.3.0
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/dist/api.js +23 -0
- package/dist/cache.js +83 -1
- package/dist/catalog/catalog.generated.js +860 -0
- package/dist/catalog/index.js +26 -0
- package/dist/catalog/manifest.js +71 -0
- package/dist/catalog/materialize.js +516 -0
- package/dist/catalog/prompt.js +89 -0
- package/dist/catalog/report.js +45 -0
- package/dist/catalog/select.js +86 -0
- package/dist/catalog/types.js +14 -0
- package/dist/commands/auth.js +44 -0
- package/dist/commands/fetch.js +50 -0
- package/dist/commands/inbox.js +15 -0
- package/dist/commands/init.js +182 -1
- package/dist/commands/offboard.js +526 -0
- package/dist/commands/override.js +65 -0
- package/dist/commands/practice-guard.js +291 -0
- package/dist/commands/setup.js +20 -3
- package/dist/commands/update.js +249 -0
- package/dist/config.js +19 -1
- package/dist/git.js +73 -0
- package/dist/githook.js +37 -0
- package/dist/hook.js +56 -0
- package/dist/index.js +60 -4
- package/dist/protocol.js +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.offboard = offboard;
|
|
7
|
+
/**
|
|
8
|
+
* `convene off-board` — the exact inverse of `convene init`. Cleanly removes this
|
|
9
|
+
* repo from Convene in ONE isolated commit, instead of the ~14-file manual surgery
|
|
10
|
+
* the VAcontractorCo cleanup needed.
|
|
11
|
+
*
|
|
12
|
+
* Principles (mirrors init's discipline):
|
|
13
|
+
* - Surgical: strip only the convene-managed block from CLAUDE.md/AGENTS.md and the
|
|
14
|
+
* convene server/hook entries from shared config files — never clobber a user's
|
|
15
|
+
* own content. Delete a file only when it was convene's alone (empty after strip,
|
|
16
|
+
* or byte-identical to what init wrote).
|
|
17
|
+
* - Isolated: stage ONLY the touched convene paths into one commit, never `git add -A`.
|
|
18
|
+
* - Identity-preserving: the machine identity in ~/.convene/config.json is shared
|
|
19
|
+
* across repos and is NEVER removed here.
|
|
20
|
+
* - Idempotent: safe to re-run; a repo with no project.json is a clean no-op.
|
|
21
|
+
*/
|
|
22
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
23
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
24
|
+
const config_1 = require("../config");
|
|
25
|
+
const git_1 = require("../git");
|
|
26
|
+
const api_1 = require("../api");
|
|
27
|
+
const hook_1 = require("../hook");
|
|
28
|
+
const githook_1 = require("../githook");
|
|
29
|
+
const init_1 = require("./init");
|
|
30
|
+
const materialize_1 = require("../catalog/materialize");
|
|
31
|
+
const catalog_1 = require("../catalog");
|
|
32
|
+
const protocol_1 = require("../protocol");
|
|
33
|
+
const ctx_1 = require("../ctx");
|
|
34
|
+
/**
|
|
35
|
+
* Every permissions.deny entry the catalog's settingsJson artifacts contribute —
|
|
36
|
+
* the EXACT set materialize.ts can write into .claude/settings.json at hook-hard.
|
|
37
|
+
* Off-board subtracts exactly these (and only these), so a teammate's own deny rule
|
|
38
|
+
* is never removed. Computed from CATALOG so it tracks the catalog automatically.
|
|
39
|
+
*/
|
|
40
|
+
function catalogManagedDenyEntries() {
|
|
41
|
+
const out = new Set();
|
|
42
|
+
for (const p of catalog_1.CATALOG.practices) {
|
|
43
|
+
for (const a of p.artifacts) {
|
|
44
|
+
if (a.kind === 'settingsJson') {
|
|
45
|
+
for (const d of (0, materialize_1.denyEntriesOf)(a))
|
|
46
|
+
out.add(d);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Every .gitignore line the catalog's gitignore artifacts contribute — the set
|
|
54
|
+
* materialize.ts appends at any hook level. Off-board removes exactly these.
|
|
55
|
+
*/
|
|
56
|
+
function catalogManagedGitignoreLines() {
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const p of catalog_1.CATALOG.practices) {
|
|
59
|
+
for (const a of p.artifacts) {
|
|
60
|
+
if (a.kind === 'gitignore')
|
|
61
|
+
out.push(...a.lines);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
const log = (m) => process.stdout.write(m + '\n');
|
|
67
|
+
const abs = (top, rel) => node_path_1.default.join(top, rel);
|
|
68
|
+
const jsonOut = (obj) => JSON.stringify(obj, null, 2) + '\n';
|
|
69
|
+
/** Delete a file/dir that is unambiguously convene's (e.g. `.convene/`, dedicated rule files). */
|
|
70
|
+
function delPath(top, rel, touched, dryRun) {
|
|
71
|
+
const p = abs(top, rel);
|
|
72
|
+
if (!node_fs_1.default.existsSync(p))
|
|
73
|
+
return;
|
|
74
|
+
touched.push({ rel, action: 'delete' });
|
|
75
|
+
if (!dryRun)
|
|
76
|
+
node_fs_1.default.rmSync(p, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A CONVENE_PROTOCOL.md that init generated, recognized by its stable opening rather
|
|
80
|
+
* than byte-equality — byte-equality breaks across CLI versions AND across the
|
|
81
|
+
* convene.stateful.world → dev.convene.live baseURL migration (the URL is baked into
|
|
82
|
+
* the doc). A doc whose intro was hand-replaced (e.g. the Convene repo's own) is preserved.
|
|
83
|
+
*/
|
|
84
|
+
function isGeneratedProtocolDoc(content) {
|
|
85
|
+
return (content.startsWith('# Convene Protocol\n') &&
|
|
86
|
+
content.includes('This repository participates in **Convene**, a hosted, multi-tenant'));
|
|
87
|
+
}
|
|
88
|
+
function handleProtocolDoc(top, touched, dryRun) {
|
|
89
|
+
const rel = 'CONVENE_PROTOCOL.md';
|
|
90
|
+
const p = abs(top, rel);
|
|
91
|
+
if (!node_fs_1.default.existsSync(p))
|
|
92
|
+
return;
|
|
93
|
+
if (!isGeneratedProtocolDoc(node_fs_1.default.readFileSync(p, 'utf8'))) {
|
|
94
|
+
log(`· ${rel} (left as-is — hand-authored; remove manually if you meant to)`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
touched.push({ rel, action: 'delete' });
|
|
98
|
+
if (!dryRun)
|
|
99
|
+
node_fs_1.default.rmSync(p, { force: true });
|
|
100
|
+
}
|
|
101
|
+
/** Delete a file ONLY if it is byte-identical to what init wrote (else it was hand-edited — leave it). */
|
|
102
|
+
function delIfOurs(top, rel, expected, touched, dryRun) {
|
|
103
|
+
const p = abs(top, rel);
|
|
104
|
+
if (!node_fs_1.default.existsSync(p))
|
|
105
|
+
return;
|
|
106
|
+
let content;
|
|
107
|
+
try {
|
|
108
|
+
content = node_fs_1.default.readFileSync(p, 'utf8');
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (content !== expected) {
|
|
114
|
+
log(`· ${rel} (left as-is — edited since onboarding; remove manually if you meant to)`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
touched.push({ rel, action: 'delete' });
|
|
118
|
+
if (!dryRun)
|
|
119
|
+
node_fs_1.default.rmSync(p, { force: true });
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Strip BOTH convene-managed regions from a Markdown doc — the main coordination
|
|
123
|
+
* block AND the Phase-2 best-practices ref region (`<!-- convene:practices:begin -->`
|
|
124
|
+
* …) — in ONE read/write so the file is reported (and staged) at most once. Deletes
|
|
125
|
+
* the file if nothing else remains. The dedicated `.convene/best-practices.md` +
|
|
126
|
+
* `.convene/practices/` docs are removed wholesale by `delPath('.convene')`.
|
|
127
|
+
*/
|
|
128
|
+
function stripMarker(top, rel, touched, dryRun) {
|
|
129
|
+
const p = abs(top, rel);
|
|
130
|
+
if (!node_fs_1.default.existsSync(p))
|
|
131
|
+
return;
|
|
132
|
+
const orig = node_fs_1.default.readFileSync(p, 'utf8');
|
|
133
|
+
const block = (0, init_1.removeMarkerBlock)(orig);
|
|
134
|
+
const ref = (0, materialize_1.removeRefRegion)(block.content);
|
|
135
|
+
if (!block.removed && !ref.removed)
|
|
136
|
+
return;
|
|
137
|
+
applyStripOrDelete(top, rel, ref.content, touched, dryRun);
|
|
138
|
+
}
|
|
139
|
+
/** Strip the convene block from a Codex config.toml; delete if nothing else remains. */
|
|
140
|
+
function stripToml(top, rel, touched, dryRun) {
|
|
141
|
+
const p = abs(top, rel);
|
|
142
|
+
if (!node_fs_1.default.existsSync(p))
|
|
143
|
+
return;
|
|
144
|
+
const { content, removed } = (0, init_1.removeTomlBlock)(node_fs_1.default.readFileSync(p, 'utf8'));
|
|
145
|
+
if (!removed)
|
|
146
|
+
return;
|
|
147
|
+
applyStripOrDelete(top, rel, content, touched, dryRun);
|
|
148
|
+
}
|
|
149
|
+
function applyStripOrDelete(top, rel, content, touched, dryRun) {
|
|
150
|
+
const p = abs(top, rel);
|
|
151
|
+
if (content.trim().length === 0) {
|
|
152
|
+
touched.push({ rel, action: 'delete' });
|
|
153
|
+
if (!dryRun)
|
|
154
|
+
node_fs_1.default.rmSync(p, { force: true });
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
touched.push({ rel, action: 'strip' });
|
|
158
|
+
if (!dryRun)
|
|
159
|
+
node_fs_1.default.writeFileSync(p, content);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Remove the `convene` MCP server from a JSON config (Cursor `mcpServers`, VS Code `servers`). */
|
|
163
|
+
function stripJsonServer(top, rel, topKey, touched, dryRun) {
|
|
164
|
+
const p = abs(top, rel);
|
|
165
|
+
if (!node_fs_1.default.existsSync(p))
|
|
166
|
+
return;
|
|
167
|
+
let obj;
|
|
168
|
+
try {
|
|
169
|
+
obj = JSON.parse(node_fs_1.default.readFileSync(p, 'utf8')) || {};
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
log(`· ${rel} (left as-is — unparseable JSON)`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!obj[topKey] || !obj[topKey].convene)
|
|
176
|
+
return;
|
|
177
|
+
delete obj[topKey].convene;
|
|
178
|
+
if (Object.keys(obj[topKey]).length === 0)
|
|
179
|
+
delete obj[topKey];
|
|
180
|
+
finalizeJson(top, rel, obj, touched, dryRun);
|
|
181
|
+
}
|
|
182
|
+
/** Gemini settings carry BOTH the convene MCP server and the AGENTS.md context entry. */
|
|
183
|
+
function stripGemini(top, rel, touched, dryRun) {
|
|
184
|
+
const p = abs(top, rel);
|
|
185
|
+
if (!node_fs_1.default.existsSync(p))
|
|
186
|
+
return;
|
|
187
|
+
let obj;
|
|
188
|
+
try {
|
|
189
|
+
obj = JSON.parse(node_fs_1.default.readFileSync(p, 'utf8')) || {};
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
log(`· ${rel} (left as-is — unparseable JSON)`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
let changed = false;
|
|
196
|
+
if (obj.mcpServers && obj.mcpServers.convene) {
|
|
197
|
+
delete obj.mcpServers.convene;
|
|
198
|
+
if (Object.keys(obj.mcpServers).length === 0)
|
|
199
|
+
delete obj.mcpServers;
|
|
200
|
+
changed = true;
|
|
201
|
+
}
|
|
202
|
+
if (obj.context && obj.context.fileName) {
|
|
203
|
+
const names = Array.isArray(obj.context.fileName) ? obj.context.fileName : [obj.context.fileName];
|
|
204
|
+
const filtered = names.filter((n) => n !== 'AGENTS.md');
|
|
205
|
+
if (filtered.length !== names.length) {
|
|
206
|
+
changed = true;
|
|
207
|
+
if (filtered.length === 0) {
|
|
208
|
+
delete obj.context.fileName;
|
|
209
|
+
if (Object.keys(obj.context).length === 0)
|
|
210
|
+
delete obj.context;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
obj.context.fileName = filtered;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!changed)
|
|
218
|
+
return;
|
|
219
|
+
finalizeJson(top, rel, obj, touched, dryRun);
|
|
220
|
+
}
|
|
221
|
+
function finalizeJson(top, rel, obj, touched, dryRun) {
|
|
222
|
+
const p = abs(top, rel);
|
|
223
|
+
if (Object.keys(obj).length === 0) {
|
|
224
|
+
touched.push({ rel, action: 'delete' });
|
|
225
|
+
if (!dryRun)
|
|
226
|
+
node_fs_1.default.rmSync(p, { force: true });
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
touched.push({ rel, action: 'strip' });
|
|
230
|
+
if (!dryRun)
|
|
231
|
+
node_fs_1.default.writeFileSync(p, jsonOut(obj));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Strip convene-managed content from the committed .claude/settings.json — both the
|
|
236
|
+
* convene-authored hooks (withoutConveneHooks, which also matches the materialized
|
|
237
|
+
* `convene practice-guard …` PreToolUse/Stop hooks) AND the Phase-3 permissions.deny
|
|
238
|
+
* entries materialize.ts merged in (subtract EXACTLY the catalog's managed set;
|
|
239
|
+
* drop permissions.deny if it empties, then permissions if it empties). A teammate's
|
|
240
|
+
* own hooks / deny rules / settings are preserved. Delete the file if nothing remains.
|
|
241
|
+
*/
|
|
242
|
+
function stripClaudeSettings(top, rel, touched, dryRun) {
|
|
243
|
+
const p = abs(top, rel);
|
|
244
|
+
if (!node_fs_1.default.existsSync(p))
|
|
245
|
+
return;
|
|
246
|
+
let obj;
|
|
247
|
+
try {
|
|
248
|
+
obj = JSON.parse(node_fs_1.default.readFileSync(p, 'utf8'));
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
log(`· ${rel} (left as-is — unparseable JSON)`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const stripped = (0, hook_1.withoutConveneHooks)(obj);
|
|
255
|
+
let removed = stripped.removed;
|
|
256
|
+
const settings = stripped.settings;
|
|
257
|
+
// Subtract the convene-managed permissions.deny entries (only ours).
|
|
258
|
+
const managedDeny = catalogManagedDenyEntries();
|
|
259
|
+
const deny = settings?.permissions?.deny;
|
|
260
|
+
if (Array.isArray(deny) && managedDeny.size) {
|
|
261
|
+
const kept = deny.filter((d) => !(typeof d === 'string' && managedDeny.has(d)));
|
|
262
|
+
if (kept.length !== deny.length) {
|
|
263
|
+
removed = true;
|
|
264
|
+
if (kept.length > 0) {
|
|
265
|
+
settings.permissions.deny = kept;
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
delete settings.permissions.deny;
|
|
269
|
+
if (settings.permissions && Object.keys(settings.permissions).length === 0)
|
|
270
|
+
delete settings.permissions;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!removed)
|
|
275
|
+
return;
|
|
276
|
+
if ((0, hook_1.settingsIsEmpty)(settings)) {
|
|
277
|
+
touched.push({ rel, action: 'delete' });
|
|
278
|
+
if (!dryRun)
|
|
279
|
+
node_fs_1.default.rmSync(p, { force: true });
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
touched.push({ rel, action: 'strip' });
|
|
283
|
+
if (!dryRun)
|
|
284
|
+
node_fs_1.default.writeFileSync(p, jsonOut(settings));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** Remove the committed git pre-push hook + relinquish core.hooksPath (only if ours). */
|
|
288
|
+
function handleGitHook(top, touched, dryRun) {
|
|
289
|
+
const rel = '.githooks/pre-push';
|
|
290
|
+
const p = abs(top, rel);
|
|
291
|
+
if (!node_fs_1.default.existsSync(p))
|
|
292
|
+
return;
|
|
293
|
+
if (!/convene:githook/.test(node_fs_1.default.readFileSync(p, 'utf8'))) {
|
|
294
|
+
log('· .githooks/pre-push (left as-is — not a convene hook)');
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (dryRun) {
|
|
298
|
+
touched.push({ rel, action: 'delete' });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const r = (0, githook_1.uninstallGitHooks)(top);
|
|
302
|
+
if (r.status === 'removed')
|
|
303
|
+
touched.push({ rel, action: 'delete' });
|
|
304
|
+
else if (r.status === 'skipped-foreign')
|
|
305
|
+
log('· .githooks/pre-push (left as-is — foreign hook)');
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Reverse BOTH .gitignore footprints: init's cache guard (removeGitignoreGuard, which
|
|
309
|
+
* also re-enables any blanket rule init disabled) AND the Phase-3 practices lines +
|
|
310
|
+
* managed marker materialize.ts appended (removeGitignoreLines). Done in one
|
|
311
|
+
* read/write so the file is reported/staged at most once; deletes the file if empty.
|
|
312
|
+
*/
|
|
313
|
+
function handleGitignore(top, touched, dryRun) {
|
|
314
|
+
const rel = '.gitignore';
|
|
315
|
+
const p = abs(top, rel);
|
|
316
|
+
if (!node_fs_1.default.existsSync(p))
|
|
317
|
+
return;
|
|
318
|
+
const old = node_fs_1.default.readFileSync(p, 'utf8');
|
|
319
|
+
const practiceLines = catalogManagedGitignoreLines();
|
|
320
|
+
const practicesProbe = (0, materialize_1.removeGitignoreLines)(old, practiceLines);
|
|
321
|
+
const willChangeGuard = old.includes('.convene/cache/') ||
|
|
322
|
+
old.includes('.convene/*.local.json') ||
|
|
323
|
+
old.includes('# convene (keep local cache') ||
|
|
324
|
+
/\(disabled by convene init/.test(old);
|
|
325
|
+
if (!willChangeGuard && !practicesProbe.removed)
|
|
326
|
+
return;
|
|
327
|
+
if (dryRun) {
|
|
328
|
+
touched.push({ rel, action: 'strip' });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// 1. Strip the Phase-3 practices lines first (write-back so removeGitignoreGuard
|
|
332
|
+
// operates on the practices-free body).
|
|
333
|
+
if (practicesProbe.removed) {
|
|
334
|
+
if (practicesProbe.content.trim().length === 0)
|
|
335
|
+
node_fs_1.default.rmSync(p, { force: true });
|
|
336
|
+
else
|
|
337
|
+
node_fs_1.default.writeFileSync(p, practicesProbe.content);
|
|
338
|
+
}
|
|
339
|
+
// 2. Then reverse init's cache guard (no-op if the file was already deleted).
|
|
340
|
+
const guardChanged = node_fs_1.default.existsSync(p) ? (0, init_1.removeGitignoreGuard)(top) : false;
|
|
341
|
+
if (practicesProbe.removed || guardChanged) {
|
|
342
|
+
touched.push({ rel, action: node_fs_1.default.existsSync(p) ? 'strip' : 'delete' });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/** Drop now-empty convene dirs. Order matters: nested dirs (.cursor/rules) before parents. */
|
|
346
|
+
function cleanupEmptyDirs(top) {
|
|
347
|
+
for (const d of ['.cursor/rules', '.cursor', '.clinerules', '.gemini', '.codex', '.vscode', '.claude', '.githooks']) {
|
|
348
|
+
const p = abs(top, d);
|
|
349
|
+
try {
|
|
350
|
+
if (node_fs_1.default.existsSync(p) && node_fs_1.default.readdirSync(p).length === 0)
|
|
351
|
+
node_fs_1.default.rmdirSync(p);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
/* not empty / already gone */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** Remove the per-machine `convene fetch` hook from ~/.claude/settings.json (keeps a .bak). */
|
|
359
|
+
function removeGlobalHook(dryRun) {
|
|
360
|
+
if (!node_fs_1.default.existsSync(hook_1.SETTINGS_PATH))
|
|
361
|
+
return;
|
|
362
|
+
let raw;
|
|
363
|
+
try {
|
|
364
|
+
raw = node_fs_1.default.readFileSync(hook_1.SETTINGS_PATH, 'utf8');
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
let obj;
|
|
370
|
+
try {
|
|
371
|
+
obj = JSON.parse(raw);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
log('· ~/.claude/settings.json (left as-is — unparseable)');
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const { settings, removed } = (0, hook_1.withoutConveneHooks)(obj);
|
|
378
|
+
if (!removed)
|
|
379
|
+
return;
|
|
380
|
+
if (dryRun) {
|
|
381
|
+
log('· would remove the global `convene fetch` hook from ~/.claude/settings.json');
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
node_fs_1.default.writeFileSync(hook_1.SETTINGS_PATH + '.bak', raw);
|
|
386
|
+
if ((0, hook_1.settingsIsEmpty)(settings))
|
|
387
|
+
node_fs_1.default.rmSync(hook_1.SETTINGS_PATH, { force: true });
|
|
388
|
+
else
|
|
389
|
+
node_fs_1.default.writeFileSync(hook_1.SETTINGS_PATH, jsonOut(settings));
|
|
390
|
+
log('✓ removed the global `convene fetch` hook (backup: ~/.claude/settings.json.bak)');
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
log('· could not update ~/.claude/settings.json — remove the `convene fetch` hook manually.');
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/** Remove the seeded memory file + its MEMORY.md index line (best-effort). */
|
|
397
|
+
function removeSeededMemory(top, slug, baseUrl, dryRun) {
|
|
398
|
+
try {
|
|
399
|
+
const mangled = top.replace(/\//g, '-');
|
|
400
|
+
const memDir = node_path_1.default.join((0, config_1.homeBase)(), '.claude', 'projects', mangled, 'memory');
|
|
401
|
+
const { name, indexLine } = (0, protocol_1.memoryEntry)(slug, baseUrl);
|
|
402
|
+
const memFile = node_path_1.default.join(memDir, `${name}.md`);
|
|
403
|
+
const indexFile = node_path_1.default.join(memDir, 'MEMORY.md');
|
|
404
|
+
if (node_fs_1.default.existsSync(memFile)) {
|
|
405
|
+
if (dryRun)
|
|
406
|
+
log(`· would remove seeded memory ${name}.md`);
|
|
407
|
+
else
|
|
408
|
+
node_fs_1.default.rmSync(memFile, { force: true });
|
|
409
|
+
}
|
|
410
|
+
if (!dryRun && node_fs_1.default.existsSync(indexFile)) {
|
|
411
|
+
const idx = node_fs_1.default.readFileSync(indexFile, 'utf8');
|
|
412
|
+
if (idx.includes(indexLine)) {
|
|
413
|
+
const next = idx
|
|
414
|
+
.split('\n')
|
|
415
|
+
.filter((l) => l.trim() !== indexLine.trim())
|
|
416
|
+
.join('\n');
|
|
417
|
+
node_fs_1.default.writeFileSync(indexFile, next);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
/* memory cleanup is best-effort */
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async function offboard(opts) {
|
|
426
|
+
const top = (0, git_1.gitToplevel)();
|
|
427
|
+
if (!top)
|
|
428
|
+
(0, ctx_1.die)('not a git repository — run `convene off-board` inside a repo');
|
|
429
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
430
|
+
if (!proj?.slug) {
|
|
431
|
+
log('This repo is not on Convene — nothing to off-board.');
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const dryRun = !!opts.dryRun;
|
|
435
|
+
// Consent gate: off-board is destructive. A human at a TTY confirms by running it;
|
|
436
|
+
// an agent / CI (no TTY) must pass `--yes`. A dry-run changes nothing, so it's exempt.
|
|
437
|
+
if (!opts.yes && !dryRun && !process.stdout.isTTY) {
|
|
438
|
+
(0, ctx_1.die)('refusing to off-board non-interactively without confirmation — re-run with `--yes` to confirm removing Convene from THIS repo.');
|
|
439
|
+
}
|
|
440
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
441
|
+
const baseUrl = cfg.baseUrl;
|
|
442
|
+
const slug = proj.slug;
|
|
443
|
+
// 1. Optional server-side token revoke FIRST (before deleting local files, so a
|
|
444
|
+
// failure leaves the repo recoverable). Owner-only — warn (don't fail) on 403.
|
|
445
|
+
if (opts.revokeToken && proj.joinToken) {
|
|
446
|
+
if (dryRun) {
|
|
447
|
+
log('· would revoke the committed join token server-side (--revoke-token).');
|
|
448
|
+
}
|
|
449
|
+
else if (!cfg.apiKey) {
|
|
450
|
+
log('⚠ --revoke-token: not logged in; skipping the server-side revoke.');
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
const api = new api_1.ConveneApi(baseUrl, cfg.apiKey, cfg.member ? (0, git_1.sessionId)(cfg.member, top) : null, cfg.tool);
|
|
454
|
+
const r = await api.revokeJoinToken(slug, proj.joinToken.slice(0, 14), 8000);
|
|
455
|
+
log(r.ok
|
|
456
|
+
? '✓ revoked the committed join token server-side.'
|
|
457
|
+
: `⚠ could not revoke the join token (${r.error}) — it is owner-only; revoke from the dashboard if needed.`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// 2. Local footprint removal.
|
|
461
|
+
const touched = [];
|
|
462
|
+
stripMarker(top, 'CLAUDE.md', touched, dryRun);
|
|
463
|
+
stripMarker(top, 'AGENTS.md', touched, dryRun);
|
|
464
|
+
handleProtocolDoc(top, touched, dryRun);
|
|
465
|
+
delIfOurs(top, '.aider.conf.yml', init_1.AIDER_CONF, touched, dryRun);
|
|
466
|
+
delPath(top, '.convene', touched, dryRun);
|
|
467
|
+
delPath(top, '.cursor/rules/convene.mdc', touched, dryRun);
|
|
468
|
+
delPath(top, '.clinerules/convene.md', touched, dryRun);
|
|
469
|
+
stripClaudeSettings(top, '.claude/settings.json', touched, dryRun);
|
|
470
|
+
stripToml(top, '.codex/config.toml', touched, dryRun);
|
|
471
|
+
stripJsonServer(top, '.cursor/mcp.json', 'mcpServers', touched, dryRun);
|
|
472
|
+
stripJsonServer(top, '.vscode/mcp.json', 'servers', touched, dryRun);
|
|
473
|
+
stripGemini(top, '.gemini/settings.json', touched, dryRun);
|
|
474
|
+
handleGitHook(top, touched, dryRun);
|
|
475
|
+
handleGitignore(top, touched, dryRun);
|
|
476
|
+
if (!dryRun)
|
|
477
|
+
cleanupEmptyDirs(top);
|
|
478
|
+
// 3. Repo-specific seeded memory is always cleaned (it's keyed to THIS repo's path).
|
|
479
|
+
// The machine-wide ~/.claude `convene fetch` hook is SHARED by every Convene repo
|
|
480
|
+
// on this machine, so off-boarding ONE repo must NOT remove it by default — that
|
|
481
|
+
// would silently stop auto-injection in your other Convene repos. Remove it only
|
|
482
|
+
// with --remove-global. NEVER touch ~/.convene/config.json (the machine identity).
|
|
483
|
+
removeSeededMemory(top, slug, baseUrl, dryRun);
|
|
484
|
+
if (opts.removeGlobal)
|
|
485
|
+
removeGlobalHook(dryRun);
|
|
486
|
+
// 4. Report + isolated commit.
|
|
487
|
+
if (touched.length === 0) {
|
|
488
|
+
log(`Convene files already absent — repo "${slug}" is clean.`);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
log('');
|
|
492
|
+
log(`${dryRun ? 'Would remove' : 'Removed'} Convene from "${slug}":`);
|
|
493
|
+
for (const t of touched)
|
|
494
|
+
log(` ${t.action === 'delete' ? '✗ delete' : '~ strip '} ${t.rel}`);
|
|
495
|
+
if (dryRun) {
|
|
496
|
+
log('');
|
|
497
|
+
log('(dry-run — nothing was changed.)');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const doCommit = opts.commit !== false; // default on; --no-commit disables
|
|
501
|
+
if (doCommit) {
|
|
502
|
+
// Stage ONLY the tracked convene paths (deletions + strips), never `git add -A`,
|
|
503
|
+
// so the off-board lands as one isolated commit and can't sweep in other work.
|
|
504
|
+
const repoPaths = touched.map((t) => t.rel).filter((rel) => (0, git_1.pathIsTracked)(rel, top));
|
|
505
|
+
if (repoPaths.length && (0, git_1.gitAddPaths)(repoPaths, top) && (0, git_1.hasStagedChanges)(top)) {
|
|
506
|
+
const res = (0, git_1.gitCommit)('Off-board from Convene coordination bus', repoPaths, top);
|
|
507
|
+
if (res.ok)
|
|
508
|
+
log(`✓ committed the removal as one isolated commit${res.sha ? ` (${res.sha})` : ''}.`);
|
|
509
|
+
else
|
|
510
|
+
log('· could not create the off-board commit — commit the changes manually.');
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
log('· nothing to commit (the convene files were untracked) — they are removed from the working tree.');
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
log('· skipped commit (--no-commit) — review with `git status` and commit when ready.');
|
|
518
|
+
}
|
|
519
|
+
log('');
|
|
520
|
+
log('Off-boarded. The per-machine identity in ~/.convene/config.json was left intact (it is shared');
|
|
521
|
+
log('across repos).');
|
|
522
|
+
if (!opts.removeGlobal) {
|
|
523
|
+
log('The shared `convene fetch` hook was kept so your OTHER Convene repos keep working — re-run');
|
|
524
|
+
log('with `--remove-global` if this was your last Convene repo and you want the machine fully clean.');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.override = override;
|
|
4
|
+
/**
|
|
5
|
+
* `convene override <id> --reason "<why>"` (Phase 3) — the attributed, short-lived
|
|
6
|
+
* bypass for a best-practice gate. Writes a LOCAL, expiry-based override token that
|
|
7
|
+
* `convene practice-guard <id>` honors → ALLOW, and best-effort posts an attributed
|
|
8
|
+
* [STATUS] to the bus so the bypass is auditable in real time.
|
|
9
|
+
*
|
|
10
|
+
* POSTURE:
|
|
11
|
+
* - DIE-LOUD on a missing/empty --reason (an unattributed override is the whole
|
|
12
|
+
* thing we are guarding against) and on a missing project.
|
|
13
|
+
* - FAIL-OPEN on the BUS: the local token is the source of truth for the gate;
|
|
14
|
+
* if the bus is unreachable we STILL write the token and WARN — an override must
|
|
15
|
+
* never be blocked by a flaky network. The status post is attribution, not
|
|
16
|
+
* authorization.
|
|
17
|
+
*/
|
|
18
|
+
const config_1 = require("../config");
|
|
19
|
+
const git_1 = require("../git");
|
|
20
|
+
const api_1 = require("../api");
|
|
21
|
+
const cache_1 = require("../cache");
|
|
22
|
+
const ctx_1 = require("../ctx");
|
|
23
|
+
const NET_TIMEOUT_MS = 2500; // explicit short timeout — never the api.ts 10s default
|
|
24
|
+
function fmtTtl(ms) {
|
|
25
|
+
const sec = Math.round(ms / 1000);
|
|
26
|
+
if (sec < 60)
|
|
27
|
+
return `${sec}s`;
|
|
28
|
+
const m = Math.round(sec / 60);
|
|
29
|
+
return m === 1 ? '1 minute' : `${m} minutes`;
|
|
30
|
+
}
|
|
31
|
+
async function override(id, opts = {}) {
|
|
32
|
+
if (!id || !id.trim())
|
|
33
|
+
(0, ctx_1.die)('override requires a practice <id> (e.g. `convene override protect-shared-files --reason "…"`)');
|
|
34
|
+
const reason = (opts.reason ?? '').trim();
|
|
35
|
+
if (!reason)
|
|
36
|
+
(0, ctx_1.die)('override requires --reason "<why>" — an unattributed override defeats the gate');
|
|
37
|
+
const top = (0, git_1.gitToplevel)();
|
|
38
|
+
const proj = (0, config_1.loadProjectConfig)(top);
|
|
39
|
+
const slug = opts.project || proj?.slug || null;
|
|
40
|
+
if (!slug)
|
|
41
|
+
(0, ctx_1.die)('no project — run inside a `convene init`-ed repo, or pass --project <slug>');
|
|
42
|
+
// 1) Write the LOCAL token first — this is what the gate honors. Source of truth.
|
|
43
|
+
const tok = (0, cache_1.writeOverrideToken)(slug, id, reason);
|
|
44
|
+
// 2) Best-effort attributed [STATUS] to the bus. FAIL-OPEN: a bus failure warns
|
|
45
|
+
// but never blocks — the local token already stands.
|
|
46
|
+
const cfg = (0, config_1.resolveConfig)();
|
|
47
|
+
const member = cfg.member;
|
|
48
|
+
const session = member && top ? (0, git_1.sessionId)(member, top) : null;
|
|
49
|
+
let posted = false;
|
|
50
|
+
if (cfg.apiKey && session) {
|
|
51
|
+
try {
|
|
52
|
+
const instance = (0, cache_1.ensureSessionInstance)(slug);
|
|
53
|
+
const api = new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey, session, cfg.tool, instance);
|
|
54
|
+
const res = await api.post(slug, { type: 'status', body: `practice ${id} gate overridden — ${reason}` }, (0, ctx_1.uuid)(), NET_TIMEOUT_MS);
|
|
55
|
+
posted = !!res.ok;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
posted = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
process.stdout.write(`override active for ${id} (${fmtTtl(cache_1.OVERRIDE_TTL_MS)}) — the next edits gated by this practice will be allowed.\n`);
|
|
62
|
+
if (!posted) {
|
|
63
|
+
process.stderr.write('convene: WARNING bus unreachable — override applied locally but NOT announced to the channel.\n');
|
|
64
|
+
}
|
|
65
|
+
}
|