gipity 1.0.429 → 1.1.2

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 (45) hide show
  1. package/README.md +67 -29
  2. package/dist/agents/claude-code.js +48 -0
  3. package/dist/agents/codex.js +45 -0
  4. package/dist/agents/grok.js +50 -0
  5. package/dist/agents/index.js +23 -0
  6. package/dist/agents/types.js +18 -0
  7. package/dist/api.js +14 -4
  8. package/dist/capture/sources/codex.js +216 -0
  9. package/dist/capture/sources/grok.js +178 -0
  10. package/dist/catalog.js +46 -0
  11. package/dist/client-context.js +2 -0
  12. package/dist/commands/add.js +9 -22
  13. package/dist/commands/build.js +1330 -0
  14. package/dist/commands/chat.js +9 -3
  15. package/dist/commands/claude.js +4 -13
  16. package/dist/commands/db.js +22 -5
  17. package/dist/commands/deploy.js +7 -0
  18. package/dist/commands/doctor.js +8 -2
  19. package/dist/commands/fn.js +24 -1
  20. package/dist/commands/init.js +9 -15
  21. package/dist/commands/logs.js +21 -2
  22. package/dist/commands/page-eval.js +25 -10
  23. package/dist/commands/page-inspect.js +7 -3
  24. package/dist/commands/page-screenshot.js +62 -23
  25. package/dist/commands/project.js +1 -1
  26. package/dist/commands/relay-install.js +1 -1
  27. package/dist/commands/relay.js +3 -3
  28. package/dist/commands/sandbox.js +62 -16
  29. package/dist/commands/setup.js +16 -10
  30. package/dist/commands/status.js +35 -7
  31. package/dist/commands/test.js +7 -0
  32. package/dist/commands/uninstall.js +25 -3
  33. package/dist/flag-aliases.js +1 -0
  34. package/dist/hooks/capture-runner.js +127 -38
  35. package/dist/index.js +1118 -361
  36. package/dist/knowledge.js +3 -3
  37. package/dist/prefs.js +42 -0
  38. package/dist/project-setup.js +2 -10
  39. package/dist/relay/daemon.js +124 -16
  40. package/dist/relay/diagnostics.js +4 -2
  41. package/dist/relay/onboarding.js +9 -9
  42. package/dist/relay/setup.js +8 -8
  43. package/dist/relay/state.js +1 -1
  44. package/dist/setup.js +258 -17
  45. package/package.json +4 -3
