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.
- package/dist/agents/claude-code.js +53 -0
- package/dist/agents/codex.js +49 -0
- package/dist/agents/grok.js +54 -0
- package/dist/agents/index.js +23 -0
- package/dist/agents/types.js +18 -0
- package/dist/api.js +7 -0
- package/dist/capture/sources/codex.js +216 -0
- package/dist/capture/sources/grok.js +178 -0
- package/dist/client-context.js +2 -0
- package/dist/commands/build.js +1352 -0
- package/dist/commands/chat.js +9 -3
- package/dist/commands/claude.js +4 -13
- package/dist/commands/db.js +12 -5
- package/dist/commands/deploy.js +19 -1
- package/dist/commands/doctor.js +8 -2
- package/dist/commands/generate.js +61 -4
- package/dist/commands/init.js +9 -15
- package/dist/commands/page-eval.js +404 -56
- package/dist/commands/page-inspect.js +15 -5
- package/dist/commands/page-screenshot.js +269 -64
- package/dist/commands/project.js +1 -1
- package/dist/commands/relay-install.js +1 -1
- package/dist/commands/relay.js +2 -2
- package/dist/commands/sandbox.js +62 -16
- package/dist/commands/setup.js +16 -10
- package/dist/commands/uninstall.js +25 -3
- package/dist/hooks/capture-runner.js +136 -39
- package/dist/index.js +1962 -668
- package/dist/knowledge.js +3 -3
- package/dist/page-fixtures.js +92 -3
- package/dist/prefs.js +50 -0
- package/dist/project-setup.js +2 -10
- package/dist/provider-docs.js +10 -10
- package/dist/relay/daemon.js +140 -18
- package/dist/relay/device-http.js +22 -0
- package/dist/relay/diagnostics.js +4 -2
- package/dist/relay/media-upload.js +184 -0
- package/dist/relay/onboarding.js +2 -2
- package/dist/setup.js +262 -18
- 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
|