gekto 0.0.14 → 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 +56 -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/proxy.js +86 -155
- package/dist/widget/gekto-widget.iife.js +279 -271
- package/package.json +1 -1
|
@@ -440,6 +440,62 @@ export function setupAgentWebSocket(server, path = '/__gekto/agent') {
|
|
|
440
440
|
}));
|
|
441
441
|
}
|
|
442
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
|
+
}
|
|
443
499
|
else if (planResult.type === 'chat') {
|
|
444
500
|
// Restore plan state — only delete if it was a temporary entry
|
|
445
501
|
if (msg.planId && getState().activePlans[msg.planId]?.status === 'planning') {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { detectClaude } from './detectClaude.js';
|
|
4
|
+
import { inspectRepo, describeStack, DEV_TEST_APP_DIR } from './inspectRepo.js';
|
|
5
|
+
import { isPortInUse, waitForPort, startDevServer } from './portUtils.js';
|
|
6
|
+
import { getPostHog, getDistinctId, initDistinctId } from './posthog.js';
|
|
7
|
+
const c = {
|
|
8
|
+
reset: '\x1b[0m',
|
|
9
|
+
bold: '\x1b[1m',
|
|
10
|
+
dim: '\x1b[2m',
|
|
11
|
+
green: '\x1b[32m',
|
|
12
|
+
white: '\x1b[37m',
|
|
13
|
+
};
|
|
14
|
+
const FALLBACK_STACK = {
|
|
15
|
+
language: 'unknown',
|
|
16
|
+
hasWebUI: true,
|
|
17
|
+
port: 3000,
|
|
18
|
+
};
|
|
19
|
+
async function saveLeadToSheetDB(data) {
|
|
20
|
+
try {
|
|
21
|
+
await fetch('https://sheetdb.io/api/v1/hxn1hd5nzjxhd?sheet=Leads', {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
body: JSON.stringify({
|
|
25
|
+
data: {
|
|
26
|
+
email: data.email,
|
|
27
|
+
is_using_claude: data.isUsingClaude ? 'Yes' : 'No',
|
|
28
|
+
source: 'tui_onboarding',
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Silently fail - don't block onboarding
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function printStack(stack) {
|
|
38
|
+
const row = (label, value) => p.log.info(`${c.dim}${label.padEnd(11)}${c.reset} ${value ?? '(none)'}`);
|
|
39
|
+
row('Language:', stack.language);
|
|
40
|
+
row('Framework:', stack.framework);
|
|
41
|
+
row('Bundler:', stack.bundler);
|
|
42
|
+
row('Runtime:', stack.runtime);
|
|
43
|
+
row('Package mgr:', stack.packageManager);
|
|
44
|
+
row('Web UI:', stack.hasWebUI ? 'yes' : 'no');
|
|
45
|
+
row('Port:', stack.hasWebUI ? stack.port : null);
|
|
46
|
+
row('Dev cmd:', stack.hasWebUI ? stack.devCommand : null);
|
|
47
|
+
}
|
|
48
|
+
function cap(s) {
|
|
49
|
+
if (!s)
|
|
50
|
+
return '';
|
|
51
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
52
|
+
}
|
|
53
|
+
function describeProject(stack) {
|
|
54
|
+
if (stack.hasWebUI) {
|
|
55
|
+
const what = stack.framework ? `${cap(stack.framework)} app` : `${cap(stack.language)} app`;
|
|
56
|
+
const portPart = stack.port ? ` that runs on port ${stack.port}` : '';
|
|
57
|
+
return `Looks like it's a ${what}${portPart}. Gekto can run on top of your app.`;
|
|
58
|
+
}
|
|
59
|
+
const langPart = cap(stack.language);
|
|
60
|
+
const fwPart = stack.framework ? ` and ${cap(stack.framework)} framework` : '';
|
|
61
|
+
return `Hmm, I suggest this is a backend app that uses ${langPart}${fwPart}. There is no visual layer, only data — Gekto will run its widget standalone.`;
|
|
62
|
+
}
|
|
63
|
+
export async function runOnboarding(opts = {}) {
|
|
64
|
+
const dev = opts.dev ?? false;
|
|
65
|
+
const proxyPort = opts.defaultProxyPort ?? 3200;
|
|
66
|
+
let stack = { ...FALLBACK_STACK };
|
|
67
|
+
console.clear();
|
|
68
|
+
// Claude Code check — must come before anything else
|
|
69
|
+
const claudeSpinner = p.spinner();
|
|
70
|
+
claudeSpinner.start('Looking for installed Claude Code...');
|
|
71
|
+
const [claude] = await Promise.all([
|
|
72
|
+
opts.fakeNoClaude ? Promise.resolve({ available: false }) : detectClaude(),
|
|
73
|
+
new Promise(resolve => setTimeout(resolve, 1000)),
|
|
74
|
+
]);
|
|
75
|
+
if (!claude.available) {
|
|
76
|
+
claudeSpinner.stop('Claude Code not found on PATH');
|
|
77
|
+
p.log.warn('Gekto requires the Claude Code CLI to run.');
|
|
78
|
+
p.log.info(`Install it: ${c.white}npm install -g @anthropic-ai/claude-code${c.reset}`);
|
|
79
|
+
p.log.info(`Docs: ${c.white}https://docs.claude.com/en/docs/claude-code${c.reset}`);
|
|
80
|
+
p.cancel('Install Claude Code, then run gekto again.');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
claudeSpinner.stop(`Claude Code v${claude.version} detected`);
|
|
84
|
+
const fs = await import('fs');
|
|
85
|
+
const path = await import('path');
|
|
86
|
+
const STORE_PATH = path.join(process.cwd(), 'gekto-store.json');
|
|
87
|
+
const loadSettings = () => {
|
|
88
|
+
if (dev)
|
|
89
|
+
return undefined;
|
|
90
|
+
try {
|
|
91
|
+
if (fs.existsSync(STORE_PATH)) {
|
|
92
|
+
const store = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
|
93
|
+
const settings = store.data?.settings;
|
|
94
|
+
if (settings?.stack && typeof settings.stack.hasWebUI === 'boolean')
|
|
95
|
+
return settings;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch { }
|
|
99
|
+
return undefined;
|
|
100
|
+
};
|
|
101
|
+
const saveSettings = (settings) => {
|
|
102
|
+
if (dev)
|
|
103
|
+
return;
|
|
104
|
+
let store = { version: 1, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), data: {} };
|
|
105
|
+
try {
|
|
106
|
+
if (fs.existsSync(STORE_PATH)) {
|
|
107
|
+
store = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch { }
|
|
111
|
+
store.data.settings = settings;
|
|
112
|
+
store.updatedAt = new Date().toISOString();
|
|
113
|
+
fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2));
|
|
114
|
+
};
|
|
115
|
+
const existingSettings = opts.force ? undefined : loadSettings();
|
|
116
|
+
if (existingSettings?.onboardingCompleted) {
|
|
117
|
+
initDistinctId(existingSettings.email);
|
|
118
|
+
console.log(`${c.dim}Loaded settings from gekto-store.json${c.reset}`);
|
|
119
|
+
return { stack: existingSettings.stack, proxyPort: existingSettings.proxyPort };
|
|
120
|
+
}
|
|
121
|
+
p.intro(`${c.green}${c.bold}create-gekto${c.reset}${dev ? ` ${c.dim}(dev mode — not persisting)${c.reset}` : ''}`);
|
|
122
|
+
// Optional: agent-based repo inspection (requires explicit consent)
|
|
123
|
+
let inspection = null;
|
|
124
|
+
const inspectAnswer = await p.confirm({
|
|
125
|
+
message: 'Inspect this repo with Claude to detect your tech stack? (read-only)',
|
|
126
|
+
initialValue: true,
|
|
127
|
+
});
|
|
128
|
+
if (p.isCancel(inspectAnswer)) {
|
|
129
|
+
p.cancel('Setup cancelled.');
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
if (inspectAnswer) {
|
|
133
|
+
const inspectSpinner = p.spinner();
|
|
134
|
+
inspectSpinner.start('Inspecting repository...');
|
|
135
|
+
inspection = await inspectRepo({
|
|
136
|
+
cwd: dev ? DEV_TEST_APP_DIR : process.cwd(),
|
|
137
|
+
onEvent: event => {
|
|
138
|
+
if (event.type === 'tool')
|
|
139
|
+
inspectSpinner.message(event.summary);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
if (inspection) {
|
|
143
|
+
inspectSpinner.stop(`Detected: ${describeStack(inspection)}`);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
inspectSpinner.stop('Inspection failed — continuing with manual setup');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// If inspection succeeded, show summary + narrative, then loop: agree / correct / manual.
|
|
150
|
+
let confirmedFromInspection = false;
|
|
151
|
+
let current = inspection;
|
|
152
|
+
while (current) {
|
|
153
|
+
printStack(current);
|
|
154
|
+
p.note(describeProject(current));
|
|
155
|
+
const choice = await p.select({
|
|
156
|
+
message: 'Agree?',
|
|
157
|
+
options: [
|
|
158
|
+
{ label: 'Yes, continue', value: 'yes' },
|
|
159
|
+
{ label: 'No, tell agent where he is wrong', value: 'tell' },
|
|
160
|
+
{ label: 'No, setup manually', value: 'manual' },
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
if (p.isCancel(choice)) {
|
|
164
|
+
p.cancel('Setup cancelled.');
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
if (choice === 'yes') {
|
|
168
|
+
stack = current;
|
|
169
|
+
confirmedFromInspection = true;
|
|
170
|
+
inspection = current;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
if (choice === 'manual') {
|
|
174
|
+
inspection = current;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
// choice === 'tell' — re-inspect with user's correction
|
|
178
|
+
const correction = await p.text({
|
|
179
|
+
message: 'What did Claude get wrong?',
|
|
180
|
+
placeholder: "e.g. it's actually a Vue 3 app, not React",
|
|
181
|
+
});
|
|
182
|
+
if (p.isCancel(correction)) {
|
|
183
|
+
p.cancel('Setup cancelled.');
|
|
184
|
+
process.exit(0);
|
|
185
|
+
}
|
|
186
|
+
if (!correction.trim())
|
|
187
|
+
continue;
|
|
188
|
+
const refineSpinner = p.spinner();
|
|
189
|
+
refineSpinner.start('Re-inspecting with your correction...');
|
|
190
|
+
const refined = await inspectRepo({
|
|
191
|
+
cwd: dev ? DEV_TEST_APP_DIR : process.cwd(),
|
|
192
|
+
correction,
|
|
193
|
+
onEvent: event => {
|
|
194
|
+
if (event.type === 'tool')
|
|
195
|
+
refineSpinner.message(event.summary);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
if (refined) {
|
|
199
|
+
current = refined;
|
|
200
|
+
refineSpinner.stop(`Updated: ${describeStack(refined)}`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
refineSpinner.stop('Re-inspection failed — keeping previous detection');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!confirmedFromInspection) {
|
|
207
|
+
// Manual override — only ask what actually affects behavior: hasWebUI + port.
|
|
208
|
+
// Keep detected lang/framework/etc. as descriptive metadata.
|
|
209
|
+
const hasWebUIAnswer = await p.confirm({
|
|
210
|
+
message: 'Does this project have a web UI Gekto should proxy?',
|
|
211
|
+
initialValue: inspection?.hasWebUI ?? true,
|
|
212
|
+
});
|
|
213
|
+
if (p.isCancel(hasWebUIAnswer)) {
|
|
214
|
+
p.cancel('Setup cancelled.');
|
|
215
|
+
process.exit(0);
|
|
216
|
+
}
|
|
217
|
+
let port;
|
|
218
|
+
if (hasWebUIAnswer) {
|
|
219
|
+
const detectedPort = inspection?.port ? String(inspection.port) : '3000';
|
|
220
|
+
const portAnswer = await p.text({
|
|
221
|
+
message: 'What port is your dev server running on?',
|
|
222
|
+
placeholder: detectedPort,
|
|
223
|
+
defaultValue: detectedPort,
|
|
224
|
+
});
|
|
225
|
+
if (p.isCancel(portAnswer)) {
|
|
226
|
+
p.cancel('Setup cancelled.');
|
|
227
|
+
process.exit(0);
|
|
228
|
+
}
|
|
229
|
+
port = parseInt(portAnswer, 10);
|
|
230
|
+
}
|
|
231
|
+
stack = {
|
|
232
|
+
language: inspection?.language ?? 'unknown',
|
|
233
|
+
framework: inspection?.framework,
|
|
234
|
+
bundler: inspection?.bundler,
|
|
235
|
+
runtime: inspection?.runtime,
|
|
236
|
+
packageManager: inspection?.packageManager,
|
|
237
|
+
hasWebUI: hasWebUIAnswer,
|
|
238
|
+
port,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const emailAnswer = await p.text({
|
|
242
|
+
message: 'Enter your email for updates',
|
|
243
|
+
placeholder: 'you@example.com',
|
|
244
|
+
});
|
|
245
|
+
if (p.isCancel(emailAnswer)) {
|
|
246
|
+
p.cancel('Setup cancelled.');
|
|
247
|
+
process.exit(0);
|
|
248
|
+
}
|
|
249
|
+
const email = emailAnswer || '';
|
|
250
|
+
// If the project has a web UI and a port, verify the dev server is up — or offer to start it.
|
|
251
|
+
if (stack.hasWebUI && stack.port) {
|
|
252
|
+
const cwd = dev ? DEV_TEST_APP_DIR : process.cwd();
|
|
253
|
+
const portUsed = await isPortInUse(stack.port);
|
|
254
|
+
if (!portUsed) {
|
|
255
|
+
const options = [];
|
|
256
|
+
if (stack.devCommand) {
|
|
257
|
+
options.push({ label: `Yes, start using "${stack.devCommand}"`, value: 'detected' });
|
|
258
|
+
}
|
|
259
|
+
options.push({ label: 'Yes, write command', value: 'custom' });
|
|
260
|
+
options.push({ label: 'Not now', value: 'skip' });
|
|
261
|
+
const startChoice = await p.select({
|
|
262
|
+
message: `Your app isn't running on port ${stack.port}. Start it?`,
|
|
263
|
+
options,
|
|
264
|
+
});
|
|
265
|
+
if (p.isCancel(startChoice)) {
|
|
266
|
+
p.cancel('Setup cancelled.');
|
|
267
|
+
process.exit(0);
|
|
268
|
+
}
|
|
269
|
+
let command = null;
|
|
270
|
+
if (startChoice === 'detected') {
|
|
271
|
+
command = stack.devCommand;
|
|
272
|
+
}
|
|
273
|
+
else if (startChoice === 'custom') {
|
|
274
|
+
const cmdDefault = stack.devCommand || 'npm run dev';
|
|
275
|
+
const cmdAnswer = await p.text({
|
|
276
|
+
message: 'Command to start your dev server:',
|
|
277
|
+
placeholder: cmdDefault,
|
|
278
|
+
defaultValue: cmdDefault,
|
|
279
|
+
});
|
|
280
|
+
if (p.isCancel(cmdAnswer)) {
|
|
281
|
+
p.cancel('Setup cancelled.');
|
|
282
|
+
process.exit(0);
|
|
283
|
+
}
|
|
284
|
+
command = (cmdAnswer || cmdDefault).trim();
|
|
285
|
+
}
|
|
286
|
+
if (command) {
|
|
287
|
+
const launchSpinner = p.spinner();
|
|
288
|
+
launchSpinner.start(`Starting "${command}"...`);
|
|
289
|
+
startDevServer(command, cwd);
|
|
290
|
+
const opened = await waitForPort(stack.port, 60_000, elapsed => {
|
|
291
|
+
launchSpinner.message(`Waiting for port ${stack.port}... (${Math.floor(elapsed / 1000)}s)`);
|
|
292
|
+
});
|
|
293
|
+
if (opened) {
|
|
294
|
+
launchSpinner.stop(`Your app is up on port ${stack.port}`);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
launchSpinner.stop(`Timed out waiting for port ${stack.port} — Gekto will start anyway`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const spinner = p.spinner();
|
|
303
|
+
spinner.start(dev ? 'Finishing (dev mode — skipping persistence)...' : 'Preparing Gekto...');
|
|
304
|
+
if (!dev) {
|
|
305
|
+
await saveLeadToSheetDB({
|
|
306
|
+
email,
|
|
307
|
+
isUsingClaude: claude.available,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
saveSettings({ stack, proxyPort, onboardingCompleted: true, email });
|
|
311
|
+
initDistinctId(email);
|
|
312
|
+
getPostHog().capture({
|
|
313
|
+
distinctId: getDistinctId(),
|
|
314
|
+
event: 'onboarding completed',
|
|
315
|
+
properties: {
|
|
316
|
+
language: stack.language,
|
|
317
|
+
framework: stack.framework,
|
|
318
|
+
bundler: stack.bundler,
|
|
319
|
+
runtime: stack.runtime,
|
|
320
|
+
has_web_ui: stack.hasWebUI,
|
|
321
|
+
port: stack.port,
|
|
322
|
+
has_email: Boolean(email),
|
|
323
|
+
dev_mode: dev,
|
|
324
|
+
$set: email ? { email } : undefined,
|
|
325
|
+
$set_once: { initial_language: stack.language, initial_framework: stack.framework },
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
spinner.stop('Ready!');
|
|
329
|
+
p.outro(`${c.green}Starting Gekto...${c.reset}`);
|
|
330
|
+
return { stack, proxyPort };
|
|
331
|
+
}
|
|
332
|
+
// CLI entry — run `bun run src/onboarding.ts [--dev] [--no-claude]`
|
|
333
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
334
|
+
const dev = process.argv.includes('--dev');
|
|
335
|
+
const fakeNoClaude = process.argv.includes('--no-claude');
|
|
336
|
+
const result = await runOnboarding({ dev, fakeNoClaude });
|
|
337
|
+
console.log('\nResult:', result);
|
|
338
|
+
process.exit(0);
|
|
339
|
+
}
|