@@ -30,12 +30,15 @@ function label(text) {
30
30
  function fmtMs(ms) {
31
31
  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
32
32
  }
33
- /** Auth state line (same format as page inspect/eval): without it an agent
34
- * can't tell a signed-in capture from "--auth silently no-op'd and this is
35
- * the anonymous page". */
33
+ /** Identity line on EVERY capture (same format as page inspect/eval): without
34
+ * it an agent can't tell a signed-in capture from "--auth silently no-op'd and
35
+ * this is the anonymous page" — or assumes an unflagged capture carried a
36
+ * signed-in session. */
36
37
  function printAuthLine(auth) {
37
- if (!auth?.requested)
38
+ if (!auth?.requested) {
39
+ console.log(`${label('Auth')} ${muted('anonymous visitor (signed out; pass --auth to capture as your Gipity account)')}`);
38
40
  return;
41
+ }
39
42
  const who = getAuth()?.email;
40
43
  console.log(auth.established
41
44
  ? `${label('Auth')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
@@ -176,10 +179,13 @@ function appendOption(value, previous = []) {
176
179
  return [...previous, value];
177
180
  }
178
181
  // Pre-capture scripting lives on --action, but agents reach for the JS-intent
179
- // names first and an "unknown option" alone doesn't name the right flag. Capture
180
- // the common guesses as hidden decoys (taking a value, so they swallow the
181
- // script) and redirect precisely — same pattern as `page eval`'s JS_DECOY_FLAGS.
182
- const ACTION_DECOY_FLAGS = ['--eval', '--js', '--javascript', '--script', '--code', '--exec'];
182
+ // names first --eval above all, the name they already know from `page eval`.
183
+ // The intent is unambiguous (run this JS in the page before the shot), so these
184
+ // are working hidden aliases, not errors — same accept-the-reflex-guess pattern
185
+ // as --full-page and --width/--height. (Supersedes the earlier decoy approach,
186
+ // which swallowed the script and errored with a redirect: when the guess is
187
+ // unambiguous, making it WORK beats making it a better error.)
188
+ const ACTION_ALIAS_FLAGS = ['--eval', '--js', '--javascript', '--script', '--code', '--exec'];
183
189
  /** A capture is worthless if it fires before the app reaches the state you meant
184
190
  * to photograph, and the only lever used to be a blind millisecond delay — so a
185
191
  * camera/vision app got screenshotted with a guessed duration (`--wait 22000`).
@@ -216,6 +222,12 @@ export const CAMERA_DEFAULT_DELAY_MS = 15_000;
216
222
  export const pageScreenshotCommand = new Command('screenshot')
217
223
  .description('Screenshot a web page')
218
224
  .argument('<url>', 'URL to screenshot')
225
+ // --device / --viewport lead the options: "how does this look on a phone?" is
226
+ // the single most common reason to screenshot, and agents routinely read the
227
+ // help through `--help | head -N` — buried below the timing flags, these two
228
+ // fell off the bottom and the run proceeded at desktop width.
229
+ .option('--device <names>', `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag). mobile/tablet emulate a real touch device — touch events, mobile user-agent, DPR — so touch-gated mobile UI actually renders.`, appendOption, [])
230
+ .option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag), e.g. --viewport 390x844 for a phone-sized window', appendOption, [])
219
231
  // No commander default: a default here would make the value always set, so the
220
232
  // merge below could not tell "caller chose a delay" from "nobody did" — which
221
233
  // is what --camera's model-load default and the --wait-for gate both hinge on.
@@ -225,27 +237,35 @@ export const pageScreenshotCommand = new Command('screenshot')
225
237
  .option('--action <js>', 'Run JS in the page before capturing — e.g. click a button to enter a state ("document.getElementById(\'play\').click()"). Runs as an async function body, so const/await and app-relative import(\'./…\') work. Runs after the post-load delay, then settles again before the shot. If it throws, the capture still happens and the failure is reported.')
226
238
  .option('--full', 'Capture the full scrollable page (default: viewport only). Scrolls the page through first so scroll-reveal/fade-in-on-scroll (IntersectionObserver) sections render into the shot instead of capturing blank.')
227
239
  .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
228
- .option('--device <names>', `Device preset(s): ${Object.keys(DEVICE_PRESETS).join(', ')} (comma-separated or repeat flag). mobile/tablet emulate a real touch device — touch events, mobile user-agent, DPR — so touch-gated mobile UI actually renders.`, appendOption, [])
229
- .option('--viewport <dims>', 'Raw viewport(s): WxH or WxH@dpr (comma-separated or repeat flag)', appendOption, [])
230
240
  .option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
231
241
  .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly. The video feed is a built-in test pattern — to capture what the app does with a REAL frame (a hand, a face, an object), use --camera <path> instead.')
232
242
  .option('--camera <path>', 'Play a local image or video (.png/.jpg/.webp/.mp4/.webm/.y4m/.mjpeg) as the browser\'s WEBCAM feed, then capture — so the shot shows the app reacting to a frame you chose (detected gesture, boxes, labels). Implies --fake-media.')
233
- .option('--auth', 'Capture the page signed in as you (your Gipity account), so UI behind a Sign-in-with-Gipity login is shown. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
243
+ .option('--auth', 'Capture the page signed in as you (your Gipity account), so UI behind a Sign-in-with-Gipity login is shown. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai. Without this flag the page loads as a genuinely anonymous, signed-out visitor — nothing carries over from earlier --auth runs.')
234
244
  .option('--ephemeral', 'Skip the project screenshot history: do not persist this capture to Gipity (screenshots/ in the project). Local file is still written.')
235
245
  .option('--json', 'Output JSON metadata instead of a friendly summary')
236
246
  .addOption(new Option('--post-load-delay <ms>', 'Alias for --wait').hideHelp())
247
+ // (--eval and the other JS-intent guesses are registered in one place from
248
+ // ACTION_ALIAS_FLAGS below — a second standalone registration here makes
249
+ // commander throw "conflicting flag" at startup.)
237
250
  // `--full-page` is the Puppeteer/Playwright name for this (their `fullPage`),
238
251
  // so agents reach for it by reflex. Accept it as a hidden alias for `--full`
239
252
  // rather than reject it as an unknown option and send them on a --help detour.
240
253
  .addOption(new Option('--full-page', 'Alias for --full').hideHelp())
254
+ // `--width 390 --height 844` is the setViewport vocabulary agents guess first
255
+ // when asked for a phone-sized shot. Accept the pair as a working alias for
256
+ // --viewport WxH instead of bouncing the guess into a --help detour.
257
+ .addOption(new Option('--width <px>', 'Alias: --viewport WxH').hideHelp())
258
+ .addOption(new Option('--height <px>', 'Alias: --viewport WxH').hideHelp())
241
259
  .action((url, opts) => run('Page screenshot', async () => {
242
- // A JS-intent flag guess (captured as a hidden decoy below): name the real
243
- // flag before any other arg-shape check fires.
244
- const decoy = ACTION_DECOY_FLAGS.find((f) => opts[f.slice(2)] !== undefined);
245
- if (decoy) {
246
- pageScreenshotCommand.error(`error: ${decoy} is not a flag on screenshot — use --action "<js>" to run JavaScript in the page ` +
247
- `before the capture, e.g. gipity page screenshot "<url>" --action "document.getElementById('play').click()"`);
260
+ // A JS-intent alias guess (--eval et al., hidden): fold it into the action
261
+ // script so the reflex guess just works, and name the canonical flag once
262
+ // so the next call uses it.
263
+ const aliasFlag = ACTION_ALIAS_FLAGS.find((f) => opts[f.slice(2)] !== undefined);
264
+ if (aliasFlag) {
265
+ console.error(muted(`Note: treating ${aliasFlag} as --action — it runs your JS in the page before the capture. --action is the canonical flag.`));
248
266
  }
267
+ const actionScript = [opts.action, ...ACTION_ALIAS_FLAGS.map((f) => opts[f.slice(2)])]
268
+ .filter(Boolean).join('\n');
249
269
  // --wait is the canonical name (it is what `page inspect`/`page eval` call it);
250
270
  // --post-load-delay stays as a hidden alias. Whether the caller named EITHER is
251
271
  // what decides the defaults below, so keep the raw "was it set" signal.
@@ -284,6 +304,24 @@ export const pageScreenshotCommand = new Command('screenshot')
284
304
  }
285
305
  const deviceNames = splitCsv(opts.device);
286
306
  const viewportStrs = splitCsv(opts.viewport);
307
+ // --width/--height: fold the alias pair into a --viewport entry. Half a
308
+ // pair is a mis-invocation worth one precise line, not a range error.
309
+ if (opts.width !== undefined || opts.height !== undefined) {
310
+ if (opts.width === undefined || opts.height === undefined) {
311
+ pageScreenshotCommand.error('error: --width and --height go together (both in px, equivalent to --viewport WxH) — ' +
312
+ 'or use a preset like --device mobile for a real handset (touch events, mobile user-agent, DPR)');
313
+ }
314
+ if (!/^\d+$/.test(String(opts.width)) || !/^\d+$/.test(String(opts.height))) {
315
+ pageScreenshotCommand.error('error: --width and --height must be integers (px)');
316
+ }
317
+ viewportStrs.push(`${opts.width}x${opts.height}`);
318
+ // A raw phone-sized window is NOT a phone: touch-gated mobile UI still
319
+ // renders its desktop variant. Say so once, on the path where the caller
320
+ // was clearly after a phone.
321
+ if (parseInt(String(opts.width), 10) <= 500) {
322
+ console.error(muted('Tip: --device mobile emulates a real handset (touch events, mobile user-agent, DPR) — a raw viewport only resizes the window.'));
323
+ }
324
+ }
287
325
  const customViewports = [
288
326
  ...deviceNames.map(resolveDevice),
289
327
  ...viewportStrs.map(parseViewportString),
@@ -318,11 +356,12 @@ export const pageScreenshotCommand = new Command('screenshot')
318
356
  }
319
357
  // The pre-capture script the server runs after the post-load delay: the
320
358
  // --wait-for gate first (so the shot waits for the state, deterministically),
321
- // then the caller's --action. Both are the same in-page primitive, so they
359
+ // then the caller's --action (with any alias-flag scripts folded in by
360
+ // actionScript above). All the same in-page primitive, so they
322
361
  // compose into one script rather than needing a second server round-trip.
323
362
  const preCapture = [
324
363
  opts.waitFor ? buildWaitForGate(opts.waitFor, waitForTimeoutMs) : '',
325
- opts.action ?? '',
364
+ actionScript,
326
365
  ].filter(Boolean).join('\n');
327
366
  const body = {
328
367
  url,
@@ -476,10 +515,10 @@ export const pageScreenshotCommand = new Command('screenshot')
476
515
  printVfsLine(s);
477
516
  }
478
517
  }));
479
- // Register the JS-intent guesses as hidden decoys (value-taking, so they swallow
480
- // the script) — the action turns any of them into the "--action" redirect above.
481
- for (const f of ACTION_DECOY_FLAGS)
482
- pageScreenshotCommand.addOption(new Option(`${f} <value>`).hideHelp());
518
+ // Register the JS-intent guesses as hidden aliases for --action (value-taking,
519
+ // so they capture the script) — the action folds them into the pre-capture body.
520
+ for (const f of ACTION_ALIAS_FLAGS)
521
+ pageScreenshotCommand.addOption(new Option(`${f} <js>`, 'Alias for --action').hideHelp());
483
522
  // `screenshot` captures the page AFTER load + settle (+ optional --wait-for gate
484
523
  // and --action). There is no scroll-to-a-position lever (agents reach for
485
524
  // --scroll and get an unknown-option detour) — but `--full` does walk the page
@@ -134,7 +134,7 @@ projectCommand
134
134
  console.log(`${muted('Next:')} switch to "${project.name}" in the sidebar.`);
135
135
  }
136
136
  else {
137
- console.log(`${muted('Next:')} exit Claude (Ctrl+D), then run: ${brand('gipity claude')}`);
137
+ console.log(`${muted('Next:')} exit Claude (Ctrl+D), then run: ${brand('gipity build')}`);
138
138
  console.log(`${muted('Pick')} "${project.name}" ${muted(`- it'll be at the top of the list.`)}`);
139
139
  }
140
140
  }));
@@ -6,7 +6,7 @@ import { installAutostart, removeAutostart, resolveCliPath } from '../relay/setu
6
6
  function requirePaired() {
7
7
  const device = state.getDevice();
8
8
  if (!device) {
9
- console.error(`${clrError('No paired device.')} Run ${bold('gipity claude')} to pair this machine.`);
9
+ console.error(`${clrError('No paired device.')} Run ${bold('gipity connect')} to pair this machine.`);
10
10
  process.exit(1);
11
11
  }
12
12
  return device;
@@ -29,7 +29,7 @@ relayCommand
29
29
  .option('--name <name>', 'Device name shown in the web CLI (default: this machine\'s hostname)')
30
30
  .option('--no-start', 'Pair only; do not start the relay daemon now')
31
31
  .option('--no-autostart', 'Skip the OS login service (use when a supervising app owns the daemon)')
32
- .option('--force', 'Re-pair even if already paired (revokes the old device first)')
32
+ .option('--force', 'Re-pair even if already paired (re-registers this machine, rotating its token)')
33
33
  .option('--json', 'Machine-readable output')
34
34
  .action(async (opts) => {
35
35
  const fail = (code, message) => {
@@ -121,7 +121,7 @@ relayCommand
121
121
  return;
122
122
  }
123
123
  if (!s.device) {
124
- console.log(`${muted('No paired device.')} Run ${brand('gipity claude')} to pair this machine.`);
124
+ console.log(`${muted('No paired device.')} Run ${brand('gipity connect')} to pair this machine.`);
125
125
  return;
126
126
  }
127
127
  console.log(`${bold('Device:')} ${brand(s.device.name)} ${muted(`(${s.device.guid})`)}`);
@@ -380,7 +380,7 @@ registerInstallCommands(relayCommand);
380
380
  function requirePaired() {
381
381
  const device = state.getDevice();
382
382
  if (!device) {
383
- console.error(`${clrError('No paired device.')} Run ${brand('gipity claude')} to pair this machine.`);
383
+ console.error(`${clrError('No paired device.')} Run ${brand('gipity connect')} to pair this machine.`);
384
384
  process.exit(1);
385
385
  }
386
386
  return device;
@@ -1,6 +1,6 @@
1
- import { Command } from 'commander';
2
- import { readFileSync, existsSync, statSync } from 'fs';
3
- import { dirname, extname, relative, resolve } from 'path';
1
+ import { Command, Option } from 'commander';
2
+ import { readFileSync, existsSync, statSync, mkdirSync, writeFileSync } from 'fs';
3
+ import { dirname, extname, relative, resolve, sep } from 'path';
4
4
  import { post } from '../api.js';
5
5
  import { resolveProjectContext, getConfigPath, getProjectRoot, shouldIgnore } from '../config.js';
6
6
  import { SCRATCH_IGNORE } from '../setup.js';
@@ -167,19 +167,28 @@ sandboxCommand
167
167
  // an interpreter token, or a --file extension - see resolveLanguage().
168
168
  .option('--language <language>', 'Language: js, py, or bash (required unless pinned by an interpreter token or --file extension)')
169
169
  .option('--file <path>', 'Read the code body from a file instead of the inline <code> arg; --language is inferred from the extension when not given')
170
+ // `--code "<code>"` is the natural first guess for passing inline code (the
171
+ // positional arg is the canonical spelling). Accept it as a working alias
172
+ // rather than bouncing the guess into an unknown-option --help detour.
173
+ .addOption(new Option('--code <code>', 'Alias for the positional inline <code> arg').hideHelp())
170
174
  .option('--timeout <seconds>', 'Execution timeout in seconds', '30')
171
175
  .option('--input <path>', 'Narrow to specific project files instead of auto-mirroring the whole tree (repeatable). Use this only for >1 GB projects or when you want surgical control.', (v, prev) => [...(prev ?? []), v])
172
- // Commander maps a `--no-` prefixed flag to the un-prefixed camelCase name
173
- // (`opts.syncOutput`); the explicit `[]` default stops commander's negated-
174
- // boolean convention from defaulting it to `true`. Collected as an array so
175
- // the flag is repeatable.
176
- .option('--no-sync-output <glob>', 'Do not persist run outputs matching this glob back to the project (repeatable). For byproducts you want to inspect once but never keep, e.g. --no-sync-output "docs/preview*". Supports *, **, and dir/ prefixes.', (v, prev) => [...prev, v], [])
176
+ // Named "discard", not "no-sync": in Gipity vocabulary "sync" means the
177
+ // local<->cloud file sync, so a no-sync spelling reads as "give me the file
178
+ // locally, just don't sync it" - which is the tmp/ scratch path below, not
179
+ // this flag. Collected as an array so the flag is repeatable.
180
+ .option('--discard-output <glob>', 'Discard run outputs matching this glob entirely - not saved, not returned (repeatable). To LOOK at an output without keeping it, write it under tmp/ instead: scratch outputs land on local disk only and never enter the project. Supports *, **, and dir/ prefixes.', (v, prev) => [...prev, v], [])
177
181
  .option('--json', 'Output as JSON')
178
182
  .addHelpText('after', `
179
183
  By default the whole project is auto-mirrored into /work/ (up to 1 GB) -
180
184
  so your code can reference project files by their relative path, and any
181
185
  file you write lands back in the project. No manual copy needed.
182
186
 
187
+ Exception: tmp/ is scratch. An output written under tmp/ comes back to
188
+ your local tmp/ for inspection but is never saved to the project - use it
189
+ for previews and other look-once artifacts (no cleanup needed). To drop
190
+ an output entirely, --discard-output <glob>.
191
+
183
192
  Use --input only for projects over the auto-mirror cap, or when you want
184
193
  to restrict what the sandbox sees.
185
194
 
@@ -196,6 +205,11 @@ Examples:
196
205
  $ gipity sandbox run --language python \\
197
206
  "import pandas as pd; print(pd.read_csv('data/sales.csv').describe())"
198
207
 
208
+ # Preview a generated PDF without saving the preview: write it to tmp/
209
+ $ gipity sandbox run bash \\
210
+ "pdftoppm -png -r 90 docs/report.pdf tmp/preview"
211
+ # -> tmp/preview-1.png lands on local disk only; Read it, done.
212
+
199
213
  # Run a script file directly (language inferred from .py)
200
214
  $ gipity sandbox run --file build_report.py
201
215
  $ gipity sandbox run python build_report.py # same thing, interpreter shorthand
@@ -228,6 +242,14 @@ GCC/Rust).
228
242
  let inlineCode;
229
243
  let filePath = opts.file;
230
244
  let langFromInterp;
245
+ // --code alias: fold it into the positional slot before the shape checks.
246
+ if (opts.code !== undefined) {
247
+ if (args.length) {
248
+ console.error(clrError('Pass the code once: either positionally or via --code, not both'));
249
+ process.exit(1);
250
+ }
251
+ args = [opts.code];
252
+ }
231
253
  if (args.length >= 2 && INTERPRETERS[args[0].toLowerCase()] !== undefined) {
232
254
  langFromInterp = INTERPRETERS[args[0].toLowerCase()];
233
255
  const rest = args.slice(1).join(' ');
@@ -268,11 +290,11 @@ GCC/Rust).
268
290
  // This runs BEFORE the project sync and the server round trip below, so a
269
291
  // missing language costs nothing but the message.
270
292
  const language = resolveLanguage({ langFromInterp, langOpt: opts.language, filePath, inlineCode });
271
- // Validate --no-sync-output globs while we're still pre-network: an empty
293
+ // Validate --discard-output globs while we're still pre-network: an empty
272
294
  // pattern (e.g. a quoting mishap) would silently match nothing server-side.
273
- const noSyncOutput = opts.syncOutput ?? [];
274
- if (noSyncOutput.some((g) => !g.trim())) {
275
- console.error(clrError('--no-sync-output requires a non-empty glob (e.g. --no-sync-output "docs/preview*")'));
295
+ const discardOutput = opts.discardOutput ?? [];
296
+ if (discardOutput.some((g) => !g.trim())) {
297
+ console.error(clrError('--discard-output requires a non-empty glob (e.g. --discard-output "*.ppm")'));
276
298
  process.exit(1);
277
299
  }
278
300
  // Args are good - now it's worth resolving (and announcing) the project.
@@ -311,8 +333,9 @@ GCC/Rust).
311
333
  cwd,
312
334
  // The filter must run SERVER-side (in the output extractor): skipping
313
335
  // only in the CLI would still write the files into project storage and
314
- // make every later sync propose deleting them.
315
- noSyncOutput: noSyncOutput.length ? noSyncOutput : undefined,
336
+ // make every later sync propose deleting them. (`noSyncOutput` is the
337
+ // wire name for --discard-output.)
338
+ noSyncOutput: discardOutput.length ? discardOutput : undefined,
316
339
  });
