gipity 1.0.428 → 1.1.1

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.
Files changed (40) hide show
  1. package/dist/agents/claude-code.js +53 -0
  2. package/dist/agents/codex.js +49 -0
  3. package/dist/agents/grok.js +54 -0
  4. package/dist/agents/index.js +23 -0
  5. package/dist/agents/types.js +18 -0
  6. package/dist/api.js +7 -0
  7. package/dist/capture/sources/codex.js +216 -0
  8. package/dist/capture/sources/grok.js +178 -0
  9. package/dist/client-context.js +2 -0
  10. package/dist/commands/build.js +1352 -0
  11. package/dist/commands/chat.js +9 -3
  12. package/dist/commands/claude.js +4 -13
  13. package/dist/commands/db.js +12 -5
  14. package/dist/commands/deploy.js +19 -1
  15. package/dist/commands/doctor.js +8 -2
  16. package/dist/commands/generate.js +61 -4
  17. package/dist/commands/init.js +9 -15
  18. package/dist/commands/page-eval.js +404 -56
  19. package/dist/commands/page-inspect.js +15 -5
  20. package/dist/commands/page-screenshot.js +269 -64
  21. package/dist/commands/project.js +1 -1
  22. package/dist/commands/relay-install.js +1 -1
  23. package/dist/commands/relay.js +2 -2
  24. package/dist/commands/sandbox.js +62 -16
  25. package/dist/commands/setup.js +16 -10
  26. package/dist/commands/uninstall.js +25 -3
  27. package/dist/hooks/capture-runner.js +136 -39
  28. package/dist/index.js +1962 -668
  29. package/dist/knowledge.js +3 -3
  30. package/dist/page-fixtures.js +92 -3
  31. package/dist/prefs.js +50 -0
  32. package/dist/project-setup.js +2 -10
  33. package/dist/provider-docs.js +10 -10
  34. package/dist/relay/daemon.js +140 -18
  35. package/dist/relay/device-http.js +22 -0
  36. package/dist/relay/diagnostics.js +4 -2
  37. package/dist/relay/media-upload.js +184 -0
  38. package/dist/relay/onboarding.js +2 -2
  39. package/dist/setup.js +262 -18
  40. package/package.json +4 -3
