@grainulation/wheat 1.0.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/LICENSE +21 -0
- package/README.md +136 -0
- package/bin/wheat.js +193 -0
- package/compiler/detect-sprints.js +319 -0
- package/compiler/generate-manifest.js +280 -0
- package/compiler/wheat-compiler.js +1229 -0
- package/lib/compiler.js +35 -0
- package/lib/connect.js +418 -0
- package/lib/disconnect.js +188 -0
- package/lib/guard.js +151 -0
- package/lib/index.js +14 -0
- package/lib/init.js +457 -0
- package/lib/install-prompt.js +186 -0
- package/lib/quickstart.js +276 -0
- package/lib/serve-mcp.js +509 -0
- package/lib/server.js +391 -0
- package/lib/stats.js +184 -0
- package/lib/status.js +135 -0
- package/lib/update.js +71 -0
- package/package.json +53 -0
- package/public/index.html +1798 -0
- package/templates/claude.md +122 -0
- package/templates/commands/blind-spot.md +47 -0
- package/templates/commands/brief.md +73 -0
- package/templates/commands/calibrate.md +39 -0
- package/templates/commands/challenge.md +72 -0
- package/templates/commands/connect.md +104 -0
- package/templates/commands/evaluate.md +80 -0
- package/templates/commands/feedback.md +60 -0
- package/templates/commands/handoff.md +53 -0
- package/templates/commands/init.md +68 -0
- package/templates/commands/merge.md +51 -0
- package/templates/commands/present.md +52 -0
- package/templates/commands/prototype.md +68 -0
- package/templates/commands/replay.md +61 -0
- package/templates/commands/research.md +73 -0
- package/templates/commands/resolve.md +42 -0
- package/templates/commands/status.md +56 -0
- package/templates/commands/witness.md +79 -0
- package/templates/explainer.html +343 -0
package/lib/compiler.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wheat compile — delegates to wheat-compiler.js with --dir
|
|
3
|
+
*
|
|
4
|
+
* Instead of duplicating the compiler, we ship the real one and shell out.
|
|
5
|
+
* This ensures the npm package always produces identical output to the
|
|
6
|
+
* standalone compiler.
|
|
7
|
+
*
|
|
8
|
+
* Zero npm dependencies.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { execFileSync } from 'child_process';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = path.dirname(__filename);
|
|
17
|
+
|
|
18
|
+
const COMPILER_PATH = path.join(__dirname, '..', 'compiler', 'wheat-compiler.js');
|
|
19
|
+
|
|
20
|
+
export async function run(dir, args) {
|
|
21
|
+
// Build argv for the real compiler: --dir <targetDir> + passthrough flags
|
|
22
|
+
const compilerArgs = [COMPILER_PATH, '--dir', dir, ...args];
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const result = execFileSync(process.execPath, compilerArgs, {
|
|
26
|
+
cwd: dir,
|
|
27
|
+
timeout: 30_000,
|
|
28
|
+
stdio: ['inherit', 'inherit', 'inherit'],
|
|
29
|
+
});
|
|
30
|
+
} catch (err) {
|
|
31
|
+
// execFileSync throws on non-zero exit — let it propagate
|
|
32
|
+
if (err.status) process.exit(err.status);
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/lib/connect.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wheat connect farmer — Auto-configure Claude Code hooks for Farmer
|
|
3
|
+
*
|
|
4
|
+
* Detects farmer on localhost, writes hooks to project-level
|
|
5
|
+
* .claude/settings.local.json. Atomic writes with lockfile.
|
|
6
|
+
* Zero npm dependencies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import https from 'node:https';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PORTS = [9090, 9091];
|
|
17
|
+
const DETECT_TIMEOUT_MS = 2000;
|
|
18
|
+
const VERIFY_TIMEOUT_MS = 5000;
|
|
19
|
+
const LOCK_RETRY_MS = 200;
|
|
20
|
+
const LOCK_MAX_RETRIES = 10;
|
|
21
|
+
const SETTINGS_FILENAME = '.claude/settings.local.json';
|
|
22
|
+
|
|
23
|
+
const HOOK_ENDPOINTS = {
|
|
24
|
+
permission: '/hooks/permission',
|
|
25
|
+
activity: '/hooks/activity',
|
|
26
|
+
notification: '/hooks/notification',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ─── Argument parsing ──────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function parseArgs(args) {
|
|
32
|
+
const flags = {};
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
if (args[i] === '--url' && args[i + 1]) {
|
|
35
|
+
flags.url = args[i + 1]; i++;
|
|
36
|
+
} else if (args[i] === '--port' && args[i + 1]) {
|
|
37
|
+
flags.port = parseInt(args[i + 1], 10); i++;
|
|
38
|
+
} else if (args[i] === '--dry-run') {
|
|
39
|
+
flags.dryRun = true;
|
|
40
|
+
} else if (args[i] === '--force') {
|
|
41
|
+
flags.force = true;
|
|
42
|
+
} else if (args[i] === '--json') {
|
|
43
|
+
flags.json = true;
|
|
44
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
45
|
+
flags.help = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return flags;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── HTTP helpers (zero-dep) ───────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function httpRequest(url, options = {}) {
|
|
54
|
+
return new Promise(resolve => {
|
|
55
|
+
const parsed = new URL(url);
|
|
56
|
+
const client = parsed.protocol === 'https:' ? https : http;
|
|
57
|
+
const timeout = options.timeout || DETECT_TIMEOUT_MS;
|
|
58
|
+
|
|
59
|
+
const req = client.request(parsed, {
|
|
60
|
+
method: options.method || 'GET',
|
|
61
|
+
headers: options.headers || {},
|
|
62
|
+
timeout,
|
|
63
|
+
}, res => {
|
|
64
|
+
let body = '';
|
|
65
|
+
res.on('data', chunk => { body += chunk; });
|
|
66
|
+
res.on('end', () => {
|
|
67
|
+
resolve({ status: res.statusCode, body, error: null });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
req.on('error', err => resolve({ status: 0, body: '', error: err.message }));
|
|
72
|
+
req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '', error: 'timeout' }); });
|
|
73
|
+
|
|
74
|
+
if (options.body) {
|
|
75
|
+
req.write(typeof options.body === 'string' ? options.body : JSON.stringify(options.body));
|
|
76
|
+
}
|
|
77
|
+
req.end();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Farmer detection ──────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
async function probeFarmer(baseUrl) {
|
|
84
|
+
// Primary detection: hit /api/state which is the canonical farmer endpoint
|
|
85
|
+
const stateResp = await httpRequest(baseUrl + '/api/state', { timeout: DETECT_TIMEOUT_MS });
|
|
86
|
+
if (stateResp.error) return { found: false, error: stateResp.error };
|
|
87
|
+
|
|
88
|
+
if (stateResp.status !== 200) {
|
|
89
|
+
// Fallback: try root to see if it's farmer at all
|
|
90
|
+
const rootResp = await httpRequest(baseUrl + '/', { timeout: DETECT_TIMEOUT_MS });
|
|
91
|
+
if (rootResp.error || rootResp.status !== 200) {
|
|
92
|
+
return { found: false, error: `Port responds (HTTP ${stateResp.status}) but /api/state not available` };
|
|
93
|
+
}
|
|
94
|
+
const looksLikeFarmer = rootResp.body.includes('farmer') || rootResp.body.includes('Farmer');
|
|
95
|
+
if (!looksLikeFarmer) {
|
|
96
|
+
return { found: false, error: `Port responds but does not look like Farmer` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Hook probe: verify the permission endpoint accepts POSTs
|
|
101
|
+
const probePayload = {
|
|
102
|
+
hook_event_name: 'PreToolUse',
|
|
103
|
+
tool_name: '__wheat_connect_probe__',
|
|
104
|
+
tool_input: '{}',
|
|
105
|
+
session_id: 'wheat-connect-probe',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const hookResp = await httpRequest(baseUrl + HOOK_ENDPOINTS.permission, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'Content-Type': 'application/json' },
|
|
111
|
+
body: probePayload,
|
|
112
|
+
timeout: VERIFY_TIMEOUT_MS,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (hookResp.error) {
|
|
116
|
+
return { found: true, verified: false, error: `Farmer found but hook probe failed: ${hookResp.error}` };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let isVerified = false;
|
|
120
|
+
try {
|
|
121
|
+
isVerified = !!JSON.parse(hookResp.body).hookSpecificOutput;
|
|
122
|
+
} catch {}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
found: true,
|
|
126
|
+
verified: isVerified,
|
|
127
|
+
status: hookResp.status,
|
|
128
|
+
error: isVerified ? null : `Hook endpoint returned unexpected response (HTTP ${hookResp.status})`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function verifySse(baseUrl) {
|
|
133
|
+
return new Promise(resolve => {
|
|
134
|
+
const parsed = new URL(baseUrl + '/events');
|
|
135
|
+
const req = http.request(parsed, { method: 'GET', timeout: VERIFY_TIMEOUT_MS }, res => {
|
|
136
|
+
const isSSE = res.headers['content-type']?.includes('text/event-stream');
|
|
137
|
+
res.destroy(); // We only need to confirm it opens
|
|
138
|
+
resolve({ ok: isSSE, status: res.statusCode });
|
|
139
|
+
});
|
|
140
|
+
req.on('error', err => resolve({ ok: false, error: err.message }));
|
|
141
|
+
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
|
|
142
|
+
req.end();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function detectFarmer(preferredPort) {
|
|
147
|
+
const ports = preferredPort ? [preferredPort] : DEFAULT_PORTS;
|
|
148
|
+
for (const port of ports) {
|
|
149
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
150
|
+
const result = await probeFarmer(baseUrl);
|
|
151
|
+
if (result.found) return { ...result, url: baseUrl, port };
|
|
152
|
+
}
|
|
153
|
+
return { found: false, url: null, port: null, error: 'No farmer server found on default ports' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Settings file management ──────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
function hookCommand(farmerUrl, endpoint) {
|
|
159
|
+
return `cat | curl -s -X POST ${farmerUrl}${endpoint} -H 'Content-Type: application/json' --data-binary @- 2>/dev/null || true`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildHooksConfig(farmerUrl) {
|
|
163
|
+
return {
|
|
164
|
+
PreToolUse: [{
|
|
165
|
+
matcher: '',
|
|
166
|
+
hooks: [{ type: 'command', command: hookCommand(farmerUrl, HOOK_ENDPOINTS.permission) }],
|
|
167
|
+
}],
|
|
168
|
+
PostToolUse: [{
|
|
169
|
+
matcher: '',
|
|
170
|
+
hooks: [{ type: 'command', command: hookCommand(farmerUrl, HOOK_ENDPOINTS.activity) }],
|
|
171
|
+
}],
|
|
172
|
+
Notification: [{
|
|
173
|
+
matcher: '',
|
|
174
|
+
hooks: [{ type: 'command', command: hookCommand(farmerUrl, HOOK_ENDPOINTS.notification) }],
|
|
175
|
+
}],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readSettings(settingsPath) {
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (err.code === 'ENOENT') return {};
|
|
184
|
+
throw new Error(`Cannot parse ${settingsPath}: ${err.message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isFarmerHookEntry(entry) {
|
|
189
|
+
if (!entry.hooks || !Array.isArray(entry.hooks)) return false;
|
|
190
|
+
return entry.hooks.some(h =>
|
|
191
|
+
(h.type === 'command' && h.command && h.command.includes('/hooks/'))
|
|
192
|
+
|| (h.type === 'url' && h.url && h.url.includes('/hooks/'))
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function mergeHooks(existing, farmerHooks) {
|
|
197
|
+
const merged = JSON.parse(JSON.stringify(existing));
|
|
198
|
+
if (!merged.hooks) merged.hooks = {};
|
|
199
|
+
|
|
200
|
+
for (const hookType of Object.keys(farmerHooks)) {
|
|
201
|
+
const existingHooks = merged.hooks[hookType] || [];
|
|
202
|
+
const nonFarmerHooks = existingHooks.filter(entry => !isFarmerHookEntry(entry));
|
|
203
|
+
merged.hooks[hookType] = [...nonFarmerHooks, ...farmerHooks[hookType]];
|
|
204
|
+
}
|
|
205
|
+
return merged;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function writeSettingsAtomic(settingsPath, settings) {
|
|
209
|
+
const lockPath = settingsPath + '.lock';
|
|
210
|
+
const backupPath = settingsPath + '.backup';
|
|
211
|
+
const tmpPath = settingsPath + '.tmp';
|
|
212
|
+
|
|
213
|
+
let lockAcquired = false;
|
|
214
|
+
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
|
|
215
|
+
try {
|
|
216
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
217
|
+
fs.writeSync(fd, String(process.pid));
|
|
218
|
+
fs.closeSync(fd);
|
|
219
|
+
lockAcquired = true;
|
|
220
|
+
break;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (err.code === 'EEXIST') {
|
|
223
|
+
try {
|
|
224
|
+
const holderPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
|
|
225
|
+
process.kill(holderPid, 0);
|
|
226
|
+
await new Promise(r => setTimeout(r, LOCK_RETRY_MS));
|
|
227
|
+
} catch {
|
|
228
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
throw err;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!lockAcquired) {
|
|
237
|
+
throw new Error('Cannot acquire file lock — another process is writing to settings');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
if (fs.existsSync(settingsPath)) {
|
|
242
|
+
fs.copyFileSync(settingsPath, backupPath);
|
|
243
|
+
}
|
|
244
|
+
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n');
|
|
245
|
+
fs.renameSync(tmpPath, settingsPath);
|
|
246
|
+
} finally {
|
|
247
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ─── Output formatting ────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
function printSuccess(farmerUrl, settingsPath, dryRun) {
|
|
254
|
+
console.log();
|
|
255
|
+
console.log(' \x1b[32m\u2713\x1b[0m \x1b[1mFarmer connected\x1b[0m');
|
|
256
|
+
console.log(' \u2500'.repeat(40));
|
|
257
|
+
console.log(` Server: ${farmerUrl}`);
|
|
258
|
+
console.log(` Settings: ${settingsPath}`);
|
|
259
|
+
console.log();
|
|
260
|
+
console.log(' Hooks configured:');
|
|
261
|
+
console.log(` PreToolUse \u2192 ${farmerUrl}/hooks/permission`);
|
|
262
|
+
console.log(` PostToolUse \u2192 ${farmerUrl}/hooks/activity`);
|
|
263
|
+
console.log(` Notification \u2192 ${farmerUrl}/hooks/notification`);
|
|
264
|
+
console.log();
|
|
265
|
+
if (dryRun) {
|
|
266
|
+
console.log(' \x1b[33m(dry run \u2014 no files were modified)\x1b[0m');
|
|
267
|
+
console.log();
|
|
268
|
+
}
|
|
269
|
+
console.log(' What this means:');
|
|
270
|
+
console.log(' Every Claude Code tool call in this project now routes');
|
|
271
|
+
console.log(' through Farmer. You can approve, deny, or monitor');
|
|
272
|
+
console.log(' from your phone or desktop.');
|
|
273
|
+
console.log();
|
|
274
|
+
console.log(' What was verified:');
|
|
275
|
+
console.log(' - Farmer is running and responding to hook probes');
|
|
276
|
+
console.log(' - SSE event stream is available for live monitoring');
|
|
277
|
+
console.log(' - Hooks were merged without overwriting existing settings');
|
|
278
|
+
console.log();
|
|
279
|
+
console.log(' What to do next:');
|
|
280
|
+
console.log(' Open Claude Code in this directory. If Farmer goes down,');
|
|
281
|
+
console.log(' hooks fail silently (|| true) so your workflow is never blocked.');
|
|
282
|
+
console.log();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function printNotFound(triedPorts) {
|
|
286
|
+
console.log();
|
|
287
|
+
console.log(' \x1b[31m\u2717\x1b[0m \x1b[1mFarmer not detected\x1b[0m');
|
|
288
|
+
console.log(' \u2500'.repeat(40));
|
|
289
|
+
console.log(` Tried ports: ${triedPorts.join(', ')}`);
|
|
290
|
+
console.log();
|
|
291
|
+
console.log(' To start Farmer:');
|
|
292
|
+
console.log(' npx @grainulation/farmer start');
|
|
293
|
+
console.log();
|
|
294
|
+
console.log(' Or connect to a remote Farmer:');
|
|
295
|
+
console.log(' wheat connect farmer --url https://your-tunnel.trycloudflare.com');
|
|
296
|
+
console.log();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── Main ──────────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
export async function run(dir, args) {
|
|
302
|
+
const flags = parseArgs(args || []);
|
|
303
|
+
|
|
304
|
+
if (flags.help) {
|
|
305
|
+
console.log(`
|
|
306
|
+
wheat connect farmer — Auto-configure Claude Code hooks for Farmer
|
|
307
|
+
|
|
308
|
+
Usage:
|
|
309
|
+
wheat connect farmer [options]
|
|
310
|
+
|
|
311
|
+
Options:
|
|
312
|
+
--url <url> Connect to a specific Farmer URL (remote/tunnel)
|
|
313
|
+
--port <port> Try a specific port instead of defaults (9090, 9091)
|
|
314
|
+
--dry-run Show what would be configured without writing
|
|
315
|
+
--force Overwrite existing farmer hooks
|
|
316
|
+
--json Output result as JSON (for scripting)
|
|
317
|
+
--help Show this help
|
|
318
|
+
`);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const targetDir = dir || process.cwd();
|
|
323
|
+
const settingsPath = path.join(targetDir, SETTINGS_FILENAME);
|
|
324
|
+
const settingsDir = path.dirname(settingsPath);
|
|
325
|
+
|
|
326
|
+
if (!fs.existsSync(settingsDir)) {
|
|
327
|
+
fs.mkdirSync(settingsDir, { recursive: true });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Step 1: Detect or connect to farmer
|
|
331
|
+
let farmerUrl;
|
|
332
|
+
let detection;
|
|
333
|
+
|
|
334
|
+
if (flags.url) {
|
|
335
|
+
farmerUrl = flags.url.replace(/\/+$/, '');
|
|
336
|
+
console.log(`\n Connecting to ${farmerUrl}...`);
|
|
337
|
+
detection = await probeFarmer(farmerUrl);
|
|
338
|
+
if (!detection.found) {
|
|
339
|
+
if (flags.json) {
|
|
340
|
+
console.log(JSON.stringify({ success: false, error: detection.error }));
|
|
341
|
+
} else {
|
|
342
|
+
console.log(`\n \x1b[31m\u2717\x1b[0m Cannot reach Farmer at ${farmerUrl}: ${detection.error}\n`);
|
|
343
|
+
}
|
|
344
|
+
process.exit(1);
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
const ports = flags.port ? [flags.port] : DEFAULT_PORTS;
|
|
348
|
+
console.log(`\n Detecting Farmer on localhost (ports: ${ports.join(', ')})...`);
|
|
349
|
+
detection = await detectFarmer(flags.port);
|
|
350
|
+
if (!detection.found) {
|
|
351
|
+
if (flags.json) {
|
|
352
|
+
console.log(JSON.stringify({ success: false, error: detection.error }));
|
|
353
|
+
} else {
|
|
354
|
+
printNotFound(ports);
|
|
355
|
+
}
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
farmerUrl = detection.url;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!detection.verified) {
|
|
362
|
+
console.log(` \x1b[33m!\x1b[0m Farmer found but hook verification failed.`);
|
|
363
|
+
console.log(` ${detection.error || 'Unknown verification error'}`);
|
|
364
|
+
console.log(` Proceeding with configuration anyway...`);
|
|
365
|
+
} else {
|
|
366
|
+
console.log(` \x1b[32m\u2713\x1b[0m Farmer detected at ${farmerUrl}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Verify SSE endpoint is available
|
|
370
|
+
const sseResult = await verifySse(farmerUrl);
|
|
371
|
+
if (sseResult.ok) {
|
|
372
|
+
console.log(` \x1b[32m\u2713\x1b[0m SSE event stream verified at ${farmerUrl}/events`);
|
|
373
|
+
} else {
|
|
374
|
+
console.log(` \x1b[33m!\x1b[0m SSE endpoint not confirmed (${sseResult.error || 'unexpected response'})`);
|
|
375
|
+
console.log(` Live monitoring may not work until Farmer restarts.`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Step 2: Read existing settings, merge, write
|
|
379
|
+
const existing = readSettings(settingsPath);
|
|
380
|
+
|
|
381
|
+
const hasExistingFarmerHooks = existing.hooks && Object.values(existing.hooks).some(
|
|
382
|
+
entries => Array.isArray(entries) && entries.some(entry => isFarmerHookEntry(entry))
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
if (hasExistingFarmerHooks && !flags.force) {
|
|
386
|
+
if (flags.json) {
|
|
387
|
+
console.log(JSON.stringify({ success: true, alreadyConfigured: true, url: farmerUrl }));
|
|
388
|
+
} else {
|
|
389
|
+
console.log(` \x1b[33m!\x1b[0m Farmer hooks already configured in ${SETTINGS_FILENAME}`);
|
|
390
|
+
console.log(' Use --force to overwrite.');
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const farmerHooks = buildHooksConfig(farmerUrl);
|
|
396
|
+
const merged = mergeHooks(existing, farmerHooks);
|
|
397
|
+
|
|
398
|
+
if (flags.dryRun) {
|
|
399
|
+
if (flags.json) {
|
|
400
|
+
console.log(JSON.stringify({ success: true, dryRun: true, url: farmerUrl, settings: merged }));
|
|
401
|
+
} else {
|
|
402
|
+
console.log('\n Would write to: ' + settingsPath);
|
|
403
|
+
console.log();
|
|
404
|
+
console.log(JSON.stringify(merged, null, 2));
|
|
405
|
+
printSuccess(farmerUrl, settingsPath, true);
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Step 3: Write settings atomically
|
|
411
|
+
await writeSettingsAtomic(settingsPath, merged);
|
|
412
|
+
|
|
413
|
+
if (flags.json) {
|
|
414
|
+
console.log(JSON.stringify({ success: true, url: farmerUrl, settingsPath, verified: detection.verified }));
|
|
415
|
+
} else {
|
|
416
|
+
printSuccess(farmerUrl, settingsPath, false);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wheat disconnect farmer — Remove Farmer hooks from Claude Code settings
|
|
3
|
+
*
|
|
4
|
+
* Reads .claude/settings.local.json, strips any hook entries whose
|
|
5
|
+
* command or URL contains "/hooks/", writes back atomically (tmp + rename).
|
|
6
|
+
* Zero npm dependencies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const SETTINGS_FILENAME = '.claude/settings.local.json';
|
|
15
|
+
|
|
16
|
+
// ─── Argument parsing ──────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function parseArgs(args) {
|
|
19
|
+
const flags = {};
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
if (args[i] === '--dry-run') {
|
|
22
|
+
flags.dryRun = true;
|
|
23
|
+
} else if (args[i] === '--json') {
|
|
24
|
+
flags.json = true;
|
|
25
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
26
|
+
flags.help = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return flags;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Hook detection (matches connect.js logic) ────────────────────────────
|
|
33
|
+
|
|
34
|
+
function isFarmerHookEntry(entry) {
|
|
35
|
+
if (!entry.hooks || !Array.isArray(entry.hooks)) return false;
|
|
36
|
+
return entry.hooks.some(h =>
|
|
37
|
+
(h.type === 'command' && h.command && h.command.includes('/hooks/'))
|
|
38
|
+
|| (h.type === 'url' && h.url && h.url.includes('/hooks/'))
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Settings I/O ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function readSettings(settingsPath) {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err.code === 'ENOENT') return null;
|
|
49
|
+
throw new Error(`Cannot parse ${settingsPath}: ${err.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeSettingsAtomic(settingsPath, settings) {
|
|
54
|
+
const tmpPath = settingsPath + '.tmp';
|
|
55
|
+
fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n');
|
|
56
|
+
fs.renameSync(tmpPath, settingsPath);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Core logic ───────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function findFarmerHooks(settings) {
|
|
62
|
+
const found = [];
|
|
63
|
+
if (!settings || !settings.hooks) return found;
|
|
64
|
+
for (const [hookType, entries] of Object.entries(settings.hooks)) {
|
|
65
|
+
if (!Array.isArray(entries)) continue;
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (isFarmerHookEntry(entry)) {
|
|
68
|
+
found.push({ hookType, entry });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return found;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function removeFarmerHooks(settings) {
|
|
76
|
+
const cleaned = JSON.parse(JSON.stringify(settings));
|
|
77
|
+
if (!cleaned.hooks) return cleaned;
|
|
78
|
+
for (const hookType of Object.keys(cleaned.hooks)) {
|
|
79
|
+
if (!Array.isArray(cleaned.hooks[hookType])) continue;
|
|
80
|
+
cleaned.hooks[hookType] = cleaned.hooks[hookType].filter(
|
|
81
|
+
entry => !isFarmerHookEntry(entry)
|
|
82
|
+
);
|
|
83
|
+
// Remove empty arrays to keep the file clean
|
|
84
|
+
if (cleaned.hooks[hookType].length === 0) {
|
|
85
|
+
delete cleaned.hooks[hookType];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Remove empty hooks object
|
|
89
|
+
if (Object.keys(cleaned.hooks).length === 0) {
|
|
90
|
+
delete cleaned.hooks;
|
|
91
|
+
}
|
|
92
|
+
return cleaned;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Main ──────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export async function run(dir, args) {
|
|
98
|
+
const flags = parseArgs(args || []);
|
|
99
|
+
|
|
100
|
+
if (flags.help) {
|
|
101
|
+
console.log(`
|
|
102
|
+
wheat disconnect farmer -- Remove Farmer hooks from Claude Code settings
|
|
103
|
+
|
|
104
|
+
Usage:
|
|
105
|
+
wheat disconnect farmer [options]
|
|
106
|
+
|
|
107
|
+
Options:
|
|
108
|
+
--dry-run Show what would be removed without writing
|
|
109
|
+
--json Output result as JSON (for scripting)
|
|
110
|
+
--help Show this help
|
|
111
|
+
|
|
112
|
+
This removes all hook entries in .claude/settings.local.json whose
|
|
113
|
+
command or URL contains "/hooks/". Other settings are preserved.
|
|
114
|
+
`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const targetDir = dir || process.cwd();
|
|
119
|
+
const settingsPath = path.join(targetDir, SETTINGS_FILENAME);
|
|
120
|
+
|
|
121
|
+
const settings = readSettings(settingsPath);
|
|
122
|
+
|
|
123
|
+
if (!settings) {
|
|
124
|
+
if (flags.json) {
|
|
125
|
+
console.log(JSON.stringify({ success: true, removed: 0, message: 'No settings file found' }));
|
|
126
|
+
} else {
|
|
127
|
+
console.log('\n No farmer hooks found (settings file does not exist).\n');
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const farmerHooks = findFarmerHooks(settings);
|
|
133
|
+
|
|
134
|
+
if (farmerHooks.length === 0) {
|
|
135
|
+
if (flags.json) {
|
|
136
|
+
console.log(JSON.stringify({ success: true, removed: 0, message: 'No farmer hooks found' }));
|
|
137
|
+
} else {
|
|
138
|
+
console.log('\n No farmer hooks found in ' + SETTINGS_FILENAME + '.\n');
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Show what will be removed
|
|
144
|
+
if (flags.dryRun) {
|
|
145
|
+
if (flags.json) {
|
|
146
|
+
console.log(JSON.stringify({
|
|
147
|
+
success: true,
|
|
148
|
+
dryRun: true,
|
|
149
|
+
removed: farmerHooks.length,
|
|
150
|
+
hookTypes: farmerHooks.map(h => h.hookType),
|
|
151
|
+
}));
|
|
152
|
+
} else {
|
|
153
|
+
console.log('\n Would remove ' + farmerHooks.length + ' farmer hook(s):');
|
|
154
|
+
for (const { hookType } of farmerHooks) {
|
|
155
|
+
console.log(' - ' + hookType);
|
|
156
|
+
}
|
|
157
|
+
console.log('\n (dry run -- no files were modified)\n');
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Remove and write
|
|
163
|
+
const cleaned = removeFarmerHooks(settings);
|
|
164
|
+
writeSettingsAtomic(settingsPath, cleaned);
|
|
165
|
+
|
|
166
|
+
if (flags.json) {
|
|
167
|
+
console.log(JSON.stringify({
|
|
168
|
+
success: true,
|
|
169
|
+
removed: farmerHooks.length,
|
|
170
|
+
hookTypes: farmerHooks.map(h => h.hookType),
|
|
171
|
+
settingsPath,
|
|
172
|
+
}));
|
|
173
|
+
} else {
|
|
174
|
+
console.log();
|
|
175
|
+
console.log(' \x1b[32m\u2713\x1b[0m \x1b[1mFarmer disconnected\x1b[0m');
|
|
176
|
+
console.log(' \u2500'.repeat(40));
|
|
177
|
+
console.log(' Removed ' + farmerHooks.length + ' hook(s):');
|
|
178
|
+
for (const { hookType } of farmerHooks) {
|
|
179
|
+
console.log(' - ' + hookType);
|
|
180
|
+
}
|
|
181
|
+
console.log();
|
|
182
|
+
console.log(' Settings: ' + settingsPath);
|
|
183
|
+
console.log();
|
|
184
|
+
console.log(' To reconnect:');
|
|
185
|
+
console.log(' wheat connect farmer');
|
|
186
|
+
console.log();
|
|
187
|
+
}
|
|
188
|
+
}
|