317
340
  const res = opts.json
318
341
  ? await doRun()
@@ -326,8 +349,26 @@ GCC/Rust).
326
349
  if (pulledLocal) {
327
350
  await sync({ interactive: false, progress: opts.json ? undefined : createProgressReporter() });
328
351
  }
352
+ // Scratch outputs (tmp/ and friends) never enter the project: the server
353
+ // returns their bytes inline instead of persisting them, and we land them
354
+ // here. The scratch namespace is ignored by sync in both directions, so
355
+ // the file exists on local disk only - inspect it, no cleanup required.
356
+ const scratchWritten = [];
357
+ if (res.data.scratchFiles?.length) {
358
+ const base = resolve(getProjectRoot() ?? process.cwd());
359
+ for (const f of res.data.scratchFiles) {
360
+ const rel = f.path.replace(/\\/g, '/');
361
+ const abs = resolve(base, rel);
362
+ if (abs !== base && !abs.startsWith(base + sep))
363
+ continue; // traversal guard
364
+ mkdirSync(dirname(abs), { recursive: true });
365
+ writeFileSync(abs, Buffer.from(f.contentBase64, 'base64'));
366
+ scratchWritten.push(rel);
367
+ }
368
+ }
329
369
  if (opts.json) {
330
- console.log(JSON.stringify({ ...res.data, filesSynced: pulledLocal }));
370
+ const { scratchFiles: _omit, ...rest } = res.data;
371
+ console.log(JSON.stringify({ ...rest, scratchFiles: scratchWritten, filesSynced: pulledLocal }));
331
372
  }