@@ -0,0 +1,1352 @@
1
+ import { Command } from 'commander';
2
+ import { join, dirname, resolve, basename, sep } from 'path';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync } from 'fs';
4
+ import { homedir } from 'os';
5
+ import { fileURLToPath } from 'url';
6
+ import { randomUUID } from 'crypto';
7
+ import { resolveCommand, spawnCommand, spawnSyncCommand } from '../platform.js';
8
+ import { getAuth, sessionExpired, accessTokenExpired, refreshTokenIfNeeded } from '../auth.js';
9
+ import { get, post, ApiError, getAccountSlug } from '../api.js';
10
+ import { interactiveLogin } from '../login-flow.js';
11
+ import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, DEFAULT_API_BASE, getConfigPath } from '../config.js';
12
+ import { sync } from '../sync.js';
13
+ import { slugify, ensureGipityPluginInstalled, setupProjectTools, DEFAULT_SYNC_IGNORE, isSyncIgnored } from '../setup.js';
14
+ import { buildProjectContextBlock as buildProjectContextBlockText, buildNewProjectPrompt, buildResumeWrap, buildFreshWrap, } from '../prompts.js';
15
+ import * as relayState from '../relay/state.js';
16
+ import { maybeOfferRelayOn, ensureDaemonRunning } from '../relay/onboarding.js';
17
+ import { prompt, pickOne, confirm } from '../utils.js';
18
+ import { brand, bold, info, success, warning, error as clrError, muted } from '../colors.js';
19
+ import { createProgressReporter } from '../progress.js';
20
+ import { printBanner } from '../banner.js';
21
+ import { scanForAdoption, isLikelyEmpty, canAdoptCwd, formatCwdLabel, formatBytes, adoptCurrentDir, ADOPT_THRESHOLDS, } from '../adopt-cwd.js';
22
+ import { isClaudeInstalled, ensureClaudeInstalled, CLAUDE_PACKAGE } from '../claude-setup.js';
23
+ import { AGENT_ADAPTERS, AGENT_KEYS, getAdapter } from '../agents/index.js';
24
+ import { readPrefs, writePrefs } from '../prefs.js';
25
+ import { binaryOnPath } from '../setup.js';
26
+ const __clDir = dirname(fileURLToPath(import.meta.url));
27
+ // Walk up to the nearest gipity package.json instead of a hardcoded '../..':
28
+ // the per-module tsc layout puts this file at dist/commands/, the bundled CLI
29
+ // at dist/, so a fixed depth breaks one of the two layouts.
30
+ function readOwnPkg(fromDir) {
31
+ for (let d = fromDir;; d = dirname(d)) {
32
+ const p = join(d, 'package.json');
33
+ if (existsSync(p)) {
34
+ try {
35
+ const pkg = JSON.parse(readFileSync(p, 'utf-8'));
36
+ if (pkg.name === 'gipity')
37
+ return pkg;
38
+ }
39
+ catch { /* keep walking */ }
40
+ }
41
+ if (dirname(d) === d)
42
+ return {};
43
+ }
44
+ }
45
+ const __clPkg = readOwnPkg(__clDir);
46
+ /** Report a sync run to the user. Beyond the applied-changes line, this SURFACES
47
+ * sync.errors - a download that came back incomplete (truncated bulk tar, a file
48
+ * that couldn't be refetched) lands here, so we never print "ready" over a
49
+ * half-synced project as if nothing went wrong. Deletions are disarmed on an
50
+ * incomplete pull, so we can tell the user plainly that nothing was deleted. */
51
+ function reportSyncResult(result) {
52
+ if (result.aborted) {
53
+ console.log(` ${warning('Merge cancelled — this folder was NOT synced with the project.')}`);
54
+ console.log(` ${muted('For a clean copy, quit and open the project in an empty folder. To merge anyway, run `gipity sync`.')}`);
55
+ return;
56
+ }
57
+ if (result.applied > 0) {
58
+ console.log(` Synced ${result.applied} change${result.applied > 1 ? 's' : ''} with Gipity.`);
59
+ }
60
+ if (result.errors.length) {
61
+ console.log(` ${warning(`Sync finished with ${result.errors.length} problem${result.errors.length === 1 ? '' : 's'} — your local copy may be incomplete:`)}`);
62
+ for (const e of result.errors.slice(0, 8))
63
+ console.log(` - ${e}`);
64
+ if (result.errors.length > 8)
65
+ console.log(` …and ${result.errors.length - 8} more.`);
66
+ console.log(` ${muted('Nothing was deleted. Re-run `gipity sync` to finish the pull.')}`);
67
+ }
68
+ }
69
+ import { getProjectsRoot } from '../relay/paths.js';
70
+ /** Ask the server for recursive VFS counts. Server owns the file metadata
71
+ * (counts, bytes, paths), so a single aggregate query beats walking the
72
+ * local filesystem - which only sees depth 1 without recursion and led
73
+ * to the "1 top-level entry (src/)" bug for scaffolded projects where
74
+ * everything is under src/.
75
+ *
76
+ * Returns a best-effort local fallback if the API call fails (offline,
77
+ * auth missing, etc.) so the prompt still builds. */
78
+ export async function fetchProjectStats(projectGuid, cwd) {
79
+ if (projectGuid) {
80
+ try {
81
+ const res = await get(`/projects/${encodeURIComponent(projectGuid)}/files/stats`);
82
+ const d = res.data;
83
+ const topLevelNames = d.top_level.map(e => e.type === 'directory' ? `${e.name}/` : e.name);
84
+ return {
85
+ fileCount: d.file_count,
86
+ folderCount: d.folder_count,
87
+ totalBytes: d.total_bytes,
88
+ topLevel: topLevelNames.length ? topLevelNames.slice(0, 20).join(', ') : '(empty directory)',
89
+ };
90
+ }
91
+ catch { /* fall through to local walk */ }
92
+ }
93
+ return localFsFallback(cwd);
94
+ }
95
+ /** Local-filesystem fallback for when the stats API isn't reachable.
96
+ * Recursive walk (unlike the old top-level-only version). Caps entries to
97
+ * keep the prompt bounded. */
98
+ function localFsFallback(dir) {
99
+ let fileCount = 0;
100
+ let folderCount = 0;
101
+ let totalBytes = 0;
102
+ const topLevelEntries = [];
103
+ const walk = (d, depth) => {
104
+ try {
105
+ for (const name of readdirSync(d).sort()) {
106
+ if (isSyncIgnored(name))
107
+ continue;
108
+ let isDir = false;
109
+ let size = 0;
110
+ try {
111
+ const st = statSync(join(d, name));
112
+ isDir = st.isDirectory();
113
+ size = st.isFile() ? st.size : 0;
114
+ }
115
+ catch {
116
+ continue;
117
+ }
118
+ if (isDir) {
119
+ folderCount++;
120
+ if (depth === 0)
121
+ topLevelEntries.push(`${name}/`);
122
+ walk(join(d, name), depth + 1);
123
+ }
124
+ else {
125
+ fileCount++;
126
+ totalBytes += size;
127
+ if (depth === 0)
128
+ topLevelEntries.push(name);
129
+ }
130
+ }
131
+ }
132
+ catch { /* unreadable */ }
133
+ };
134
+ walk(dir, 0);
135
+ const topLevel = topLevelEntries.length
136
+ ? topLevelEntries.slice(0, 20).join(', ') + (topLevelEntries.length > 20 ? ', …' : '')
137
+ : '(empty directory)';
138
+ return { fileCount, folderCount, totalBytes, topLevel };
139
+ }
140
+ async function buildProjectContextBlock(opts) {
141
+ const stats = await fetchProjectStats(opts.projectGuid, opts.cwd);
142
+ return buildProjectContextBlockText({ ...opts, ...stats });
143
+ }
144
+ // Interactive email+code login now lives in `login-flow.ts` (shared with
145
+ // `gipity setup`). Used on first login and when the server returns 401
146
+ // mid-command (session expired).
147
+ // First-run relay onboarding now lives in `relay/onboarding.ts`
148
+ // (`maybeOfferRelayOn`). `gipity claude` invokes it after project
149
+ // selection, and also calls `ensureDaemonRunning` unconditionally before
150
+ // launching Claude Code so a paired user doesn't have to think about it.
151
+ /** Format a millisecond duration as hh:mm:ss.s (one decimal on seconds). */
152
+ function formatElapsed(ms) {
153
+ const totalSec = ms / 1000;
154
+ const h = Math.floor(totalSec / 3600);
155
+ const m = Math.floor((totalSec % 3600) / 60);
156
+ const s = totalSec % 60;
157
+ const pad2 = (n) => String(n).padStart(2, '0');
158
+ return `${pad2(h)}:${pad2(m)}:${s.toFixed(1).padStart(4, '0')}`;
159
+ }
160
+ /** Pre-accept Claude Code's per-directory workspace-trust dialog for a
161
+ * Gipity-managed project directory. `gipity claude` only ever launches the
162
+ * agent in a project the user created or explicitly chose, so the dialog is
163
+ * pure friction here - and a fresh `project-NNN` dir is untrusted on every
164
+ * interactive run.
165
+ *
166
+ * Writes `projects["<dir>"].hasTrustDialogAccepted = true` into
167
+ * `~/.claude.json` - the exact entry Claude records when the user clicks
168
+ * "Yes". Purely additive (a path Claude hadn't seen); every other key is
169
+ * preserved, and the write is atomic (temp + rename) so a crash can't
170
+ * truncate the file. Best-effort: a malformed or unreadable config is left
171
+ * untouched and never blocks the launch. Headless `-p` runs skip the dialog
172
+ * already, so this is only needed for interactive launches. */
173
+ /** True when the Claude Code argv asks for stream-json output (both the
174
+ * `--output-format stream-json` and `--output-format=stream-json` forms).
175
+ * Combined with an inherited GIPITY_CONVERSATION_GUID this identifies a
176
+ * relay-daemon spawn, where the daemon owns capture and lifecycle-hook
177
+ * capture must stand down. Exported for tests. */
178
+ export function hasStreamJsonFlag(args) {
179
+ const idx = args.indexOf('--output-format');
180
+ if (idx !== -1 && args[idx + 1] === 'stream-json')
181
+ return true;
182
+ return args.includes('--output-format=stream-json');
183
+ }
184
+ export function markFolderTrusted(dir) {
185
+ try {
186
+ const file = join(homedir(), '.claude.json');
187
+ const cfg = existsSync(file)
188
+ ? JSON.parse(readFileSync(file, 'utf-8'))
189
+ : {};
190
+ if (typeof cfg !== 'object' || cfg === null)
191
+ return;
192
+ if (typeof cfg.projects !== 'object' || cfg.projects === null)
193
+ cfg.projects = {};
194
+ const entry = typeof cfg.projects[dir] === 'object' && cfg.projects[dir] !== null
195
+ ? cfg.projects[dir]
196
+ : {};
197
+ if (entry.hasTrustDialogAccepted === true)
198
+ return; // already trusted - no write
199
+ entry.hasTrustDialogAccepted = true;
200
+ cfg.projects[dir] = entry;
201
+ const tmp = `${file}.gipity-tmp`;
202
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2));
203
+ renameSync(tmp, file);
204
+ }
205
+ catch {
206
+ // A config-file hiccup must never block launching the agent.
207
+ }
208
+ }
209
+ function suggestProjectName(existingSlugs) {
210
+ // Canonical format: `project-NNN` (3-digit, zero-padded, hyphenated).
211
+ // Same shape the web "+ New Project" button uses, so both entry points
212
+ // produce identical-looking projects.
213
+ //
214
+ // We scan for both the canonical `project-NNN` and the legacy `projectNN`
215
+ // slugs so the suggested number stays monotonically ahead of whatever
216
+ // already exists in the user's account.
217
+ const slugSet = new Set(existingSlugs);
218
+ let maxSeen = 0;
219
+ for (const slug of existingSlugs) {
220
+ const m = /^project-?(\d+)$/.exec(slug);
221
+ if (m) {
222
+ const n = parseInt(m[1], 10);
223
+ if (n > maxSeen)
224
+ maxSeen = n;
225
+ }
226
+ }
227
+ const start = maxSeen + 1;
228
+ for (let i = start; i <= 9999; i++) {
229
+ const candidate = `project-${String(i).padStart(3, '0')}`;
230
+ if (!slugSet.has(candidate))
231
+ return candidate;
232
+ }
233
+ return `project-${Date.now().toString(36).slice(-6)}`;
234
+ }
235
+ /** Both launch commands share one implementation: `gipity build` (the
236
+ * documented start-from-anywhere funnel: project picker + agent picker +
237
+ * model picker) and the hidden legacy `gipity claude` (identical behavior
238
+ * with the agent pinned to Claude and no agent/model questions - kept
239
+ * working forever because deployed relay daemons and the GUI installer
240
+ * invoke it, but no longer advertised anywhere). */
241
+ function createLaunchCommand(name, cfg = {}) {
242
+ const cmd = new Command(name)
243
+ .description(cfg.presetAgent ? 'Set up and run Claude Code (legacy alias of `gipity build`)' : 'Start building from anywhere - pick a project, pick your coding agent, go')
244
+ .option('--setup-only', 'Do the Gipity setup but skip launching the agent')
245
+ .option('--new-project', 'Create a fresh Gipity project instead of using cwd or the picker')
246
+ .option('--name <name>', 'Name for --new-project (default: project-NNN)')
247
+ .option('--project <slug>', 'Open an existing project by slug or id')
248
+ .option('--here', 'Use the current directory instead of ~/GipityProjects/<slug>/')
249
+ .option('--quiet', "Suppress the agent's live progress output (headless --new-project/--project runs)")
250
+ // Forwarded to the agent via the unknown-arg passthrough below (NOT in the
251
+ // gipity strip lists). Declared here only so it shows up in --help — without
252
+ // this, callers can't discover that the session model is selectable.
253
+ .option('--model <model>', 'Model for the session (e.g. opus, sonnet - or skip it to use your agent\'s own default)')
254
+ .option('--bypass-approvals', 'Skip the agent\'s interactive tool approvals (headless runs; the relay daemon sets this)')
255
+ .allowUnknownOption(true)
256
+ .allowExcessArguments(true);
257
+ if (!cfg.presetAgent) {
258
+ cmd.option('--agent <agent>', `Which coding agent to launch: ${AGENT_KEYS.join(', ')} (default: your last-used)`);
259
+ }
260
+ cmd.action(async (opts) => { await runLaunch(name, cfg, opts); });
261
+ return cmd;
262
+ }
263
+ export const buildCommand = createLaunchCommand('build');
264
+ export const claudeCommand = createLaunchCommand('claude', { presetAgent: 'claude' });
265
+ async function runLaunch(cmdName, cmdCfg, opts) {
266
+ try {
267
+ const runStart = Date.now();
268
+ // Non-interactive passthrough: `gipity build -p "msg"` (and --print)
269
+ // routes directly to the agent's headless mode after the normal setup
270
+ // (auth, hooks, sync). Used by the relay daemon to dispatch messages
271
+ // from the web CLI into a local agent session without a human at the
272
+ // terminal. Requires an existing .gipity.json - we can't interactively
273
+ // pick or create a project in this mode.
274
+ const rawArgs = process.argv.slice(process.argv.indexOf(cmdName) + 1);
275
+ // `gipity build install` - explicit, GUI-callable Claude Code install.
276
+ // Handled here (leading positional) rather than as a Commander subcommand
277
+ // because this command uses allowUnknownOption/allowExcessArguments to
278
+ // pass everything through to the agent; a real subcommand would risk that
279
+ // passthrough (which the relay daemon's headless dispatches depend on).
280
+ if (rawArgs[0] === 'install') {
281
+ const r = ensureClaudeInstalled({ force: rawArgs.includes('--force') });
282
+ if (r.alreadyPresent)
283
+ console.log(` ${success('Claude Code already installed.')}`);
284
+ else if (r.installed)
285
+ console.log(` ${success('Claude Code installed.')}`);
286
+ else
287
+ console.log(` ${clrError('Could not install Claude Code.')} Install manually: npm install -g ${CLAUDE_PACKAGE}`);
288
+ process.exit(r.installed ? 0 : 1);
289
+ }
290
+ const nonInteractive = rawArgs.some(a => a === '-p' || a === '--print' || a.startsWith('--print=') || a.startsWith('-p='));
291
+ // Headless progress emitter: routes to stderr (stdout stays clean for
292
+ // the child's result), drops the interactive 2-space indent, collapses
293
+ // blank-line runs, and emits one leading blank to frame the block.
294
+ const headlessOut = (() => {
295
+ let started = false;
296
+ let prevBlank = false;
297
+ return (text) => {
298
+ for (const raw of String(text).split('\n')) {
299
+ const line = raw.replace(/^[ \t]+/, '');
300
+ const blank = line === '';
301
+ if (!started) {
302
+ process.stderr.write('\n');
303
+ started = true;
304
+ prevBlank = true;
305
+ }
306
+ if (blank && prevBlank)
307
+ continue;
308
+ process.stderr.write(`${line}\n`);
309
+ prevBlank = blank;
310
+ }
311
+ };
312
+ })();
313
+ // In non-interactive mode, all banner/progress output goes through the
314
+ // headless emitter (stderr) so the child's stream-json stays clean.
315
+ if (nonInteractive) {
316
+ console.log = (...args) => headlessOut(args.map(a => (typeof a === 'string' ? a : String(a))).join(' '));
317
+ }
318
+ if (opts.newProject && opts.project) {
319
+ console.error(` ${clrError('Use --new-project or --project, not both.')}`);
320
+ process.exit(1);
321
+ }
322
+ // ── Step 1: Auth ──────────────────────────────────────────────────
323
+ let auth = getAuth();
324
+ if (nonInteractive && !auth) {
325
+ console.error(` ${clrError('Not logged in. Run: gipity login')}`);
326
+ process.exit(1);
327
+ }
328
+ if (nonInteractive && !getConfig() && !opts.newProject && !opts.project) {
329
+ console.error(` ${clrError('No Gipity project in cwd. Run `gipity build` (interactive) first, or pass --new-project / --project <slug>.')}`);
330
+ process.exit(1);
331
+ }
332
+ if (!nonInteractive) {
333
+ printBanner({ version: __clPkg.version ?? 'unknown', email: auth?.email, cwd: process.cwd() });
334
+ }
335
+ // A GIPITY_TOKEN env token authenticates every request on its own, so the
336
+ // saved session's expiry is irrelevant when one is set — never re-login or
337
+ // warn about expiry in that case.
338
+ const hasEnvToken = Boolean(process.env.GIPITY_TOKEN?.trim());
339
+ // Renew the access token UP FRONT so the login line we're about to print is
340
+ // actually true — in BOTH interactive and headless/relay runs. A saved
341
+ // session can look valid locally (refresh token not past its 7-day JWT
342
+ // expiry, so sessionExpired() is false) yet be dead on the server: a sibling
343
+ // process sharing this auth.json already consumed the single-use refresh
344
+ // token (GipRunner and the relay run many gipity processes against one auth
345
+ // dir), the user logged in elsewhere, or — in dev — the server's session
346
+ // store was flushed/restarted. refreshTokenIfNeeded() round-trips to the
347
+ // server and adopts any freshly-rotated token; if it fails, the access token
348
+ // stays expired and accessTokenExpired() catches it below. Headless runs
349
+ // USED to skip this and print "Logged in" off the cheap local check alone —
350
+ // then the very next sync would 401 with "Session expired" and the relay
351
+ // would run Claude anyway against a tree it could neither pull nor push.
352
+ if (auth && !hasEnvToken && !sessionExpired()) {
353
+ await refreshTokenIfNeeded();
354
+ auth = getAuth();
355
+ }
356
+ // Re-login is genuinely required when the refresh token has lapsed, OR the
357
+ // proactive renewal above could not produce a still-valid access token
358
+ // (refresh token rejected — rotated away, revoked, or the server forgot it).
359
+ // Checked identically for headless and interactive now: a dead session must
360
+ // not silently proceed in either. Never triggered while a GIPITY_TOKEN env
361
+ // token is supplying auth.
362
+ const reloginRequired = auth != null && !hasEnvToken &&
363
+ (sessionExpired() || accessTokenExpired());
364
+ if (auth && reloginRequired && nonInteractive) {
365
+ // Headless (relay dispatch, CI): we can't prompt for a re-login, and
366
+ // running Claude now would work against a project tree we can neither pull
367
+ // nor push (every authenticated call 401s). Fail fast with the actionable
368
+ // message instead of printing "Logged in" and silently losing the user's
369
+ // changes. The relay surfaces this stderr line to the web CLI as the
370
+ // dispatch error, so the user sees "run: gipity login" instead of a run
371
+ // that appeared to work but synced nothing.
372
+ console.error(` ${clrError('Your Gipity session has expired. Run: gipity login')}`);
373
+ process.exit(1);
374
+ }
375
+ if (auth && reloginRequired) {
376
+ // The saved session is dead (refresh token lapsed, or rotated away by a
377
+ // sibling process), so the first API call below would 401. Re-login up
378
+ // front instead of printing "Logged in" and immediately contradicting it
379
+ // with "session expired" once the projects fetch fails.
380
+ console.log(` ${muted('Your session expired. Let\'s sign you back in.')}\n`);
381
+ auth = await interactiveLogin();
382
+ }
383
+ else if (auth) {
384
+ console.log(` Logged in (${auth.email}).`);
385
+ }
386
+ else {
387
+ console.log(' Let\'s get you logged in.\n');
388
+ auth = await interactiveLogin();
389
+ }
390
+ console.log('');
391
+ // ── Step 1b: Relay first-run onboarding (account-scoped, runs before project) ──
392
+ if (!nonInteractive) {
393
+ await maybeOfferRelayOn();
394
+ }
395
+ if (!nonInteractive && relayState.isRelayEnabled() && !relayState.isPaused()) {
396
+ ensureDaemonRunning();
397
+ }
398
+ // ── Step 2: Project ───────────────────────────────────────────────
399
+ let initialPrompt = '';
400
+ let headlessNewProject = false;
401
+ // One reporter for the (otherwise silent) sync phases + upload bar, so a
402
+ // long sync of a large tree no longer reads as a hang. Stays silent in
403
+ // headless (-p) runs to keep machine-readable output clean.
404
+ const syncProgress = nonInteractive ? undefined : createProgressReporter();
405
+ let existing = getConfig();
406
+ let forceAdoptCwd = false;
407
+ // Ambiguity guard: a `.gipity.json` inherited from an ANCESTOR directory
408
+ // (cwd has none of its own) plus an empty cwd is genuinely ambiguous -
409
+ // it could mean "work inside the parent project" or "start a new project
410
+ // here". Ask, rather than silently adopting the parent. Flag-driven runs
411
+ // (--project / --new-project) and headless (-p) runs are unambiguous.
412
+ if (existing && !opts.newProject && !opts.project && !nonInteractive) {
413
+ const cwdHasConfig = existsSync(resolve(process.cwd(), '.gipity.json'));
414
+ if (!cwdHasConfig && isLikelyEmpty(process.cwd())) {
415
+ const ancestorRoot = dirname(getConfigPath());
416
+ console.log(` ${bold('You are inside an existing Gipity project.')}\n`);
417
+ console.log(` ${bold('1.')} Continue in ${brand(existing.projectSlug)} ${muted(`(rooted at ${formatCwdLabel(ancestorRoot)})`)}`);
418
+ console.log(` ${bold('2.')} Create a new project here ${muted(`(${formatCwdLabel(process.cwd())})`)}`);
419
+ console.log('');
420
+ const choice = await pickOne('Choose', 2, 1);
421
+ console.log('');
422
+ if (choice === 2) {
423
+ existing = null;
424
+ forceAdoptCwd = true;
425
+ clearConfigCache();
426
+ }
427
+ }
428
+ }
429
+ if (opts.newProject || opts.project) {
430
+ // Flag-driven resolution: create or open a named project without the
431
+ // interactive picker. Powers single-command runs such as
432
+ // `gipity claude --new-project -p "..."` and `--project <slug> -p`.
433
+ // Fetch the account's projects - needed to suggest/validate a name
434
+ // for --new-project and to resolve the target for --project.
435
+ let projects;
436
+ try {
437
+ const res = await get('/projects?limit=1000');
438
+ projects = res.data;
439
+ }
440
+ catch (err) {
441
+ if (err instanceof ApiError && err.statusCode === 401) {
442
+ // Don't clearAuth() — the file is shared with concurrent gipity
443
+ // processes (sync hook, relay); deleting it logs them all out. A
444
+ // re-login overwrites it anyway. Leave it and just point the user.
445
+ console.error(` ${clrError('Your session expired.')} ${muted('Run: gipity login')}`);
446
+ }
447
+ else {
448
+ console.error(` ${clrError(`Could not load projects: ${err?.message || err}`)}`);
449
+ }
450
+ process.exit(1);
451
+ return;
452
+ }
453
+ const existingSlugs = projects.map(p => p.slug);
454
+ let project;
455
+ let isNewProject = false;
456
+ if (opts.newProject) {
457
+ const explicit = Boolean(opts.name);
458
+ const presetName = explicit ? String(opts.name).trim() : suggestProjectName(existingSlugs);
459
+ project = await createNewProject(existingSlugs, { name: presetName, explicit });
460
+ isNewProject = true;
461
+ }
462
+ else {
463
+ const target = String(opts.project);
464
+ const found = projects.find(p => p.slug === target || p.short_guid === target);
465
+ if (!found) {
466
+ console.error(` ${clrError(`No project matching "${target}".`)} ${muted('List projects with: gipity project')}`);
467
+ process.exit(1);
468
+ return;
469
+ }
470
+ project = found;
471
+ }
472
+ // Materialize the project directory, chdir in, write config, sync.
473
+ // Default: ~/GipityProjects/<slug>/. With --here: the current dir.
474
+ // For --project this pulls the server's files into that dir.
475
+ const projectDir = opts.here ? process.cwd() : join(getProjectsRoot(), project.slug);
476
+ mkdirSync(projectDir, { recursive: true });
477
+ process.chdir(projectDir);
478
+ clearConfigCache();
479
+ let agentGuid = '';
480
+ try {
481
+ const agents = await get(`/projects/${project.short_guid}/agents`);
482
+ if (agents.data.length > 0)
483
+ agentGuid = agents.data[0].short_guid;
484
+ }
485
+ catch { /* no agents */ }
486
+ const accountSlug = await getAccountSlug();
487
+ saveConfigAt(projectDir, {
488
+ projectGuid: project.short_guid,
489
+ projectSlug: project.slug,
490
+ accountSlug,
491
+ agentGuid,
492
+ conversationGuid: null,
493
+ apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
494
+ ignore: DEFAULT_SYNC_IGNORE,
495
+ });
496
+ console.log(`\n Using ${projectDir}`);
497
+ try {
498
+ const result = await sync({ interactive: !nonInteractive, confirmMerge: !nonInteractive, progress: syncProgress });
499
+ reportSyncResult(result);
500
+ }
501
+ catch (err) {
502
+ console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
503
+ }
504
+ setupProjectTools();
505
+ if (nonInteractive) {
506
+ // Headless: the -p message is wrapped later (Step 3). Just record
507
+ // whether to use the new-project framing for that wrap.
508
+ headlessNewProject = isNewProject;
509
+ }
510
+ else {
511
+ // Interactive: launch with no seeded first message. The per-project
512
+ // CLAUDE.md (refreshed just above) carries the project identity,
513
+ // scaffold rule, and definition of done, so Claude has full context
514
+ // the moment the user types. A welcome banner prints before launch.
515
+ initialPrompt = '';
516
+ }
517
+ console.log(` ${success(`Project "${project.name}" ready.`)}\n`);
518
+ }
519
+ else if (existing) {
520
+ // cwd already has (or inherits) a .gipity.json - run inside it.
521
+ console.log(` Project: ${brand(existing.projectSlug)} ${muted(`(${existing.projectGuid})`)}`);
522
+ console.log(` ${success('Already set up.')}\n`);
523
+ setupProjectTools();
524
+ // Warn before syncing a very large project tree. An oversized root
525
+ // (or a config accidentally rooted high up, e.g. at $HOME) makes the
526
+ // sync slow, and a silent multi-GB walk reads as a hang.
527
+ let doSync = true;
528
+ const projectRoot = dirname(getConfigPath());
529
+ const scan = scanForAdoption(projectRoot);
530
+ if (scan.tier === 'refuse' && !nonInteractive) {
531
+ const sizeStr = scan.truncated ? `>${formatBytes(ADOPT_THRESHOLDS.REFUSE_BYTES)}` : formatBytes(scan.bytes);
532
+ const fileStr = scan.truncated ? `>${ADOPT_THRESHOLDS.REFUSE_FILES}` : `${scan.files}`;
533
+ console.log(` ${clrError(`Large project: ${fileStr} files (${sizeStr}) under ${formatCwdLabel(projectRoot)}.`)}`);
534
+ console.log(` ${muted('Syncing this many files with Gipity may be slow.')}`);
535
+ doSync = await confirm(' Sync with Gipity now?', { default: 'yes' });
536
+ console.log('');
537
+ }
538
+ if (doSync) {
539
+ try {
540
+ const result = await sync({ interactive: !nonInteractive, confirmMerge: !nonInteractive, progress: syncProgress });
541
+ reportSyncResult(result);
542
+ }
543
+ catch (err) {
544
+ console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
545
+ }
546
+ }
547
+ // Interactive: no seeded prompt - CLAUDE.md carries the context now.
548
+ initialPrompt = '';
549
+ }
550
+ else {
551
+ // Fetch user's projects. If the session expired (401), re-run the
552
+ // interactive login and retry once - no reason to kick the user out
553
+ // to a shell just to re-run the same command.
554
+ let projects = [];
555
+ let reauthed = false;
556
+ // When the user already chose "create a new project here", skip the
557
+ // project list entirely - we go straight to the adopt-cwd path.
558
+ while (!forceAdoptCwd) {
559
+ try {
560
+ const res = await get('/projects?limit=1000');
561
+ projects = res.data;
562
+ break;
563
+ }
564
+ catch (err) {
565
+ const isConnectionError = err?.code === 'ECONNREFUSED' || err?.code === 'ENOTFOUND' ||
566
+ err?.code === 'ETIMEDOUT' || err?.cause?.code === 'ECONNREFUSED' ||
567
+ err?.cause?.code === 'ENOTFOUND' || err?.cause?.code === 'ETIMEDOUT';
568
+ if (isConnectionError) {
569
+ const apiBase = getApiBaseOverride() || DEFAULT_API_BASE;
570
+ console.error(` ${clrError(`Could not connect to ${apiBase}`)}`);
571
+ console.error(` ${muted('Check your connection and try again.')}`);
572
+ process.exit(1);
573
+ }
574
+ if (err instanceof ApiError && err.statusCode === 401 && !reauthed && !nonInteractive) {
575
+ // No clearAuth(): interactiveLogin() saves fresh tokens over the
576
+ // file. Deleting it first would log out any sibling process (sync
577
+ // hook, relay) sharing this ~/.gipity/auth.json mid-run.
578
+ console.log(` ${muted('Your session expired. Let\'s sign you back in.')}\n`);
579
+ auth = await interactiveLogin();
580
+ console.log('');
581
+ reauthed = true;
582
+ continue;
583
+ }
584
+ if (err instanceof ApiError && err.statusCode === 401) {
585
+ // Leave the shared auth.json in place (see above) — just message.
586
+ console.error(` ${clrError('Your session expired.')}`);
587
+ console.error(` ${muted('Run: gipity login')}`);
588
+ process.exit(1);
589
+ }
590
+ console.error(` ${clrError(`Could not load projects: ${err?.message || err}`)}`);
591
+ process.exit(1);
592
+ }
593
+ }
594
+ const existingSlugs = projects.map(p => p.slug);
595
+ let project;
596
+ let isNewProject = false;
597
+ let accountSlug = '';
598
+ let agentGuid = '';
599
+ const result = forceAdoptCwd
600
+ ? { kind: 'adopt-cwd' }
601
+ : projects.length > 0
602
+ ? await pickOrCreateProject(projects, existingSlugs)
603
+ : { kind: 'create-new', project: await createNewProject(existingSlugs) };
604
+ if (result.kind === 'adopt-cwd') {
605
+ // User chose "Use this directory" - derive a slug from the cwd
606
+ // basename, find-or-create the server project, write
607
+ // .gipity.json into cwd (no projects-root materialization).
608
+ const cwdBase = basename(process.cwd());
609
+ const adoptSlug = slugify(cwdBase) || 'project';
610
+ // Server caps name at 100 chars; clamp so a very long dir name
611
+ // doesn't trip a "Too big" 400 (slugify already clamps the slug).
612
+ const adoptName = (cwdBase || adoptSlug).slice(0, 100);
613
+ accountSlug = await getAccountSlug();
614
+ const adopted = await adoptCurrentDir({
615
+ cwd: process.cwd(),
616
+ projectName: adoptName,
617
+ projectSlug: adoptSlug,
618
+ accountSlug,
619
+ confirmDeletions: !nonInteractive,
620
+ });
621
+ project = adopted.project;
622
+ agentGuid = adopted.agentGuid;
623
+ // Treat as "new project" for the build prompt only when cwd is
624
+ // genuinely empty - otherwise the user has chosen to adopt
625
+ // existing content and shouldn't be asked "what to build?".
626
+ isNewProject = isLikelyEmpty(process.cwd());
627
+ console.log(`\n Using ${process.cwd()}`);
628
+ if (adopted.applied > 0) {
629
+ console.log(` Synced ${adopted.applied} change${adopted.applied > 1 ? 's' : ''} with Gipity.`);
630
+ }
631
+ }
632
+ else {
633
+ project = result.project;
634
+ isNewProject = result.kind === 'create-new';
635
+ // Resolve project directory under ~/GipityProjects/{slug}
636
+ const projectDir = join(getProjectsRoot(), project.slug);
637
+ mkdirSync(projectDir, { recursive: true });
638
+ process.chdir(projectDir);
639
+ clearConfigCache();
640
+ // Fetch agents
641
+ try {
642
+ const agents = await get(`/projects/${project.short_guid}/agents`);
643
+ if (agents.data.length > 0)
644
+ agentGuid = agents.data[0].short_guid;
645
+ }
646
+ catch {
647
+ // No agents
648
+ }
649
+ accountSlug = await getAccountSlug();
650
+ // Always write config (refresh stale GUIDs from a previous setup)
651
+ saveConfigAt(projectDir, {
652
+ projectGuid: project.short_guid,
653
+ projectSlug: project.slug,
654
+ accountSlug,
655
+ agentGuid,
656
+ conversationGuid: null,
657
+ apiBase: getApiBaseOverride() || DEFAULT_API_BASE,
658
+ ignore: DEFAULT_SYNC_IGNORE,
659
+ });
660
+ console.log(`\n Using ${projectDir}`);
661
+ // Unified sync - push and pull resolved via three-way merge (non-fatal)
662
+ try {
663
+ const result = await sync({ interactive: !nonInteractive, confirmMerge: !nonInteractive, progress: syncProgress });
664
+ reportSyncResult(result);
665
+ }
666
+ catch (err) {
667
+ console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
668
+ }
669
+ }
670
+ // Interactive launch: no seeded first message (new or existing). The
671
+ // per-project CLAUDE.md carries identity + scaffold rule + definition
672
+ // of done, and a welcome banner prints before launch - the user tells
673
+ // Claude directly what they want to build or do.
674
+ initialPrompt = '';
675
+ setupProjectTools();
676
+ console.log(` ${success(`Project "${project.name}" ready.`)}\n`);
677
+ }
678
+ // ── Step 3: Pick the coding agent + model ─────────────────────────
679
+ if (opts.setupOnly) {
680
+ console.log(` Done. cd ${process.cwd()} && gipity ${cmdName}`);
681
+ return;
682
+ }
683
+ const prefs = readPrefs();
684
+ let agentKey = cmdCfg.presetAgent ?? (typeof opts.agent === 'string' ? opts.agent.toLowerCase() : undefined);
685
+ if (agentKey && !AGENT_KEYS.includes(agentKey)) {
686
+ console.error(` ${clrError(`Unknown agent "${agentKey}".`)} ${muted(`Valid: ${AGENT_KEYS.join(', ')}`)}`);
687
+ process.exit(1);
688
+ }
689
+ if (!agentKey) {
690
+ if (nonInteractive) {
691
+ // Headless never prompts: --agent, else last-used, else Claude.
692
+ agentKey = prefs.lastAgent && AGENT_KEYS.includes(prefs.lastAgent) ? prefs.lastAgent : 'claude';
693
+ }
694
+ else {
695
+ agentKey = await pickAgent(prefs.lastAgent);
696
+ }
697
+ }
698
+ const adapter = getAdapter(agentKey);
699
+ // Model: an explicit --model (in the passthrough args) wins and skips
700
+ // the question. Interactive launches get the picker - option 1 is
701
+ // "Agent default", which passes NO model flag so the agent's own model
702
+ // memory stays authoritative. Headless runs never prompt. Only an
703
+ // interactive answer is remembered (a daemon dispatch or the legacy
704
+ // `gipity claude` shim must not clobber the user's picker memory).
705
+ const modelFlagGiven = rawArgs.some(a => a === '--model' || a.startsWith('--model='));
706
+ let pickedModel = null;
707
+ if (!nonInteractive && !modelFlagGiven && !cmdCfg.presetAgent) {
708
+ pickedModel = await pickModel(adapter, prefs.lastModel?.[adapter.key] ?? null);
709
+ writePrefs({ lastAgent: adapter.key, lastModel: { [adapter.key]: pickedModel } });
710
+ }
711
+ // ── Step 4: Launch the agent ───────────────────────────────────────
712
+ if (adapter.key !== 'claude') {
713
+ await launchNonClaudeAgent(adapter, {
714
+ cmdName, opts, rawArgs, nonInteractive, headlessOut, runStart, pickedModel,
715
+ });
716
+ return;
717
+ }
718
+ // Ensure Claude Code is installed - install it ourselves if missing so a
719
+ // GUI/installer (and terminal users) get it for free, rather than being
720
+ // told to run npm by hand.
721
+ if (!isClaudeInstalled()) {
722
+ console.log(' Claude Code not found - installing it now...');
723
+ const r = ensureClaudeInstalled();
724
+ if (!r.installed) {
725
+ console.log(` ${clrError('Could not install Claude Code.')} Install manually: npm install -g ${CLAUDE_PACKAGE}`);
726
+ console.log(` Then: cd ${process.cwd()} && claude`);
727
+ return;
728
+ }
729
+ console.log(` ${success('Claude Code installed.')}`);
730
+ }
731
+ // Ensure the Gipity plugin is actually installed at user scope (not just
732
+ // enabled declaratively) so its capture + file-sync hooks load in this
733
+ // run's cwd. No-ops once the current version is installed; best-effort.
734
+ ensureGipityPluginInstalled();
735
+ // Resolve (or create) the backing Gipity conversation for this
736
+ // Claude Code run. The conv_guid is handed to the child (and thus
737
+ // every capture hook spawned by Claude Code) via env var so every
738
+ // capture event is explicitly tagged to the right conversation.
739
+ //
740
+ // Three paths:
741
+ // 1. Inherited GIPITY_CONVERSATION_GUID - the relay daemon already
742
+ // created the conv and spawned us with it set. Just pass it
743
+ // through. Creating another would orphan the dispatch's conv.
744
+ // 2. `--resume <sid>` - look up the existing conv by Claude Code
745
+ // session_id.
746
+ // 3. Otherwise - create a fresh claude_code conv tied to this
747
+ // paired device.
748
+ //
749
+ // Skipped silently if this machine isn't paired - without a device
750
+ // we can't satisfy the claude_code ownership rule, so hooks stay
751
+ // offline for this run.
752
+ //
753
+ // Note: when this process was spawned BY the relay daemon (inherited
754
+ // GIPITY_CONVERSATION_GUID) with `--output-format stream-json`, the
755
+ // daemon is capturing Claude's stdout directly and hook-based capture
756
+ // would double-post every event. In that case we set GIPITY_CAPTURE=off
757
+ // on the Claude child (see childEnv assignment below) so the hook
758
+ // runner stands down. A USER-supplied stream-json run has no daemon
759
+ // behind it - hooks stay armed there, or the conversation would sit
760
+ // empty forever (the qwen-flight zero-message bug, 2026-07-07).
761
+ const inheritedConvGuid = Boolean(process.env.GIPITY_CONVERSATION_GUID);
762
+ let convGuidForHooks = process.env.GIPITY_CONVERSATION_GUID ?? null;
763
+ if (!convGuidForHooks) {
764
+ const device = relayState.getDevice();
765
+ if (device) {
766
+ const cfg = getConfig();
767
+ const resumeIdx = process.argv.indexOf('--resume');
768
+ const resumeSid = resumeIdx >= 0 ? process.argv[resumeIdx + 1] : null;
769
+ try {
770
+ if (resumeSid) {
771
+ try {
772
+ const found = await get(`/conversations/remote/by-session/${encodeURIComponent(resumeSid)}?source=claude_code`);
773
+ convGuidForHooks = found.data.conversation_guid;
774
+ }
775
+ catch {
776
+ // No existing conv for this session id - fall through and
777
+ // create one so capture still has a home.
778
+ }
779
+ }
780
+ if (!convGuidForHooks && cfg?.projectGuid) {
781
+ // A terminal launch is always origin='local' - marks the
782
+ // conversation as a local-terminal chat so the web CLI renders
783
+ // it read-only. Dispatch convs (created by the relay daemon
784
+ // spawning headless runs) use the default 'dispatch'.
785
+ const created = await post('/conversations/remote', { project_guid: cfg.projectGuid, device_guid: device.guid, source: 'claude_code', origin: 'local' });
786
+ convGuidForHooks = created.data.conversation_guid;
787
+ }
788
+ }
789
+ catch (err) {
790
+ if (!nonInteractive) {
791
+ console.error(` ${clrError(`Could not create Gipity conversation: ${err?.message || err}`)}`);
792
+ }
793
+ }
794
+ }
795
+ }
796
+ // Pass through all unknown args to the agent (everything after the
797
+ // command name). Gipity-level flags are stripped: boolean flags dropped
798
+ // outright, value-bearing flags drop the flag and its value (both
799
+ // `--f v` and `--f=v` forms).
800
+ const cmdIdx = process.argv.indexOf(cmdName);
801
+ const rawClaudeArgs = process.argv.slice(cmdIdx + 1);
802
+ const gipityBooleanFlags = new Set(['--setup-only', '--new-project', '--here', '--quiet', '--bypass-approvals']);
803
+ const gipityValueFlags = ['--api-base', '--name', '--project', '--agent'];
804
+ const claudeArgs = [];
805
+ for (let i = 0; i < rawClaudeArgs.length; i++) {
806
+ const arg = rawClaudeArgs[i];
807
+ if (gipityBooleanFlags.has(arg))
808
+ continue;
809
+ if (gipityValueFlags.includes(arg)) {
810
+ i++;
811
+ continue;
812
+ } // skip flag + its value
813
+ if (gipityValueFlags.some(f => arg.startsWith(`${f}=`)))
814
+ continue;
815
+ claudeArgs.push(arg);
816
+ }
817
+ if (!nonInteractive) {
818
+ console.log(` ${bold('Launching Claude Code, powered by Gipity.')}`);
819
+ console.log(` ${muted("Just tell Claude what you'd like to build or do - everything Claude can do,")}`);
820
+ console.log(` ${muted('plus hosting, databases, and live deploys on Gipity.')}`);
821
+ if (convGuidForHooks) {
822
+ console.log(` ${muted(`This session is saved to Gipity - watch it live at ${brand('prompt.gipity.ai')}. (--no-capture on init to disable.)`)}`);
823
+ }
824
+ console.log('');
825
+ }
826
+ // In non-interactive (-p) mode, prepend a Gipity preamble to the
827
+ // user's raw message. Two flavors:
828
+ // - No --resume → full project context block (project name, files,
829
+ // deploy URL, decision flow). A fresh session has no prior context.
830
+ // - --resume → short framing line only. Claude already has the full
831
+ // context from the original start dispatch; we just remind it which
832
+ // project it's in and to use Gipity tools rather than freelance.
833
+ let allArgs;
834
+ if (nonInteractive) {
835
+ const hasResume = claudeArgs.includes('--resume');
836
+ const pIdx = claudeArgs.findIndex(a => a === '-p' || a === '--print');
837
+ if (pIdx !== -1 && pIdx + 1 < claudeArgs.length) {
838
+ const config = getConfig();
839
+ const userMsg = claudeArgs[pIdx + 1];
840
+ let accountSlug = '';
841
+ try {
842
+ accountSlug = await getAccountSlug();
843
+ }
844
+ catch { /* best effort */ }
845
+ const ctxOpts = {
846
+ projectName: config?.projectSlug ?? 'this project',
847
+ projectSlug: config?.projectSlug ?? '',
848
+ projectGuid: config?.projectGuid ?? '',
849
+ accountSlug,
850
+ cwd: process.cwd(),
851
+ };
852
+ let wrapped;
853
+ if (headlessNewProject) {
854
+ // Brand-new empty project - use the new-project framing so Claude
855
+ // picks a template and scaffolds, with the -p message as the idea.
856
+ const stats = await fetchProjectStats(ctxOpts.projectGuid, ctxOpts.cwd);
857
+ wrapped = buildNewProjectPrompt({ ...ctxOpts, ...stats, buildIdea: userMsg });
858
+ }
859
+ else if (hasResume) {
860
+ // Resume wrap only needs project identity (no file stats), so
861
+ // skip the stats API call on every resumed message.
862
+ wrapped = buildResumeWrap(ctxOpts, userMsg);
863
+ }
864
+ else {
865
+ wrapped = buildFreshWrap(await buildProjectContextBlock(ctxOpts), userMsg);
866
+ }
867
+ allArgs = [...claudeArgs];
868
+ allArgs[pIdx + 1] = wrapped;
869
+ headlessOut(`\nSending to Claude Code: ${userMsg}\n`);
870
+ }
871
+ else {
872
+ allArgs = claudeArgs;
873
+ }
874
+ }
875
+ else {
876
+ allArgs = initialPrompt ? [initialPrompt, ...claudeArgs] : claudeArgs;
877
+ // Model from the interactive picker (an explicit --model flag skipped
878
+ // the picker and is already in claudeArgs; "Agent default" is null and
879
+ // deliberately passes nothing).
880
+ if (pickedModel)
881
+ allArgs.push('--model', pickedModel);
882
+ }
883
+ // Single-command headless flows (`--new-project` / `--project` with -p)
884
+ // have no human to approve tool use - auto-bypass so Claude can actually
885
+ // execute. Same authority as the relay daemon spawning `gipity claude -p`.
886
+ // Skipped if the user already set a permission flag themselves.
887
+ if (nonInteractive && (opts.newProject || opts.project)) {
888
+ const hasPermFlag = allArgs.some(a => a === '--permission-mode' || a.startsWith('--permission-mode=') ||
889
+ a === '--dangerously-skip-permissions');
890
+ if (!hasPermFlag)
891
+ allArgs.push('--permission-mode', 'bypassPermissions');
892
+ }
893
+ // Live progress: a human typed this single-command run, so stream
894
+ // Claude's turn-by-turn output instead of going silent until the final
895
+ // result. Scoped to --new-project/--project, so the relay daemon's own
896
+ // invocation (which never sets those) is unaffected. Skipped if the user
897
+ // opted out (--quiet) or already chose their own verbosity / format.
898
+ if (nonInteractive && (opts.newProject || opts.project) && !opts.quiet) {
899
+ if (!allArgs.includes('--verbose') && !allArgs.includes('--output-format')) {
900
+ allArgs.push('--verbose');
901
+ }
902
+ }
903
+ // Resolve the real path on Windows; spawnCommand then routes the `.cmd`
904
+ // shim through cmd.exe with verbatim args (a bare `spawn` of a `.cmd`
905
+ // throws EINVAL on Node >=18.20.2/20.12.2 - the CVE-2024-27980 fix).
906
+ const claudeCmd = resolveCommand('claude');
907
+ const childEnv = { ...process.env };
908
+ // Gate hook-based capture: only when the DAEMON is streaming via
909
+ // --output-format stream-json (inherited guid = daemon spawn) does it
910
+ // own the capture; any hook post would then be a dupe. GIPITY_CAPTURE=off
911
+ // stands the hook runner down explicitly - merely unsetting the guid is
912
+ // no longer enough now that the runner self-arms from .gipity.json.
913
+ // A user-supplied stream-json run (no inherited guid) keeps hooks armed.
914
+ const daemonCapturing = inheritedConvGuid && hasStreamJsonFlag(allArgs);
915
+ if (daemonCapturing) {
916
+ delete childEnv.GIPITY_CONVERSATION_GUID;
917
+ childEnv.GIPITY_CAPTURE = 'off';
918
+ }
919
+ else if (convGuidForHooks) {
920
+ childEnv.GIPITY_CONVERSATION_GUID = convGuidForHooks;
921
+ }
922
+ // A Gipity project directory is trusted by construction - the user
923
+ // created or explicitly chose it. Pre-accept Claude's per-directory
924
+ // trust dialog so an interactive launch doesn't stop to ask. Headless
925
+ // `-p` runs skip the dialog already, so this is only needed interactively.
926
+ if (!nonInteractive)
927
+ markFolderTrusted(process.cwd());
928
+ const child = spawnCommand(claudeCmd, allArgs, {
929
+ stdio: 'inherit',
930
+ cwd: process.cwd(),
931
+ env: childEnv,
932
+ });
933
+ // A spawn failure (missing/non-executable binary) arrives asynchronously via
934
+ // 'error', which the surrounding try/catch can't catch - without this handler
935
+ // Node crashes with a raw stack trace. claude is normally installed above, so
936
+ // this is a defensive fallback.
937
+ child.on('error', (err) => {
938
+ console.error(`\n ${clrError(err.code === 'ENOENT'
939
+ ? `Claude Code not found. Install it: npm install -g ${CLAUDE_PACKAGE}`
940
+ : `Failed to launch Claude Code: ${err.message}`)}`);
941
+ process.exit(1);
942
+ });
943
+ child.on('exit', (code) => {
944
+ const doneLine = `Done (${formatElapsed(Date.now() - runStart)})`;
945
+ if (nonInteractive)
946
+ process.stderr.write(`\n${doneLine}\n\n`);
947
+ else {
948
+ console.log(`\n ${doneLine}`);
949
+ // Both continue paths are first-class - user preference, not a hierarchy.
950
+ console.log(` ${muted(`Next time: gipity build from anywhere, or cd ${process.cwd()} && claude.`)}`);
951
+ }
952
+ process.exit(code ?? 0);
953
+ });
954
+ }
955
+ catch (err) {
956
+ console.error(`\n ${clrError(`Error: ${err.message}`)}`);
957
+ process.exit(1);
958
+ }
959
+ }
960
+ // ─── Agent + model pickers ────────────────────────────────────────────────
961
+ // Same pick-a-number-or-hit-enter format as the project picker. The
962
+ // enter-enter path must stay the visibly obvious one: default agent =
963
+ // last-used (else Claude), default model = "Agent default".
964
+ async function pickAgent(lastUsed) {
965
+ const defaultKey = lastUsed && AGENT_KEYS.includes(lastUsed) ? lastUsed : 'claude';
966
+ const defaultIdx = AGENT_ADAPTERS.findIndex(a => a.key === defaultKey) + 1;
967
+ console.log(` ${bold('Which coding agent?')}\n`);
968
+ AGENT_ADAPTERS.forEach((a, i) => {
969
+ const installed = binaryOnPath(a.binary) || (a.key === 'claude' && isClaudeInstalled());
970
+ const notes = [];
971
+ if (a.key === lastUsed)
972
+ notes.push('last used');
973
+ if (!installed)
974
+ notes.push(a.ensureInstalled ? "not installed - we'll install it" : 'not installed');
975
+ if (a.key === 'codex' && !a.hooksSupportedOnPlatform(process.platform)) {
976
+ notes.push('session recording unavailable on Windows');
977
+ }
978
+ const note = notes.length ? ` ${muted(`(${notes.join(', ')})`)}` : '';
979
+ console.log(` ${bold(`${i + 1}.`)} ${a.displayName} ${muted(`(${a.providerName})`)}${note}`);
980
+ });
981
+ console.log('');
982
+ const choice = await pickOne('Choose', AGENT_ADAPTERS.length, defaultIdx);
983
+ console.log('');
984
+ return AGENT_ADAPTERS[choice - 1].key;
985
+ }
986
+ /** Returns the model id to pass via --model, or null for "Agent default"
987
+ * (no flag at all - the agent's own model memory stays authoritative). */
988
+ async function pickModel(adapter, lastUsed) {
989
+ console.log(` ${bold('Which model?')}\n`);
990
+ const lastIsModel = lastUsed && adapter.models.some(m => m.id === lastUsed);
991
+ const defaultIdx = lastIsModel ? adapter.models.findIndex(m => m.id === lastUsed) + 2 : 1;
992
+ console.log(` ${bold('1.')} Agent default ${muted(defaultIdx === 1 ? '(recommended)' : '')}`);
993
+ adapter.models.forEach((m, i) => {
994
+ const note = m.id === lastUsed ? ` ${muted('(last used)')}` : '';
995
+ console.log(` ${bold(`${i + 2}.`)} ${m.label}${note}`);
996
+ });
997
+ console.log('');
998
+ const choice = await pickOne('Choose', adapter.models.length + 1, defaultIdx);
999
+ console.log('');
1000
+ return choice === 1 ? null : adapter.models[choice - 2].id;
1001
+ }
1002
+ // ─── Non-Claude agent launch (Codex, Grok) ────────────────────────────────
1003
+ // The Claude path above carries years of Claude-specific plumbing (plugin
1004
+ // install, folder trust, stream-json capture gating, -p context wrapping).
1005
+ // Other agents launch simpler: their project integration (skills, hooks,
1006
+ // AGENTS.md) was installed by setupProjectTools() during the project step,
1007
+ // and session capture rides those hooks - INCLUDING for relay dispatches
1008
+ // (there is no stream-json ingest path for them, so GIPITY_CAPTURE is never
1009
+ // turned off here).
1010
+ async function launchNonClaudeAgent(adapter, ctx) {
1011
+ const { opts, rawArgs, nonInteractive, headlessOut, runStart, pickedModel } = ctx;
1012
+ // ── Binary ──────────────────────────────────────────────────────────────
1013
+ if (!binaryOnPath(adapter.binary)) {
1014
+ let installed = false;
1015
+ if (adapter.ensureInstalled) {
1016
+ console.log(` ${adapter.displayName} not found - installing it now...`);
1017
+ installed = adapter.ensureInstalled();
1018
+ }
1019
+ if (!installed && !binaryOnPath(adapter.binary)) {
1020
+ console.error(` ${clrError(`${adapter.displayName} is not installed.`)} Install it with: ${adapter.installHint}`);
1021
+ console.error(` ${muted(`Then run: gipity ${ctx.cmdName} --agent ${adapter.key}`)}`);
1022
+ process.exit(1);
1023
+ }
1024
+ }
1025
+ // ── Gipity-level flag strip + agent-flag extraction ────────────────────
1026
+ // Same strip contract as the Claude path, plus extraction of the flags the
1027
+ // adapter translates (-p message, --model, --resume): Codex's headless form
1028
+ // is `codex exec …`, not `-p`, so these can't pass through verbatim.
1029
+ const booleanFlags = new Set(['--setup-only', '--new-project', '--here', '--quiet', '--bypass-approvals']);
1030
+ const valueFlags = ['--api-base', '--name', '--project', '--agent'];
1031
+ let message = null;
1032
+ let resume;
1033
+ let model = pickedModel ?? undefined;
1034
+ const extras = [];
1035
+ for (let i = 0; i < rawArgs.length; i++) {
1036
+ const arg = rawArgs[i];
1037
+ if (booleanFlags.has(arg))
1038
+ continue;
1039
+ if (valueFlags.includes(arg)) {
1040
+ i++;
1041
+ continue;
1042
+ }
1043
+ if (valueFlags.some(f => arg.startsWith(`${f}=`)))
1044
+ continue;
1045
+ if (arg === '-p' || arg === '--print') {
1046
+ message = rawArgs[++i] ?? '';
1047
+ continue;
1048
+ }
1049
+ if (arg.startsWith('-p=') || arg.startsWith('--print=')) {
1050
+ message = arg.slice(arg.indexOf('=') + 1);
1051
+ continue;
1052
+ }
1053
+ if (arg === '--model') {
1054
+ model = rawArgs[++i];
1055
+ continue;
1056
+ }
1057
+ if (arg.startsWith('--model=')) {
1058
+ model = arg.slice('--model='.length);
1059
+ continue;
1060
+ }
1061
+ if (arg === '--resume') {
1062
+ resume = rawArgs[++i];
1063
+ continue;
1064
+ }
1065
+ if (arg.startsWith('--resume=')) {
1066
+ resume = arg.slice('--resume='.length);
1067
+ continue;
1068
+ }
1069
+ extras.push(arg);
1070
+ }
1071
+ // ── Conversation binding (session recording) ────────────────────────────
1072
+ // Same three paths as Claude: inherited guid (daemon dispatch), resume
1073
+ // lookup by session id, else create fresh. Hook capture posts the actual
1074
+ // transcript - the env var just pins which conversation it lands in.
1075
+ let convGuid = process.env.GIPITY_CONVERSATION_GUID ?? null;
1076
+ if (!convGuid) {
1077
+ const device = relayState.getDevice();
1078
+ const config = getConfig();
1079
+ if (device && config?.projectGuid) {
1080
+ try {
1081
+ if (resume) {
1082
+ try {
1083
+ const found = await get(`/conversations/remote/by-session/${encodeURIComponent(resume)}?source=${encodeURIComponent(adapter.source)}`);
1084
+ convGuid = found.data.conversation_guid;
1085
+ }
1086
+ catch { /* no existing conv - create below */ }
1087
+ }
1088
+ if (!convGuid) {
1089
+ const created = await post('/conversations/remote', { project_guid: config.projectGuid, device_guid: device.guid, source: adapter.source, origin: 'local' });
1090
+ convGuid = created.data.conversation_guid;
1091
+ }
1092
+ }
1093
+ catch (err) {
1094
+ if (!nonInteractive) {
1095
+ console.error(` ${clrError(`Could not create Gipity conversation: ${err?.message || err}`)}`);
1096
+ }
1097
+ }
1098
+ }
1099
+ }
1100
+ // ── argv ────────────────────────────────────────────────────────────────
1101
+ let agentArgs;
1102
+ let syntheticSid = null;
1103
+ if (nonInteractive) {
1104
+ // Headless one-shots auto-bypass approvals when there's no human at the
1105
+ // terminal to click "approve" (single-command runs and relay dispatches)
1106
+ // unless the caller opted out by... passing nothing. Mirrors the Claude
1107
+ // path's --permission-mode auto-append.
1108
+ const bypass = Boolean(opts.bypassApprovals || opts.newProject || opts.project);
1109
+ agentArgs = [
1110
+ ...adapter.buildHeadlessArgs({ message: message ?? '', resume, model, bypassApprovals: bypass, jsonStream: false }),
1111
+ ...extras,
1112
+ ];
1113
+ // Agents that fire no hooks headless (Grok) get launcher-driven capture:
1114
+ // pin the session id now, replay the transcript after the run.
1115
+ if (adapter.headlessCapture) {
1116
+ syntheticSid = resume ?? randomUUID();
1117
+ if (!resume)
1118
+ agentArgs.push(...adapter.headlessCapture.sessionIdArgs(syntheticSid));
1119
+ }
1120
+ if (message)
1121
+ headlessOut(`\nSending to ${adapter.displayName}: ${message}\n`);
1122
+ }
1123
+ else {
1124
+ agentArgs = [...adapter.buildInteractiveArgs({ resume, model }), ...extras];
1125
+ console.log(` ${bold(`Launching ${adapter.displayName}, powered by Gipity.`)}`);
1126
+ console.log(` ${muted(`Just tell ${adapter.displayName} what you'd like to build or do - plus hosting,`)}`);
1127
+ console.log(` ${muted('databases, and live deploys on Gipity.')}`);
1128
+ if (convGuid) {
1129
+ console.log(` ${muted(`This session is saved to Gipity - watch it live at ${brand('prompt.gipity.ai')}. (--no-capture on init to disable.)`)}`);
1130
+ }
1131
+ if (adapter.oneTimeSetupNote) {
1132
+ console.log(` ${warning(adapter.oneTimeSetupNote)}`);
1133
+ }
1134
+ console.log('');
1135
+ }
1136
+ // ── Spawn ───────────────────────────────────────────────────────────────
1137
+ const childEnv = { ...process.env };
1138
+ if (convGuid)
1139
+ childEnv.GIPITY_CONVERSATION_GUID = convGuid;
1140
+ const agentCmd = resolveCommand(adapter.binary);
1141
+ const child = spawnCommand(agentCmd, agentArgs, {
1142
+ stdio: 'inherit',
1143
+ cwd: process.cwd(),
1144
+ env: childEnv,
1145
+ });
1146
+ child.on('error', (err) => {
1147
+ console.error(`\n ${clrError(err.code === 'ENOENT'
1148
+ ? `${adapter.displayName} not found. Install it: ${adapter.installHint}`
1149
+ : `Failed to launch ${adapter.displayName}: ${err.message}`)}`);
1150
+ process.exit(1);
1151
+ });
1152
+ child.on('exit', (code) => {
1153
+ // Launcher-driven capture for hookless headless runs: replay the agent's
1154
+ // on-disk transcript through the capture runner as a synthetic
1155
+ // session-start + stop. Best-effort and flushed even on a nonzero exit -
1156
+ // whatever the agent got done should still show in the web CLI.
1157
+ if (syntheticSid && convGuid) {
1158
+ flushHeadlessCapture(adapter, syntheticSid, convGuid);
1159
+ }
1160
+ const doneLine = `Done (${formatElapsed(Date.now() - runStart)})`;
1161
+ if (nonInteractive)
1162
+ process.stderr.write(`\n${doneLine}\n\n`);
1163
+ else {
1164
+ console.log(`\n ${doneLine}`);
1165
+ console.log(` ${muted(`Next time: gipity build from anywhere, or cd ${process.cwd()} && ${adapter.binary}.`)}`);
1166
+ }
1167
+ process.exit(code ?? 0);
1168
+ });
1169
+ }
1170
+ /** Synchronously replay a finished headless session's transcript into the
1171
+ * conversation via the capture runner (which owns parsing, image rewrite,
1172
+ * ingest batching, watermark state, and session attach). Synchronous on
1173
+ * purpose: it runs inside the child-exit handler, and the process exits
1174
+ * right after - a fire-and-forget async spawn would be killed mid-flush. */
1175
+ function flushHeadlessCapture(adapter, sessionId, convGuid) {
1176
+ const runner = join(__clDir, '..', 'hooks', 'capture-runner.js');
1177
+ if (!existsSync(runner))
1178
+ return;
1179
+ const payload = JSON.stringify({ session_id: sessionId, cwd: process.cwd() });
1180
+ const env = { ...process.env, GIPITY_CONVERSATION_GUID: convGuid };
1181
+ for (const event of ['session-start', 'stop']) {
1182
+ try {
1183
+ spawnSyncCommand(process.execPath, [runner, adapter.key, event], {
1184
+ input: payload, env, timeout: 60_000, stdio: ['pipe', 'ignore', 'ignore'],
1185
+ });
1186
+ }
1187
+ catch { /* capture is best-effort - never break the run */ }
1188
+ }
1189
+ }
1190
+ async function pickOrCreateProject(projects, existingSlugs) {
1191
+ const filtered = projects.filter(p => !p.is_default);
1192
+ const cwd = process.cwd();
1193
+ const showAdopt = canAdoptCwd(cwd);
1194
+ // Reserve slot 1 for "create new", slot 2 for "use this dir" when shown.
1195
+ const reserved = showAdopt ? 2 : 1;
1196
+ // Pickable single-keypress range is 1-9; leave one slot for "Show all"
1197
+ // when there are more projects than fit.
1198
+ const maxRecent = 9 - reserved - 1; // worst case: keep slot 9 free for "show all"
1199
+ const recent = filtered.slice(0, maxRecent);
1200
+ const hasMore = filtered.length > recent.length;
1201
+ // Loop so that a refused/declined adopt-cwd re-shows the picker rather
1202
+ // than dropping the user into a shell.
1203
+ while (true) {
1204
+ const newProjectLabel = formatNewProjectLabel(existingSlugs);
1205
+ console.log(` ${bold('Choose project to open:')}\n`);
1206
+ console.log(` ${bold('1.')} Create new project ${muted(`(${newProjectLabel})`)}`);
1207
+ if (showAdopt) {
1208
+ console.log(` ${bold('2.')} Use this directory ${muted(`(${formatCwdLabel(cwd)})`)}`);
1209
+ }
1210
+ recent.forEach((p, i) => console.log(` ${bold(`${i + reserved + 1}.`)} ${p.name} ${muted(`(${p.slug})`)}`));
1211
+ if (hasMore) {
1212
+ console.log(` ${bold(`${recent.length + reserved + 1}.`)} Show all projects`);
1213
+ }
1214
+ console.log('');
1215
+ const maxOption = recent.length + reserved + (hasMore ? 1 : 0);
1216
+ const idx = await pickOne('Choose', maxOption, 1);
1217
+ // Recent project (slots reserved+1 .. recent.length+reserved).
1218
+ if (idx > reserved && idx <= recent.length + reserved) {
1219
+ return { kind: 'pick', project: recent[idx - reserved - 1] };
1220
+ }
1221
+ // Show all projects (one past the last recent slot).
1222
+ if (hasMore && idx === recent.length + reserved + 1) {
1223
+ const picked = await pickFromAll(filtered);
1224
+ if (picked)
1225
+ return { kind: 'pick', project: picked };
1226
+ continue; // user bailed; re-show top picker
1227
+ }
1228
+ // Slot 2 = "Use this directory" (only when shown).
1229
+ if (showAdopt && idx === 2) {
1230
+ const ok = await confirmAdoptCwd(cwd);
1231
+ if (!ok) {
1232
+ console.log('');
1233
+ continue;
1234
+ }
1235
+ return { kind: 'adopt-cwd' };
1236
+ }
1237
+ // Default (Enter) or slot 1 = create new project.
1238
+ const project = await createNewProject(existingSlugs);
1239
+ return { kind: 'create-new', project };
1240
+ }
1241
+ }
1242
+ /** Render "Show all projects" with numbered list; returns the picked
1243
+ * project or null if the user picked "create new" (slot 1) or invalid. */
1244
+ async function pickFromAll(filtered) {
1245
+ console.log('');
1246
+ console.log(` ${bold('All projects:')}\n`);
1247
+ console.log(` ${bold('1.')} Create new project`);
1248
+ filtered.forEach((p, i) => console.log(` ${bold(`${i + 2}.`)} ${p.name} ${muted(`(${p.slug})`)}`));
1249
+ console.log('');
1250
+ const allChoice = await prompt(` Choose (1-${filtered.length + 1}): `);
1251
+ const allIdx = parseInt(allChoice, 10);
1252
+ if (allIdx >= 2 && allIdx <= filtered.length + 1)
1253
+ return filtered[allIdx - 2];
1254
+ return null;
1255
+ }
1256
+ /** Show "(~/GipityProjects/project-NNN)" - the exact dir option 1 will
1257
+ * create, so the user can see where their new project will land. */
1258
+ function formatNewProjectLabel(existingSlugs) {
1259
+ const slug = suggestProjectName(existingSlugs);
1260
+ const root = getProjectsRoot();
1261
+ const home = homedir();
1262
+ const display = root === home || root.startsWith(home + sep)
1263
+ ? '~' + root.slice(home.length)
1264
+ : root;
1265
+ return `${display}${sep}${slug}`;
1266
+ }
1267
+ /** Run the size-tier scan, surface the right UX:
1268
+ * - easy → silent, return true.
1269
+ * - moderate → confirm prompt with file count + size.
1270
+ * - refuse → print explanation, return false (caller re-shows picker).
1271
+ * Returns true if adoption should proceed. */
1272
+ async function confirmAdoptCwd(cwd) {
1273
+ process.stdout.write(` ${muted('Scanning directory...')} `);
1274
+ const scan = scanForAdoption(cwd);
1275
+ process.stdout.write('\r\x1b[K'); // clear the scanning line
1276
+ if (scan.tier === 'refuse') {
1277
+ const sizeStr = scan.truncated ? `>${formatBytes(ADOPT_THRESHOLDS.REFUSE_BYTES)}` : formatBytes(scan.bytes);
1278
+ const fileStr = scan.truncated ? `>${ADOPT_THRESHOLDS.REFUSE_FILES}` : `${scan.files}`;
1279
+ console.log(` ${clrError(`Directory has ${fileStr} files (${sizeStr}) - too large to adopt as a Gipity project.`)}`);
1280
+ console.log(` ${muted('Move into a subdirectory, or use option 1 to create a fresh project under ~/GipityProjects/.')}`);
1281
+ console.log('');
1282
+ return false;
1283
+ }
1284
+ if (scan.tier === 'moderate') {
1285
+ console.log('');
1286
+ return confirm(` About to adopt ${bold(String(scan.files))} files (${bold(formatBytes(scan.bytes))}) at ${bold(formatCwdLabel(cwd))}. Continue?`, { default: 'yes' });
1287
+ }
1288
+ return true;
1289
+ }
1290
+ async function createNewProject(existingSlugs, preset) {
1291
+ const taken = new Set(existingSlugs);
1292
+ let suggested = suggestProjectName(existingSlugs);
1293
+ console.log('');
1294
+ // Loop until the user picks a free, well-formed name. Bad input or duplicate
1295
+ // slugs re-prompt instead of crashing - duplicates can still slip through
1296
+ // after the initial projects-list fetch (race with another session), so the
1297
+ // server-side 409 also routes back here.
1298
+ //
1299
+ // `preset` drives the headless path (no human at the terminal):
1300
+ // - explicit (--name X) → a clash fails loudly, never silently renames.
1301
+ // - non-explicit (suggested project-NNN) → a clash bumps to the next
1302
+ // number and retries, same as the interactive default.
1303
+ while (true) {
1304
+ let projectName;
1305
+ if (preset) {
1306
+ projectName = preset.explicit ? preset.name : suggested;
1307
+ }
1308
+ else {
1309
+ const name = await prompt(` ${bold('Project name')} [${bold(suggested)}]: `);
1310
+ projectName = name || suggested;
1311
+ }
1312
+ const projectSlug = slugify(projectName);
1313
+ if (!projectSlug) {
1314
+ console.error(` ${clrError('Invalid project name. Use letters, numbers, and hyphens.')}`);
1315
+ if (preset)
1316
+ process.exit(1);
1317
+ continue;
1318
+ }
1319
+ if (taken.has(projectSlug)) {
1320
+ console.error(` ${clrError(`"${projectSlug}" already exists. Pick a different name.`)}`);
1321
+ if (preset?.explicit)
1322
+ process.exit(1);
1323
+ suggested = suggestProjectName([...taken]);
1324
+ continue;
1325
+ }
1326
+ try {
1327
+ const device = relayState.getDevice();
1328
+ const body = { name: projectName, slug: projectSlug };
1329
+ if (device) {
1330
+ body.autoChat = 'claude_code';
1331
+ body.deviceGuid = device.guid;
1332
+ }
1333
+ const res = await post('/projects', body);
1334
+ // Single console.log keeps the line on the headless stderr stream -
1335
+ // process.stdout.write would leak the message onto the child's stdout.
1336
+ console.log(` ${info(`Creating "${projectName}"...`)} ${success('Created.')}`);
1337
+ return res.data;
1338
+ }
1339
+ catch (err) {
1340
+ if (err instanceof ApiError && err.statusCode === 409) {
1341
+ console.error(` ${clrError(`"${projectSlug}" was just taken. Pick a different name.`)}`);
1342
+ if (preset?.explicit)
1343
+ process.exit(1);
1344
+ taken.add(projectSlug);
1345
+ suggested = suggestProjectName([...taken]);
1346
+ continue;
1347
+ }
1348
+ throw err;
1349
+ }
1350
+ }
1351
+ }
1352
+ //# sourceMappingURL=build.js.map