gekto 0.0.13 → 0.0.15
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/agents/agentWebSocket.js +170 -0
- package/dist/agents/gektoPersistent.js +3 -1
- package/dist/agents/gektoTools.js +14 -0
- package/dist/detectClaude.js +14 -0
- package/dist/inspectRepo.js +185 -0
- package/dist/onboarding.js +339 -0
- package/dist/portUtils.js +40 -0
- package/dist/posthog.js +53 -0
- package/dist/proxy.js +96 -132
- package/dist/terminal.js +5 -0
- package/dist/widget/gekto-widget.iife.js +279 -271
- package/package.json +2 -1
|
@@ -6,6 +6,7 @@ import { getState, mutate, mutateBatch, addClient, removeClient, sendSnapshot, g
|
|
|
6
6
|
import { persistEntity } from '../entityStore.js';
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import nodePath from 'path';
|
|
9
|
+
import { getPostHog, getDistinctId } from '../posthog.js';
|
|
9
10
|
let gektoInitialized = false;
|
|
10
11
|
function broadcastGektoState(state) {
|
|
11
12
|
const message = JSON.stringify({ type: 'gekto_state', state });
|
|
@@ -160,6 +161,11 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
160
161
|
type: 'kill_all_result',
|
|
161
162
|
killed: killedCount,
|
|
162
163
|
}));
|
|
164
|
+
getPostHog().capture({
|
|
165
|
+
distinctId: getDistinctId(),
|
|
166
|
+
event: 'all agents killed',
|
|
167
|
+
properties: { killed_count: killedCount },
|
|
168
|
+
});
|
|
163
169
|
// Notify about state changes
|
|
164
170
|
for (const session of getActiveSessions()) {
|
|
165
171
|
ws.send(JSON.stringify({ type: 'state', lizardId: session.lizardId, state: 'ready' }));
|
|
@@ -193,6 +199,11 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
193
199
|
mutate('activePlanId', null);
|
|
194
200
|
broadcastActivePlans();
|
|
195
201
|
broadcastActivePlanId();
|
|
202
|
+
getPostHog().capture({
|
|
203
|
+
distinctId: getDistinctId(),
|
|
204
|
+
event: 'all agents cleared',
|
|
205
|
+
properties: { agent_count: agentIds.length },
|
|
206
|
+
});
|
|
196
207
|
return;
|
|
197
208
|
}
|
|
198
209
|
case 'set_active_plan': {
|
|
@@ -299,6 +310,14 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
299
310
|
planId: msg.planId,
|
|
300
311
|
prompt: msg.prompt,
|
|
301
312
|
}));
|
|
313
|
+
getPostHog().capture({
|
|
314
|
+
distinctId: getDistinctId(),
|
|
315
|
+
event: 'gekto message sent',
|
|
316
|
+
properties: {
|
|
317
|
+
plan_id: msg.planId,
|
|
318
|
+
has_images: Boolean(planImagePaths?.length),
|
|
319
|
+
},
|
|
320
|
+
});
|
|
302
321
|
const planResult = await processWithTools(msg.prompt, msg.planId, getWorkingDir(), getActiveSessions(), planCallbacks, msg.existingPlan, planImagePaths);
|
|
303
322
|
// Replace streamed JSON with clean message (always send to overwrite raw JSON)
|
|
304
323
|
// For delegate, the system message handles display — clear the streaming text
|
|
@@ -348,6 +367,15 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
348
367
|
planId: msg.planId,
|
|
349
368
|
plan: planResult.plan,
|
|
350
369
|
}));
|
|
370
|
+
getPostHog().capture({
|
|
371
|
+
distinctId: getDistinctId(),
|
|
372
|
+
event: 'plan created',
|
|
373
|
+
properties: {
|
|
374
|
+
plan_id: planResult.plan.id,
|
|
375
|
+
plan_title: planResult.plan.title,
|
|
376
|
+
action: planExistedBefore ? 'update_plan' : 'create_plan',
|
|
377
|
+
},
|
|
378
|
+
});
|
|
351
379
|
}
|
|
352
380
|
else if (planResult.type === 'remove' && planResult.removedAgents) {
|
|
353
381
|
// Remove agents from server state
|
|
@@ -412,6 +440,62 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
412
440
|
}));
|
|
413
441
|
}
|
|
414
442
|
}
|
|
443
|
+
else if (planResult.type === 'spawn_agent' && planResult.spawn) {
|
|
444
|
+
// Restore plan state — only delete if it was a temporary entry
|
|
445
|
+
if (msg.planId && getState().activePlans[msg.planId]?.status === 'planning') {
|
|
446
|
+
if (planExistedBefore && previousStatus) {
|
|
447
|
+
mutate(`activePlans.${msg.planId}.status`, previousStatus);
|
|
448
|
+
broadcastSinglePlan(msg.planId);
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
mutate(`activePlans.${msg.planId}`, undefined);
|
|
452
|
+
broadcastSinglePlan(msg.planId);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// Spin up a brand-new free agent (no plan) and hand the prompt to it.
|
|
456
|
+
// Uses the `agent_` prefix so it shows up in the floating LizardsList swarm UI
|
|
457
|
+
// (the `worker_` prefix is filtered out — those agents only render on the whiteboard).
|
|
458
|
+
const { agentName, taskDescription, prompt: workerPrompt } = planResult.spawn;
|
|
459
|
+
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
460
|
+
const newAgentId = `agent_${suffix}`;
|
|
461
|
+
const newTaskId = `task_${suffix}`;
|
|
462
|
+
const newTask = {
|
|
463
|
+
id: newTaskId,
|
|
464
|
+
name: agentName,
|
|
465
|
+
description: taskDescription,
|
|
466
|
+
prompt: workerPrompt,
|
|
467
|
+
status: 'in_progress',
|
|
468
|
+
dependencies: [],
|
|
469
|
+
files: [],
|
|
470
|
+
assignedAgentId: newAgentId,
|
|
471
|
+
};
|
|
472
|
+
const newAgent = {
|
|
473
|
+
id: newAgentId,
|
|
474
|
+
taskId: newTaskId,
|
|
475
|
+
personaId: 'plain',
|
|
476
|
+
status: 'working',
|
|
477
|
+
name: agentName,
|
|
478
|
+
createdAt: new Date().toISOString(),
|
|
479
|
+
};
|
|
480
|
+
mutateBatch([
|
|
481
|
+
{ path: `tasks.${newTaskId}`, value: newTask },
|
|
482
|
+
{ path: `agents.${newAgentId}`, value: newAgent },
|
|
483
|
+
]);
|
|
484
|
+
broadcastTask(newTaskId);
|
|
485
|
+
broadcastAgent(newAgentId);
|
|
486
|
+
// Notify the master chat so the "Delegated to <name>" badge renders.
|
|
487
|
+
// Intentionally omit planId — this worker is a free agent, not part of any plan.
|
|
488
|
+
ws.send(JSON.stringify({
|
|
489
|
+
type: 'gekto_delegate',
|
|
490
|
+
agentId: newAgentId,
|
|
491
|
+
agentName,
|
|
492
|
+
message: planResult.message || `Delegated to ${agentName}.`,
|
|
493
|
+
}));
|
|
494
|
+
// Fire-and-forget: send the prompt to the new worker's session.
|
|
495
|
+
sendMessage(newAgentId, workerPrompt, ws).catch(err => {
|
|
496
|
+
console.error(`[Agent] Spawn delegate to ${newAgentId} failed:`, err);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
415
499
|
else if (planResult.type === 'chat') {
|
|
416
500
|
// Restore plan state — only delete if it was a temporary entry
|
|
417
501
|
if (msg.planId && getState().activePlans[msg.planId]?.status === 'planning') {
|
|
@@ -441,6 +525,7 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
441
525
|
}
|
|
442
526
|
catch (err) {
|
|
443
527
|
console.error('[Agent] Gekto processing failed:', err);
|
|
528
|
+
getPostHog().captureException(err, getDistinctId(), { plan_id: msg.planId });
|
|
444
529
|
ws.send(JSON.stringify({
|
|
445
530
|
type: 'gekto_chat',
|
|
446
531
|
planId: msg.planId,
|
|
@@ -531,6 +616,14 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
531
616
|
planId: msg.planId,
|
|
532
617
|
taskCount: tasks.length,
|
|
533
618
|
}));
|
|
619
|
+
getPostHog().capture({
|
|
620
|
+
distinctId: getDistinctId(),
|
|
621
|
+
event: 'plan tasks generated',
|
|
622
|
+
properties: {
|
|
623
|
+
plan_id: msg.planId,
|
|
624
|
+
task_count: tasks.length,
|
|
625
|
+
},
|
|
626
|
+
});
|
|
534
627
|
},
|
|
535
628
|
onError: (error) => {
|
|
536
629
|
ws.send(JSON.stringify({
|
|
@@ -547,6 +640,7 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
547
640
|
}
|
|
548
641
|
catch (err) {
|
|
549
642
|
console.error('[Agent] Task generation failed:', err);
|
|
643
|
+
getPostHog().captureException(err, getDistinctId(), { plan_id: msg.planId });
|
|
550
644
|
ws.send(JSON.stringify({
|
|
551
645
|
type: 'gekto_chat',
|
|
552
646
|
planId: msg.planId,
|
|
@@ -570,9 +664,22 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
570
664
|
mutate(`activePlans.${msg.planId}.status`, 'executing');
|
|
571
665
|
broadcastSinglePlan(msg.planId);
|
|
572
666
|
}
|
|
667
|
+
getPostHog().capture({
|
|
668
|
+
distinctId: getDistinctId(),
|
|
669
|
+
event: 'plan executed',
|
|
670
|
+
properties: {
|
|
671
|
+
plan_id: msg.planId,
|
|
672
|
+
task_count: currentState.activePlans[msg.planId]?.taskIds?.length ?? 0,
|
|
673
|
+
},
|
|
674
|
+
});
|
|
573
675
|
return;
|
|
574
676
|
}
|
|
575
677
|
case 'cancel_plan': {
|
|
678
|
+
getPostHog().capture({
|
|
679
|
+
distinctId: getDistinctId(),
|
|
680
|
+
event: 'plan canceled',
|
|
681
|
+
properties: { plan_id: msg.planId },
|
|
682
|
+
});
|
|
576
683
|
const currentState = getState();
|
|
577
684
|
const cancelPlan = currentState.activePlans[msg.planId];
|
|
578
685
|
if (cancelPlan) {
|
|
@@ -625,6 +732,15 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
625
732
|
]);
|
|
626
733
|
broadcastTask(task.id);
|
|
627
734
|
broadcastAgent(agent.id);
|
|
735
|
+
getPostHog().capture({
|
|
736
|
+
distinctId: getDistinctId(),
|
|
737
|
+
event: 'worker agent spawned',
|
|
738
|
+
properties: {
|
|
739
|
+
agent_id: agent.id,
|
|
740
|
+
task_id: task.id,
|
|
741
|
+
plan_id: task.planId,
|
|
742
|
+
},
|
|
743
|
+
});
|
|
628
744
|
return;
|
|
629
745
|
}
|
|
630
746
|
// Client updates a chat message list — store on agent
|
|
@@ -695,6 +811,16 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
695
811
|
broadcastTask(msg.taskId);
|
|
696
812
|
if (agentId)
|
|
697
813
|
broadcastAgent(agentId);
|
|
814
|
+
getPostHog().capture({
|
|
815
|
+
distinctId: getDistinctId(),
|
|
816
|
+
event: 'task completed',
|
|
817
|
+
properties: {
|
|
818
|
+
task_id: msg.taskId,
|
|
819
|
+
task_name: taskState?.name,
|
|
820
|
+
plan_id: taskState?.planId,
|
|
821
|
+
agent_id: agentId,
|
|
822
|
+
},
|
|
823
|
+
});
|
|
698
824
|
}
|
|
699
825
|
return;
|
|
700
826
|
}
|
|
@@ -713,16 +839,38 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
713
839
|
broadcastTask(msg.taskId);
|
|
714
840
|
if (agentId)
|
|
715
841
|
broadcastAgent(agentId);
|
|
842
|
+
getPostHog().capture({
|
|
843
|
+
distinctId: getDistinctId(),
|
|
844
|
+
event: 'task failed',
|
|
845
|
+
properties: {
|
|
846
|
+
task_id: msg.taskId,
|
|
847
|
+
task_name: taskState?.name,
|
|
848
|
+
plan_id: taskState?.planId,
|
|
849
|
+
agent_id: agentId,
|
|
850
|
+
error: msg.error,
|
|
851
|
+
},
|
|
852
|
+
});
|
|
716
853
|
}
|
|
717
854
|
return;
|
|
718
855
|
}
|
|
719
856
|
case 'task_started': {
|
|
720
857
|
if (msg.taskId) {
|
|
858
|
+
const taskState = getState().tasks[msg.taskId];
|
|
721
859
|
mutateBatch([
|
|
722
860
|
{ path: `tasks.${msg.taskId}.status`, value: 'in_progress' },
|
|
723
861
|
{ path: `tasks.${msg.taskId}.assignedAgentId`, value: msg.lizardId },
|
|
724
862
|
]);
|
|
725
863
|
broadcastTask(msg.taskId);
|
|
864
|
+
getPostHog().capture({
|
|
865
|
+
distinctId: getDistinctId(),
|
|
866
|
+
event: 'task started',
|
|
867
|
+
properties: {
|
|
868
|
+
task_id: msg.taskId,
|
|
869
|
+
task_name: taskState?.name,
|
|
870
|
+
plan_id: taskState?.planId,
|
|
871
|
+
agent_id: msg.lizardId,
|
|
872
|
+
},
|
|
873
|
+
});
|
|
726
874
|
}
|
|
727
875
|
return;
|
|
728
876
|
}
|
|
@@ -930,6 +1078,14 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
930
1078
|
broadcastAgent(lizardId);
|
|
931
1079
|
}
|
|
932
1080
|
const images = msg.images;
|
|
1081
|
+
getPostHog().capture({
|
|
1082
|
+
distinctId: getDistinctId(),
|
|
1083
|
+
event: 'agent message sent',
|
|
1084
|
+
properties: {
|
|
1085
|
+
agent_id: lizardId,
|
|
1086
|
+
has_images: Boolean(images?.length),
|
|
1087
|
+
},
|
|
1088
|
+
});
|
|
933
1089
|
await sendMessage(lizardId, msg.content, ws, images);
|
|
934
1090
|
}
|
|
935
1091
|
catch (err) {
|
|
@@ -943,6 +1099,11 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
943
1099
|
else {
|
|
944
1100
|
resetSession(lizardId);
|
|
945
1101
|
}
|
|
1102
|
+
getPostHog().capture({
|
|
1103
|
+
distinctId: getDistinctId(),
|
|
1104
|
+
event: 'agent reset',
|
|
1105
|
+
properties: { agent_id: lizardId },
|
|
1106
|
+
});
|
|
946
1107
|
ws.send(JSON.stringify({ type: 'state', lizardId, state: 'ready' }));
|
|
947
1108
|
break;
|
|
948
1109
|
case 'revert_files': {
|
|
@@ -953,6 +1114,15 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
953
1114
|
reverted: revertResult.reverted,
|
|
954
1115
|
failed: revertResult.failed,
|
|
955
1116
|
}));
|
|
1117
|
+
getPostHog().capture({
|
|
1118
|
+
distinctId: getDistinctId(),
|
|
1119
|
+
event: 'files reverted',
|
|
1120
|
+
properties: {
|
|
1121
|
+
agent_id: lizardId,
|
|
1122
|
+
reverted_count: revertResult.reverted.length,
|
|
1123
|
+
failed_count: revertResult.failed.length,
|
|
1124
|
+
},
|
|
1125
|
+
});
|
|
956
1126
|
// Remove reverted file changes from agent state
|
|
957
1127
|
const agentState = getState().agents[lizardId];
|
|
958
1128
|
if (agentState?.fileChanges) {
|
|
@@ -8,7 +8,7 @@ export const GEKTO_OUTPUT_SCHEMA = JSON.stringify({
|
|
|
8
8
|
properties: {
|
|
9
9
|
action: {
|
|
10
10
|
type: 'string',
|
|
11
|
-
enum: ['create_plan', 'reply', 'clarify', 'remove_agents', 'update_plan', 'delegate', 'add_task'],
|
|
11
|
+
enum: ['create_plan', 'reply', 'clarify', 'remove_agents', 'update_plan', 'delegate', 'add_task', 'spawn_agent'],
|
|
12
12
|
},
|
|
13
13
|
message: { type: 'string' },
|
|
14
14
|
title: { type: 'string' },
|
|
@@ -16,6 +16,7 @@ export const GEKTO_OUTPUT_SCHEMA = JSON.stringify({
|
|
|
16
16
|
buildPrompt: { type: 'string' },
|
|
17
17
|
target: { type: 'string' },
|
|
18
18
|
agentId: { type: 'string' },
|
|
19
|
+
agentName: { type: 'string' },
|
|
19
20
|
taskName: { type: 'string' },
|
|
20
21
|
taskDescription: { type: 'string' },
|
|
21
22
|
taskFiles: { type: 'array', items: { type: 'string' } },
|
|
@@ -40,6 +41,7 @@ How you act:
|
|
|
40
41
|
- If the user wants to modify the active plan (marked [ACTIVE] in CURRENT STATE), use "update_plan" with the updated abstract. "update_plan" ALWAYS applies to the active plan. You MUST also include a short "message" for the chat confirming what changed.
|
|
41
42
|
- If the user wants to remove agents, use "remove_agents" with a target.
|
|
42
43
|
- If there is an existing agent that has context about the relevant files or task, use "delegate" with "agentId" (the agent's ID from CURRENT STATE) and "message" (a clear instruction). The agent already has session context — delegating is faster than creating a new plan. Always include a short "message" for the chat confirming what you delegated.
|
|
44
|
+
- If the user wants a single piece of work handed off and no existing agent fits (e.g. they invoked "/delegate" and none of the active agents match), use "spawn_agent" — it creates one new worker without a multi-step plan. Provide: "agentName" (2-3 word descriptive name, e.g. "AuthRefactor" or "ApiDocs"), "taskDescription" (1 sentence describing what the worker will do), and "message" (the full self-contained, actionable prompt that will be sent to the new worker). Use "spawn_agent" instead of "create_plan" when the work is a single agent's job, not a multi-step plan.
|
|
43
45
|
- ALWAYS research the codebase first (Read, Glob, Grep) before creating plans. Understand the project structure, frameworks, and conventions.
|
|
44
46
|
|
|
45
47
|
Conflict awareness:
|
|
@@ -219,6 +219,20 @@ If the user wants to change this plan, respond with "update_plan" and the FULL u
|
|
|
219
219
|
delegateMessage: parsed.message,
|
|
220
220
|
message: parsed.message,
|
|
221
221
|
};
|
|
222
|
+
case 'spawn_agent': {
|
|
223
|
+
const agentName = (parsed.agentName || parsed.taskName || 'Worker').trim();
|
|
224
|
+
const taskDescription = (parsed.taskDescription || parsed.message || 'Handle delegated task').trim();
|
|
225
|
+
const workerPrompt = (parsed.message || prompt).trim();
|
|
226
|
+
return {
|
|
227
|
+
type: 'spawn_agent',
|
|
228
|
+
spawn: {
|
|
229
|
+
agentName,
|
|
230
|
+
taskDescription,
|
|
231
|
+
prompt: workerPrompt,
|
|
232
|
+
},
|
|
233
|
+
message: `Delegated to ${agentName}.`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
222
236
|
default:
|
|
223
237
|
return { type: 'chat', message: parsed.message || "I'm not sure how to help with that." };
|
|
224
238
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { CLAUDE_PATH } from './claudePath.js';
|
|
3
|
+
export function detectClaude() {
|
|
4
|
+
return new Promise(resolve => {
|
|
5
|
+
const opts = { timeout: 3000, shell: process.platform === 'win32' };
|
|
6
|
+
execFile(CLAUDE_PATH, ['--version'], opts, (err, stdout) => {
|
|
7
|
+
if (err)
|
|
8
|
+
return resolve({ available: false });
|
|
9
|
+
const out = stdout.trim();
|
|
10
|
+
const version = (out.match(/\d+\.\d+\.\d+[^\s)]*/) || [out])[0];
|
|
11
|
+
resolve({ available: true, version, path: CLAUDE_PATH });
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { CLAUDE_PATH } from './claudePath.js';
|
|
5
|
+
export const DEV_TEST_APP_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../test-app');
|
|
6
|
+
const INSPECT_PROMPT = `You are inspecting a software project to auto-configure a dev tool called Gekto.
|
|
7
|
+
|
|
8
|
+
Use Glob, Grep, and Read (read-only) to look at the project's manifest and config files. Likely candidates:
|
|
9
|
+
- package.json, pyproject.toml, requirements.txt, Cargo.toml, go.mod, Gemfile, composer.json, pom.xml, build.gradle(.kts), mix.exs, *.csproj
|
|
10
|
+
- vite.config.*, next.config.*, astro.config.*, nuxt.config.*, angular.json, webpack.config.*, rollup.config.*
|
|
11
|
+
- .env, .env.local, .env.development
|
|
12
|
+
- Dockerfile, docker-compose.yml, Procfile
|
|
13
|
+
|
|
14
|
+
Return ONLY a single JSON object — no prose, no markdown fences — with exactly this shape:
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
"language": "<primary language, lowercase: typescript, javascript, python, go, ruby, rust, java, kotlin, csharp, php, elixir, swift, etc.>",
|
|
18
|
+
"framework": "<main UI / meta-framework / app framework, lowercase — see rules below>",
|
|
19
|
+
"bundler": "<build tool, lowercase: vite, webpack, turbopack, esbuild, rollup, parcel, rspack — or null>",
|
|
20
|
+
"runtime": "<runtime, e.g. node, bun, deno, python, ruby, go, jvm, dotnet, php — or null>",
|
|
21
|
+
"packageManager": "<e.g. npm, pnpm, yarn, bun, pip, poetry, uv, cargo, gem, composer, maven, gradle — or null>",
|
|
22
|
+
"hasWebUI": true | false,
|
|
23
|
+
"port": <integer dev-server port, or null>,
|
|
24
|
+
"devCommand": "<shell command to start the dev server, e.g. 'npm run dev', 'pnpm dev', 'bun dev', 'python manage.py runserver', 'flask run', 'uvicorn app:app --reload', 'rails server' — or null if uncertain>"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Framework field rules (read carefully — this is the most common mistake):
|
|
28
|
+
- "framework" is the **UI library or app framework**, NOT the bundler. Vite, Webpack, Turbopack, esbuild, Rollup, Parcel, Rspack are **bundlers** — they go in the "bundler" field, never in "framework".
|
|
29
|
+
- For frontend projects, prefer the UI library: "react", "vue", "svelte", "solid", "preact", "lit". Detect this from package.json dependencies (e.g. presence of "react", "vue", "svelte", "solid-js").
|
|
30
|
+
- If the project uses a meta-framework (which implies its own UI library), use the meta-framework name instead: "next", "nuxt", "sveltekit", "remix", "astro", "gatsby", "qwik", "angular".
|
|
31
|
+
- For backend projects, use the server framework: "express", "nestjs", "fastify", "koa", "hono", "django", "flask", "fastapi", "rails", "sinatra", "laravel", "symfony", "spring-boot", "phoenix", "actix", "axum", "gin", "echo".
|
|
32
|
+
- If nothing identifiable, set framework to null.
|
|
33
|
+
|
|
34
|
+
Other rules:
|
|
35
|
+
- "hasWebUI" is true if the project serves a web frontend that runs on a dev server. Backend-only servers, CLI tools, mobile apps, and libraries: false.
|
|
36
|
+
- "port" must be an integer when hasWebUI is true. Use the explicit port from config if found; otherwise the framework/bundler default (Vite 5173, Next 3000, CRA 3000, Astro 4321, Nuxt 3000, Angular 4200, SvelteKit 5173, Webpack-dev-server 8080).
|
|
37
|
+
- "port" must be null when hasWebUI is false.
|
|
38
|
+
- "devCommand" should be the canonical way to start the dev server based on the package manager (check the lockfile) and the framework. For JS projects: read package.json "scripts" — prefer "dev", then "start", then "serve". Use the right package manager (pnpm/yarn/bun/npm) based on the lockfile. Set null if you can't determine it.
|
|
39
|
+
- Output JSON only, nothing else.`;
|
|
40
|
+
function summarizeTool(name, input) {
|
|
41
|
+
if (!input)
|
|
42
|
+
return name;
|
|
43
|
+
switch (name) {
|
|
44
|
+
case 'Read':
|
|
45
|
+
return `Reading ${String(input.file_path ?? '').split('/').pop() || input.file_path}`;
|
|
46
|
+
case 'Glob':
|
|
47
|
+
return `Searching for ${input.pattern}`;
|
|
48
|
+
case 'Grep':
|
|
49
|
+
return `Grep "${input.pattern}"${input.path ? ` in ${input.path}` : ''}`;
|
|
50
|
+
default:
|
|
51
|
+
return name;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function parseStack(text) {
|
|
55
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
56
|
+
if (!jsonMatch)
|
|
57
|
+
return null;
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
60
|
+
if (!parsed.language || typeof parsed.hasWebUI !== 'boolean')
|
|
61
|
+
return null;
|
|
62
|
+
const port = typeof parsed.port === 'number' ? parsed.port : undefined;
|
|
63
|
+
return {
|
|
64
|
+
language: String(parsed.language).toLowerCase(),
|
|
65
|
+
framework: parsed.framework ? String(parsed.framework).toLowerCase() : undefined,
|
|
66
|
+
bundler: parsed.bundler ? String(parsed.bundler).toLowerCase() : undefined,
|
|
67
|
+
runtime: parsed.runtime ? String(parsed.runtime).toLowerCase() : undefined,
|
|
68
|
+
packageManager: parsed.packageManager ? String(parsed.packageManager).toLowerCase() : undefined,
|
|
69
|
+
hasWebUI: parsed.hasWebUI,
|
|
70
|
+
port: parsed.hasWebUI ? port : undefined,
|
|
71
|
+
devCommand: typeof parsed.devCommand === 'string' && parsed.devCommand.trim() ? parsed.devCommand.trim() : undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function inspectRepo(opts = {}) {
|
|
79
|
+
const prompt = opts.correction
|
|
80
|
+
? `${INSPECT_PROMPT}\n\nIMPORTANT: A previous inspection was incorrect. The user said:\n"${opts.correction}"\n\nRe-inspect the project, taking this correction into account. Verify against the actual files before answering.`
|
|
81
|
+
: INSPECT_PROMPT;
|
|
82
|
+
if (opts.dry) {
|
|
83
|
+
console.log('--- inspectRepo prompt ---');
|
|
84
|
+
console.log(prompt);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const args = [
|
|
88
|
+
'-p', prompt,
|
|
89
|
+
'--output-format', 'stream-json',
|
|
90
|
+
'--verbose',
|
|
91
|
+
'--model', 'claude-opus-4-6',
|
|
92
|
+
'--dangerously-skip-permissions',
|
|
93
|
+
'--allowed-tools', 'Read,Glob,Grep',
|
|
94
|
+
];
|
|
95
|
+
return new Promise(resolve => {
|
|
96
|
+
const proc = spawn(CLAUDE_PATH, args, {
|
|
97
|
+
cwd: opts.cwd || process.cwd(),
|
|
98
|
+
env: process.env,
|
|
99
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
100
|
+
});
|
|
101
|
+
proc.stdin?.end();
|
|
102
|
+
const timeout = setTimeout(() => {
|
|
103
|
+
proc.kill('SIGTERM');
|
|
104
|
+
}, opts.timeoutMs ?? 60_000);
|
|
105
|
+
let buffer = '';
|
|
106
|
+
let resultText = '';
|
|
107
|
+
const handleLine = (line) => {
|
|
108
|
+
if (!line.trim())
|
|
109
|
+
return;
|
|
110
|
+
try {
|
|
111
|
+
const event = JSON.parse(line);
|
|
112
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
113
|
+
for (const block of event.message.content) {
|
|
114
|
+
if (block.type === 'tool_use' && block.name) {
|
|
115
|
+
opts.onEvent?.({
|
|
116
|
+
type: 'tool',
|
|
117
|
+
name: block.name,
|
|
118
|
+
summary: summarizeTool(block.name, block.input),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
else if (block.type === 'text' && block.text) {
|
|
122
|
+
opts.onEvent?.({ type: 'text', text: block.text });
|
|
123
|
+
}
|
|
124
|
+
else if (block.type === 'thinking' && block.thinking) {
|
|
125
|
+
opts.onEvent?.({ type: 'thinking', text: block.thinking });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
else if (event.type === 'result' && typeof event.result === 'string') {
|
|
130
|
+
resultText = event.result;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// ignore parse errors on partial / non-JSON lines
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
proc.stdout.on('data', chunk => {
|
|
138
|
+
buffer += chunk.toString();
|
|
139
|
+
const lines = buffer.split('\n');
|
|
140
|
+
buffer = lines.pop() || '';
|
|
141
|
+
for (const line of lines)
|
|
142
|
+
handleLine(line);
|
|
143
|
+
});
|
|
144
|
+
proc.stderr.on('data', () => { });
|
|
145
|
+
proc.on('close', () => {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
if (buffer.trim())
|
|
148
|
+
handleLine(buffer);
|
|
149
|
+
resolve(parseStack(resultText));
|
|
150
|
+
});
|
|
151
|
+
proc.on('error', () => {
|
|
152
|
+
clearTimeout(timeout);
|
|
153
|
+
resolve(null);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
export function describeStack(stack) {
|
|
158
|
+
const parts = [
|
|
159
|
+
stack.framework,
|
|
160
|
+
stack.language,
|
|
161
|
+
stack.bundler && stack.bundler !== stack.framework ? `bundler:${stack.bundler}` : null,
|
|
162
|
+
stack.runtime,
|
|
163
|
+
stack.packageManager,
|
|
164
|
+
stack.hasWebUI ? `port:${stack.port ?? '?'}` : 'no-ui',
|
|
165
|
+
].filter(Boolean);
|
|
166
|
+
return parts.join(' · ');
|
|
167
|
+
}
|
|
168
|
+
// CLI entry — `bun run src/inspectRepo.ts [--dry] [--dev]`
|
|
169
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
170
|
+
const dry = process.argv.includes('--dry');
|
|
171
|
+
const dev = process.argv.includes('--dev');
|
|
172
|
+
const cwd = dev ? DEV_TEST_APP_DIR : process.cwd();
|
|
173
|
+
if (dev)
|
|
174
|
+
console.log(`[inspectRepo] dev mode — inspecting ${cwd}`);
|
|
175
|
+
const result = await inspectRepo({
|
|
176
|
+
dry,
|
|
177
|
+
cwd,
|
|
178
|
+
onEvent: event => {
|
|
179
|
+
if (event.type === 'tool')
|
|
180
|
+
console.log(` · ${event.summary}`);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
console.log('\nInspection:', result);
|
|
184
|
+
process.exit(0);
|
|
185
|
+
}
|