332
373
  else {
333
374
  if (res.data.autoMirrorSkipped) {
@@ -367,8 +408,13 @@ GCC/Rust).
367
408
  console.log(dim("Not pulled locally - run 'gipity sync' to fetch them."));
368
409
  }
369
410
  }
411
+ if (scratchWritten.length > 0) {
412
+ console.log('\nScratch outputs (local only - never synced or deployed):');
413
+ for (const f of scratchWritten)
414
+ console.log(`${f}`);
415
+ }
370
416
  if (res.data.skippedOutputFiles && res.data.skippedOutputFiles.length > 0) {
371
- console.log(dim('\nNot persisted (--no-sync-output):'));
417
+ console.log(dim('\nDiscarded (--discard-output):'));
372
418
  for (const f of res.data.skippedOutputFiles)
373
419
  console.log(dim(`${f}`));
374
420
  }
@@ -1,12 +1,17 @@
1
1
  /**
2
- * `gipity setup` — get this computer ready as a relay, then stop.
2
+ * `gipity connect` — connect this computer to gipity.ai, then stop.
3
3
  *
4
- * Runs the SAME first steps as `gipity claude` (log in, then pair + start the
4
+ * Runs the SAME first steps as `gipity build` (log in, then pair + start the
5
5
  * relay + install the OS login service) but does NOT pick a project or launch
6
- * Claude Code. For the user who wants their machine available as a relay in the
7
- * web CLI without being dropped into a coding session.
6
+ * an agent. For the user who wants their machine drivable from the web CLI
7
+ * without being dropped into a coding session.
8
8
  *
9
- * Shares one implementation with `gipity claude`: auth via `login-flow.ts`,
9
+ * Named `connect` because "setup" and "init" are synonyms in plain English -
10
+ * the machine-scoped verb needed its own word (and it matches `gipity
11
+ * payments connect`). `setup` stays as a hidden alias for muscle memory and
12
+ * old docs.
13
+ *
14
+ * Shares one implementation with `gipity build`: auth via `login-flow.ts`,
10
15
  * relay setup via `relay/onboarding.ts` (`runRelaySetup`). Nothing to maintain
11
16
  * twice.
12
17
  */
@@ -16,12 +21,13 @@ import { interactiveLogin } from '../login-flow.js';
16
21
  import { runRelaySetup } from '../relay/onboarding.js';
17
22
  import * as relayState from '../relay/state.js';
18
23
  import { bold, brand, success, muted, error as clrError } from '../colors.js';
19
- export const setupCommand = new Command('setup')
20
- .description('Set up this computer as a relay (no project, no launch)')
24
+ export const connectCommand = new Command('connect')
25
+ .alias('setup') // legacy name, kept working but not advertised
26
+ .description('Connect this computer to gipity.ai so the web CLI can drive it (no project, no launch)')
21
27
  .action(async () => {
22
28
  try {
23
29
  // Leading blank comes from the central output frame (installOutputFrame).
24
- console.log(` ${bold('Gipity setup')} ${muted('- get this computer ready as a relay')}`);
30
+ console.log(` ${bold('Gipity connect')} ${muted('- let gipity.ai drive this computer')}`);
25
31
  console.log('');
26
32
  // ── Step 1: Auth ──────────────────────────────────────────────────
27
33
  let auth = getAuth();
@@ -44,10 +50,10 @@ export const setupCommand = new Command('setup')
44
50
  if (enabled) {
45
51
  const running = relayState.isRelayEnabled() && !relayState.isPaused();
46
52
  console.log(` ${success('Done')} — your relay ${running ? 'is running in the background' : 'is set up'} and will start with your computer.`);
47
- console.log(` ${muted('Open')} ${brand('gipity.ai')} ${muted('and start a chat to drive Claude Code here. Manage it with `gipity relay status`.')}`);
53
+ console.log(` ${muted('Open')} ${brand('gipity.ai')} ${muted('and start a chat to drive your coding agent here. Manage it with `gipity relay status`.')}`);
48
54
  }
49
55
  else {
50
- console.log(` ${muted('No relay set up. Run `gipity setup` again anytime, or `gipity claude` to build with Gipity.')}`);
56
+ console.log(` ${muted('No relay set up. Run `gipity connect` again anytime, or `gipity build` to start building.')}`);
51
57
  }
52
58
  console.log('');
53
59
  }
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'fs';
3
3
  import { join, resolve } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { getAuth, sessionExpired } from '../auth.js';
6
+ import { get, usingEnvToken, ApiError } from '../api.js';
6
7
  import { getConfig, liveUrl } from '../config.js';
7
8
  import { brand, success, warning, muted, error as clrError } from '../colors.js';
8
9
  import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, setupClaudeHooks, ensureGipityPlugin, ensureGipityPluginInstalled, userScopeInstallState } from '../setup.js';
@@ -38,6 +39,17 @@ function checkGipityPlugin() {
38
39
  const stale = missing.length === 1 && missing[0] === 'install' && install.exists;
39
40
  return { missing, ok: missing.length === 0, stale };
40
41
  }
42
+ async function probeAuth(loggedIn) {
43
+ if (!loggedIn && !usingEnvToken())
44
+ return 'none';
45
+ if (!usingEnvToken() && sessionExpired())
46
+ return 'expired';
47
+ // Cap the probe well below the API layer's 60s request timeout - status is
48
+ // a diagnostic command and must answer fast even when the network is dark.
49
+ const timeout = new Promise(res => setTimeout(() => res('unreachable'), 5000).unref?.());
50
+ const call = get('/users/me').then(() => 'ok', (err) => (err instanceof ApiError && err.statusCode === 401 ? 'rejected' : 'unreachable'));
51
+ return Promise.race([call, timeout]);
52
+ }
41
53
  // `whoami` is the name agents reach for first when they want the signed-in
42
54
  // identity (it's the unix spelling), and this is the command that prints it.
43
55
  // Aliasing costs one line and turns a guess into a hit.
@@ -58,6 +70,7 @@ export const statusCommand = new Command('status')
58
70
  // now go out. Skipped entirely (existsSync short-circuit) when the queue
59
71
  // is empty, which is the common case, so this stays a no-op most runs.
60
72
  const queueDelivered = (auth && !sessionExpired()) ? await flushBugQueue().catch(() => 0) : 0;
73
+ const probe = await probeAuth(!!auth);
61
74
  if (opts.json) {
62
75
  console.log(JSON.stringify({
63
76
  project: config ? {
@@ -69,9 +82,13 @@ export const statusCommand = new Command('status')
69
82
  } : null,
70
83
  // `valid` reflects the refresh token (the real session) - access
71
84
  // tokens auto-renew, so their expiry must not read as "invalid".
72
- auth: auth ? {
73
- email: auth.email,
74
- valid: !sessionExpired(),
85
+ // `probe` is what one live call just proved: 'rejected' means every
86
+ // authenticated command will fail even though `valid` reads true.
87
+ auth: (auth || usingEnvToken()) ? {
88
+ email: auth?.email,
89
+ source: usingEnvToken() ? 'agent-token' : 'session',
90
+ valid: usingEnvToken() ? probe !== 'rejected' : !sessionExpired(),
91
+ probe,
75
92
  } : null,
76
93
  plugin: hookCheck,
77
94
  }, null, 2));
@@ -88,14 +105,25 @@ export const statusCommand = new Command('status')
88
105
  if (config.agentGuid)
89
106
  console.log(`${muted('Agent:')} ${config.agentGuid}`);
90
107
  }
91
- if (!auth) {
108
+ if (usingEnvToken()) {
109
+ console.log(`${muted('Auth:')} ${probe === 'rejected'
110
+ ? warning('agent API token (GIPITY_TOKEN) rejected by the server — mint a new one: gipity skill read agent-deploy')
111
+ : success('agent API token (GIPITY_TOKEN)')}${probe === 'unreachable' ? ` ${muted('(unverified — API unreachable)')}` : ''}`);
112
+ }
113
+ else if (!auth) {
92
114
  console.log(`${muted('Auth:')} ${warning('not logged in. Run: gipity login')}`);
93
115
  }
94
- else if (sessionExpired()) {
95
- console.log(`${muted('Auth:')} ${warning(`session expired for ${auth.email}. Run: gipity login`)}`);
116
+ else if (probe === 'expired') {
117
+ console.log(`${muted('Auth:')} ${warning(`session expired for ${auth.email}. Run: gipity login (headless/CI: set GIPITY_TOKEN — gipity skill read agent-deploy)`)}`);
118
+ }
119
+ else if (probe === 'rejected') {
120
+ // Locally fresh but the server says no (refresh token rotated away or
121
+ // revoked). Without the live probe this printed a green identity while
122
+ // every authenticated command failed.
123
+ console.log(`${muted('Auth:')} ${warning(`session for ${auth.email} was rejected by the server. Run: gipity login (headless/CI: set GIPITY_TOKEN — gipity skill read agent-deploy)`)}`);
96
124
  }
97
125
  else {
98
- console.log(`${muted('Auth:')} ${success(auth.email)}`);
126
+ console.log(`${muted('Auth:')} ${success(auth.email)}${probe === 'unreachable' ? ` ${muted('(unverified — API unreachable)')}` : ''}`);
99
127
  }
100
128
  if (queueDelivered > 0) {
101
129
  console.log(`${muted('Bug queue:')} ${success(`delivered ${queueDelivered} queued bug report${queueDelivered === 1 ? '' : 's'}`)}`);
@@ -206,6 +206,13 @@ export const testCommand = new Command('test')
206
206
  console.log(clrError(`Run failed: ${data.errorMessage}`));
207
207
  console.log('');
208
208
  }
209
+ // What the isolated test DB reset did (tables truncated, test.preserve
210
+ // list, seeds re-applied) - so "expected N rows, got 0" reads as "the
211
+ // reset truncated that table", not as a mystery.
212
+ if (data.dbSummary) {
213
+ console.log(muted(`Test db: ${data.dbSummary}`));
214
+ console.log('');
215
+ }
209
216
  // Failures recap. Results stream incrementally above, but agents usually
210
217
  // read this output through a `tail` window, so anything printed early is
211
218
  // lost - restate every failure (name + assertion message) right before
@@ -12,14 +12,14 @@ import { Command } from 'commander';
12
12
  import { existsSync, rmSync, unlinkSync, readFileSync, writeFileSync } from 'fs';
13
13
  import { homedir, platform as osPlatform } from 'os';
14
14
  import { join, resolve } from 'path';
15
- import { spawnSyncCommand } from '../platform.js';
15
+ import { spawnSyncCommand, resolveCommand } from '../platform.js';
16
16
  import { post } from '../api.js';
17
17
  import { getAuth } from '../auth.js';
18
18
  import { confirm, getAutoConfirm } from '../utils.js';
19
19
  import { bold, brand, dim, success, error as clrError, muted } from '../colors.js';
20
20
  import * as relayState from '../relay/state.js';
21
21
  import { planFor, UnsupportedPlatformError } from '../relay/installers.js';
22
- import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, stripGipityHooks } from '../setup.js';
22
+ import { GIPITY_PLUGIN_ID, GIPITY_MARKETPLACE_NAME, stripGipityHooks, grokInstallState, agentSkillsState, AGENTS_SKILLS_DIR } from '../setup.js';
23
23
  /** Remove Gipity's entries from the user-scope Claude Code settings: the
24
24
  * plugin enablement, the marketplace registration, and any legacy hook
25
25
  * blocks older CLI versions wrote there. Surgical - everything else in the
@@ -219,7 +219,29 @@ export const uninstallCommand = new Command('uninstall')
219
219
  else {
220
220
  console.log(`${muted('No Gipity entries in Claude Code settings.')}`);
221
221
  }
222
- // 5. Wipe ~/.gipity/.
222
+ // 4b. Uninstall the Gipity plugin from Grok Build and remove the skills
223
+ // the CLI copied into the cross-agent ~/.agents/skills dir for Codex.
224
+ // Both best-effort: neither existing is the common case.
225
+ if (grokInstallState().exists) {
226
+ spawnSyncCommand(resolveCommand('grok'), ['plugin', 'uninstall', 'gipity', '--confirm'], {
227
+ stdio: 'ignore',
228
+ timeout: 60_000,
229
+ });
230
+ console.log(`${success('Gipity plugin removed from Grok.')}`);
231
+ }
232
+ const agentSkills = agentSkillsState();
233
+ if (agentSkills.skills.length) {
234
+ for (const name of agentSkills.skills) {
235
+ // Only names our manifest recorded - never someone else's skills.
236
+ try {
237
+ rmSync(join(AGENTS_SKILLS_DIR, name), { recursive: true, force: true });
238
+ }
239
+ catch { /* best-effort */ }
240
+ }
241
+ console.log(`${success(`Removed ${agentSkills.skills.length} Gipity skills from ~/.agents/skills.`)}`);
242
+ }
243
+ // 5. Wipe ~/.gipity/ (this also removes the agent-hooks scripts and the
244
+ // agent-skills manifest, which live under it).
223
245
  if (existsSync(gipityDir)) {
224
246
  try {
225
247
  rmSync(gipityDir, { recursive: true, force: true });
@@ -28,6 +28,7 @@ export const FLAG_ALIASES = {
28
28
  '--src': '--source-dir',
29
29
  '--srcdir': '--source-dir',
30
30
  '--parallel': '--concurrency',
31
+ '--no-sync-output': '--discard-output',
31
32
  '--max': '--limit',
32
33
  '--from': '--since',
33
34
  '--after': '--since',