gipity 1.1.4 → 1.1.5

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/adopt-cwd.js CHANGED
@@ -167,7 +167,7 @@ export function formatCwdLabel(cwd) {
167
167
  const tail = norm.slice(home.length + 1).split(sep);
168
168
  if (tail.length <= 2)
169
169
  return '~/' + tail.join('/');
170
- return '~/…/' + tail.slice(-2).join('/');
170
+ return '~/.../' + tail.slice(-2).join('/');
171
171
  }
172
172
  // Outside home: show parent/this for non-root paths.
173
173
  const parts = norm.split(sep).filter(Boolean);
package/dist/banner.js CHANGED
@@ -115,15 +115,13 @@ function padR(s, width) {
115
115
  const gap = width - visLen(s);
116
116
  return gap > 0 ? s + ' '.repeat(gap) : s;
117
117
  }
118
- /** Truncate from the left with a leading ellipsis if longer than maxW (plain text). */
118
+ /** Truncate from the left with a leading "..." if longer than maxW (plain text). */
119
119
  function leadingEllipsify(s, maxW) {
120
120
  if (s.length <= maxW)
121
121
  return s;
122
- if (maxW <= 1)
122
+ if (maxW <= 4)
123
123
  return s.slice(-maxW);
124
- if (maxW <= 3)
125
- return '…'.padStart(maxW);
126
- return '…' + s.slice(-(maxW - 1));
124
+ return '...' + s.slice(-(maxW - 3));
127
125
  }
128
126
  function center(s, width) {
129
127
  const gap = width - visLen(s);
@@ -207,7 +207,7 @@ export const addCommand = new Command('add')
207
207
  const doAdd = () => post(`/projects/${config.projectGuid}/add`, body);
208
208
  const res = opts.json
209
209
  ? await doAdd()
210
- : await withSpinner('Installing', doAdd, { done: null });
210
+ : await withSpinner('Installing...', doAdd, { done: null });
211
211
  // Pull the created/installed files down to local.
212
212
  const syncResult = await sync({ interactive: false, progress: opts.json ? undefined : createProgressReporter() });
213
213
  const data = res.data;
@@ -50,7 +50,7 @@ const __clPkg = readOwnPkg(__clDir);
50
50
  * incomplete pull, so we can tell the user plainly that nothing was deleted. */
51
51
  function reportSyncResult(result) {
52
52
  if (result.aborted) {
53
- console.log(` ${warning('Merge cancelled this folder was NOT synced with the project.')}`);
53
+ console.log(` ${warning('Merge cancelled - this folder was NOT synced with the project.')}`);
54
54
  console.log(` ${muted('For a clean copy, quit and open the project in an empty folder. To merge anyway, run `gipity sync`.')}`);
55
55
  return;
56
56
  }
@@ -58,11 +58,11 @@ function reportSyncResult(result) {
58
58
  console.log(` Synced ${result.applied} change${result.applied > 1 ? 's' : ''} with Gipity.`);
59
59
  }
60
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:`)}`);
61
+ console.log(` ${warning(`Sync finished with ${result.errors.length} problem${result.errors.length === 1 ? '' : 's'} - your local copy may be incomplete:`)}`);
62
62
  for (const e of result.errors.slice(0, 8))
63
63
  console.log(` - ${e}`);
64
64
  if (result.errors.length > 8)
65
- console.log(` and ${result.errors.length - 8} more.`);
65
+ console.log(` ...and ${result.errors.length - 8} more.`);
66
66
  console.log(` ${muted('Nothing was deleted. Re-run `gipity sync` to finish the pull.')}`);
67
67
  }
68
68
  }
@@ -133,7 +133,7 @@ function localFsFallback(dir) {
133
133
  };
134
134
  walk(dir, 0);
135
135
  const topLevel = topLevelEntries.length
136
- ? topLevelEntries.slice(0, 20).join(', ') + (topLevelEntries.length > 20 ? ', ' : '')
136
+ ? topLevelEntries.slice(0, 20).join(', ') + (topLevelEntries.length > 20 ? ', ...' : '')
137
137
  : '(empty directory)';
138
138
  return { fileCount, folderCount, totalBytes, topLevel };
139
139
  }
@@ -502,6 +502,8 @@ async function runLaunch(cmdName, cmdCfg, opts) {
502
502
  console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
503
503
  }
504
504
  setupProjectTools();
505
+ if (!nonInteractive)
506
+ console.log(` ${muted('Installed Gipity plugin, skills, and hooks.')}`);
505
507
  if (nonInteractive) {
506
508
  // Headless: the -p message is wrapped later (Step 3). Just record
507
509
  // whether to use the new-project framing for that wrap.
@@ -673,6 +675,8 @@ async function runLaunch(cmdName, cmdCfg, opts) {
673
675
  // Claude directly what they want to build or do.
674
676
  initialPrompt = '';
675
677
  setupProjectTools();
678
+ if (!nonInteractive)
679
+ console.log(` ${muted('Installed Gipity plugin, skills, and hooks.')}`);
676
680
  console.log(` ${success(`Project "${project.name}" ready.`)}\n`);
677
681
  }
678
682
  // ── Step 3: Pick the coding agent + model ─────────────────────────
@@ -812,9 +816,8 @@ async function runLaunch(cmdName, cmdCfg, opts) {
812
816
  }
813
817
  if (!nonInteractive) {
814
818
  console.log(` ${bold('Launching Claude Code, powered by Gipity.')}`);
815
- console.log(` ${muted("Just tell Claude what you'd like to build or do - everything Claude can do,")}`);
816
- console.log(` ${muted('plus hosting, databases, and live deploys on Gipity.')}`);
817
819
  if (convGuidForHooks) {
820
+ console.log('');
818
821
  console.log(` ${muted(`This session is saved to Gipity - watch it live at ${brand('prompt.gipity.ai')}. (--no-capture on init to disable.)`)}`);
819
822
  }
820
823
  console.log('');
@@ -960,12 +963,11 @@ async function pickAgent(lastUsed) {
960
963
  const defaultIdx = AGENT_ADAPTERS.findIndex(a => a.key === defaultKey) + 1;
961
964
  console.log(` ${bold('Which coding agent?')}\n`);
962
965
  AGENT_ADAPTERS.forEach((a, i) => {
963
- const installed = binaryOnPath(a.binary) || (a.key === 'claude' && isClaudeInstalled());
966
+ // Keep the list clean: no install-state notes (picking an uninstalled
967
+ // agent installs it, or fails with the install hint at that point).
964
968
  const notes = [];
965
969
  if (a.key === lastUsed)
966
970
  notes.push('last used');
967
- if (!installed)
968
- notes.push(a.ensureInstalled ? "not installed - we'll install it" : 'not installed');
969
971
  if (a.key === 'codex' && !a.hooksSupportedOnPlatform(process.platform)) {
970
972
  notes.push('session recording unavailable on Windows');
971
973
  }
@@ -1101,9 +1103,8 @@ async function launchNonClaudeAgent(adapter, ctx) {
1101
1103
  else {
1102
1104
  agentArgs = [...adapter.buildInteractiveArgs({ resume, model }), ...extras];
1103
1105
  console.log(` ${bold(`Launching ${adapter.displayName}, powered by Gipity.`)}`);
1104
- console.log(` ${muted(`Just tell ${adapter.displayName} what you'd like to build or do - plus hosting,`)}`);
1105
- console.log(` ${muted('databases, and live deploys on Gipity.')}`);
1106
1106
  if (convGuid) {
1107
+ console.log('');
1107
1108
  console.log(` ${muted(`This session is saved to Gipity - watch it live at ${brand('prompt.gipity.ai')}. (--no-capture on init to disable.)`)}`);
1108
1109
  }
1109
1110
  if (adapter.oneTimeSetupNote) {
@@ -25,7 +25,7 @@ export const chatCommand = new Command('chat')
25
25
  const doChat = () => post(endpoint, body);
26
26
  const res = opts.json
27
27
  ? await doChat()
28
- : await withSpinner('Thinking', doChat, { done: null });
28
+ : await withSpinner('Thinking...', doChat, { done: null });
29
29
  // Save conversation guid for continuity. Skipped in one-off mode: the
30
30
  // config was resolved from the server's Home project and there is no
31
31
  // local `.gipity.json` to update - persisting here would create one in
@@ -57,7 +57,7 @@ export const deployCommand = new Command('deploy')
57
57
  });
58
58
  const res = opts.json
59
59
  ? await doDeploy()
60
- : await withSpinner(`Deploying to ${target}…`, doDeploy, { done: null });
60
+ : await withSpinner(`Deploying to ${target}...`, doDeploy, { done: null });
61
61
  const d = res.data;
62
62
  // Deploy + verify in one command: after a successful deploy, run the
63
63
  // same inspect pipeline `page inspect` uses against the live URL. An
@@ -219,7 +219,7 @@ Examples:
219
219
  seed: Number.isFinite(opts.seed) ? opts.seed : undefined,
220
220
  input_images: inputImages,
221
221
  });
222
- const verb = inputImages ? 'Editing image' : 'Generating image';
222
+ const verb = inputImages ? 'Editing image...' : 'Generating image...';
223
223
  const result = opts.json
224
224
  ? await doGenerate()
225
225
  : await withSpinner(verb, doGenerate, { done: null });
@@ -285,7 +285,7 @@ Examples:
285
285
  // Veo runs 30-120s; the bouncing bar + timer keeps the wait honest.
286
286
  const result = opts.json
287
287
  ? await doGenerate()
288
- : await withSpinner('Generating video', doGenerate, { done: null });
288
+ : await withSpinner('Generating video...', doGenerate, { done: null });
289
289
  const filename = opts.output || 'generated.mp4';
290
290
  const savedPath = await downloadFile(result.url, filename, !!opts.output);
291
291
  if (opts.json) {
@@ -349,7 +349,7 @@ Examples:
349
349
  });
350
350
  const result = opts.json
351
351
  ? await doGenerate()
352
- : await withSpinner('Generating speech', doGenerate, { done: null });
352
+ : await withSpinner('Generating speech...', doGenerate, { done: null });
353
353
  const filename = opts.output || 'speech.mp3';
354
354
  const savedPath = await downloadFile(result.url, filename, !!opts.output);
355
355
  if (opts.json) {
@@ -402,7 +402,7 @@ Examples:
402
402
  });
403
403
  const result = opts.json
404
404
  ? await doGenerate()
405
- : await withSpinner('Generating music', doGenerate, { done: null });
405
+ : await withSpinner('Generating music...', doGenerate, { done: null });
406
406
  const filename = opts.output || 'music.mp3';
407
407
  const savedPath = await downloadFile(result.url, filename, !!opts.output);
408
408
  if (opts.json) {
@@ -453,7 +453,7 @@ Examples:
453
453
  });
454
454
  const result = opts.json
455
455
  ? await doGenerate()
456
- : await withSpinner('Generating sound effect', doGenerate, { done: null });
456
+ : await withSpinner('Generating sound effect...', doGenerate, { done: null });
457
457
  const filename = opts.output || 'sound.mp3';
458
458
  const savedPath = await downloadFile(result.url, filename, !!opts.output);
459
459
  if (opts.json) {
@@ -61,7 +61,7 @@ gmailCommand
61
61
  .option('--cc <email>', 'Cc recipient (repeatable)', collect, [])
62
62
  .option('--bcc <email>', 'Bcc recipient (repeatable)', collect, [])
63
63
  .option('--reply-to <email>', 'Reply-To header address')
64
- .requiredOption('--subject <s>', 'Subject (usually "Re: " of the original)')
64
+ .requiredOption('--subject <s>', 'Subject (usually "Re: ..." of the original)')
65
65
  .requiredOption('--body <text>', 'Reply body (you compose the quote header)')
66
66
  .option('--json', 'Output as JSON')
67
67
  .action((opts) => run('Reply', async () => {
@@ -63,8 +63,13 @@ jobCommand
63
63
  const r = res.data;
64
64
  const statusColor = r.status === 'success' ? success : r.status === 'failed' ? clrError : muted;
65
65
  console.log(`${statusColor(r.status)} ${muted(r.guid)}`);
66
- if (r.progress_pct != null)
67
- console.log(`progress: ${Math.round(r.progress_pct * 100)}%${r.progress_message ? ` (${r.progress_message})` : ''}`);
66
+ if (r.progress_pct != null) {
67
+ const prog = `${Math.round(r.progress_pct * 100)}%${r.progress_message ? ` (${r.progress_message})` : ''}`;
68
+ // A failed run can have reported 100% (done) right before dying; a bare
69
+ // "progress: 100% (done)" next to status=failed reads as success. Label it.
70
+ const failedish = r.status === 'failed' || r.status === 'cancelled' || r.status === 'canceled' || r.status === 'error';
71
+ console.log(failedish ? `last progress before ${r.status}: ${prog}` : `progress: ${prog}`);
72
+ }
68
73
  if (r.duration_ms != null)
69
74
  console.log(`duration: ${r.duration_ms}ms`);
70
75
  if (r.error)
@@ -98,7 +103,7 @@ jobCommand
98
103
  ? `${Math.round(r.progress_pct * 100)}%${r.progress_message ? ` (${r.progress_message})` : ''}`
99
104
  : r.status;
100
105
  if (prog !== lastProgress) {
101
- console.error(muted(`… ${prog}`));
106
+ console.error(muted(`... ${prog}`));
102
107
  lastProgress = prog;
103
108
  }
104
109
  }
@@ -198,7 +203,7 @@ jobCommand
198
203
  }
199
204
  catch (e) {
200
205
  if (peekedOut) {
201
- console.error(muted(`… still streaming after ${peekSeconds}s. Run \`gipity job logs ${runGuid} --timeout ${Math.round(peekSeconds)}\` again to keep watching, or \`gipity job wait ${runGuid}\` to block until it finishes.`));
206
+ console.error(muted(`... still streaming after ${peekSeconds}s. Run \`gipity job logs ${runGuid} --timeout ${Math.round(peekSeconds)}\` again to keep watching, or \`gipity job wait ${runGuid}\` to block until it finishes.`));
202
207
  return;
203
208
  }
204
209
  throw e;
@@ -221,7 +226,7 @@ jobCommand
221
226
  catch (e) {
222
227
  // Peek window elapsed mid-stream: report cleanly and exit 0 (not an error).
223
228
  if (peekedOut) {
224
- console.error(muted(`… still streaming after ${peekSeconds}s. Run \`gipity job logs ${runGuid} --timeout ${Math.round(peekSeconds)}\` again to keep watching, or \`gipity job wait ${runGuid}\` to block until it finishes.`));
229
+ console.error(muted(`... still streaming after ${peekSeconds}s. Run \`gipity job logs ${runGuid} --timeout ${Math.round(peekSeconds)}\` again to keep watching, or \`gipity job wait ${runGuid}\` to block until it finishes.`));
225
230
  return;
226
231
  }
227
232
  throw e;
@@ -152,7 +152,7 @@ export const loadCommand = new Command('load')
152
152
  if (opts.inspect) {
153
153
  const res = opts.json
154
154
  ? await doInspect()
155
- : await withSpinner('Inspecting', doInspect, { done: null });
155
+ : await withSpinner('Inspecting...', doInspect, { done: null });
156
156
  if (opts.json) {
157
157
  console.log(JSON.stringify(res.data));
158
158
  return;
@@ -162,7 +162,7 @@ export const loadCommand = new Command('load')
162
162
  }
163
163
  const { data, partial } = opts.json
164
164
  ? await doImport()
165
- : await withSpinner('Importing', doImport, { done: null });
165
+ : await withSpinner('Importing...', doImport, { done: null });
166
166
  const project = data.project;
167
167
  // Materialize a local dir and link it, exactly like `gipity project create`:
168
168
  // `.gipity.json` is written directly inside the new dir, then a soft sync
@@ -4,6 +4,7 @@ import { publicPost } from '../api.js';
4
4
  import { prompt, decodeJwtExp } from '../utils.js';
5
5
  import { success, error as clrError, muted } from '../colors.js';
6
6
  import { flushBugQueue } from '../bug-queue.js';
7
+ import { warnBeforeCodeIfUnexpectedNewAccount, warnIfUnexpectedNewAccount } from '../login-flow.js';
7
8
  export const loginCommand = new Command('login')
8
9
  .description('Log in or sign up')
9
10
  .option('--email <email>', 'Email address')
@@ -19,9 +20,10 @@ export const loginCommand = new Command('login')
19
20
  }
20
21
  // Email only → send code and exit (non-interactive step 1)
21
22
  if (email && !code) {
22
- await publicPost('/auth/login', { email });
23
+ const sendRes = await publicPost('/auth/login', { email });
23
24
  console.log('Check your email for a 6-digit code.');
24
25
  console.log(muted(`Then run: gipity login --email ${email} --code <code>`));
26
+ warnBeforeCodeIfUnexpectedNewAccount(sendRes.isNewUser, email);
25
27
  return;
26
28
  }
27
29
  // Fully interactive flow
@@ -34,9 +36,10 @@ export const loginCommand = new Command('login')
34
36
  console.error(clrError('Email required.'));
35
37
  process.exit(1);
36
38
  }
37
- await publicPost('/auth/login', { email });
39
+ const sendRes = await publicPost('/auth/login', { email });
38
40
  console.log('');
39
41
  console.log('Check your email for a 6-digit code.');
42
+ warnBeforeCodeIfUnexpectedNewAccount(sendRes.isNewUser, email);
40
43
  code = await prompt('Code: ');
41
44
  await verify(email, code);
42
45
  }
@@ -46,6 +49,7 @@ export const loginCommand = new Command('login')
46
49
  }
47
50
  });
48
51
  async function verify(email, code) {
52
+ const priorAuth = getAuth();
49
53
  const res = await publicPost('/auth/verify', { email, code });
50
54
  const exp = decodeJwtExp(res.accessToken);
51
55
  if (!exp) {
@@ -60,6 +64,7 @@ async function verify(email, code) {
60
64
  expiresAt,
61
65
  });
62
66
  console.log(success(`Logged in (${email}).`));
67
+ warnIfUnexpectedNewAccount(res.isNewUser, email, priorAuth);
63
68
  // A fresh session is the clearest "we're reconnected" signal - clear any bug
64
69
  // reports that got stranded while this account's session was expired/offline.
65
70
  const delivered = await flushBugQueue().catch(() => 0);
@@ -106,7 +106,7 @@ export function summarizeExpr(expr) {
106
106
  if (oneLine && expr.trim().length <= EXPR_ECHO_MAX_CHARS)
107
107
  return expr.trim();
108
108
  const first = (meaningful[0] ?? '').trim();
109
- const head = first.length > EXPR_ECHO_MAX_CHARS ? `${first.slice(0, EXPR_ECHO_MAX_CHARS - 1)}…` : first;
109
+ const head = first.length > EXPR_ECHO_MAX_CHARS ? `${first.slice(0, EXPR_ECHO_MAX_CHARS - 3)}...` : first;
110
110
  const shape = oneLine
111
111
  ? `(${expr.trim().length} chars)`
112
112
  : `(+${meaningful.length - 1} more ${meaningful.length - 1 === 1 ? 'line' : 'lines'}, ${expr.trim().length} chars)`;
@@ -379,7 +379,7 @@ export const pageEvalCommand = new Command('eval')
379
379
  .description('Evaluate JS in a real browser on a page (DOM, computed styles, element rects; inline expr or --file script). ONE client per call - to verify realtime/presence across concurrent clients use `page test --observe` instead')
380
380
  .argument('<url>', 'URL to load')
381
381
  .argument('[expr]', `JavaScript to evaluate in page context (inline expression or statement body; await works, and the trailing expression is returned automatically — REPL-style — so no explicit return is needed; result is JSON-serialized). Omit when using --file. Time budget: the body has ${EVAL_SCRIPT_BUDGET_MS / 1000}s to finish after page load (${EVAL_SCRIPT_BUDGET_CAMERA_MS / 1000}s with --camera) - raise it with --timeout, max ${EVAL_SCRIPT_BUDGET_MAX_MS / 1000}s.`)
382
- .option('--file <path>', `Read the script body from a file instead of the inline <expr> arg (mutually exclusive), or --file - to read it from stdin (pipe a heredoc: --file - <<'EOF' EOF) with no tmp file. Runs as an async function body, so top-level return/await work. Same post-load budget as <expr> (--timeout).`)
382
+ .option('--file <path>', `Read the script body from a file instead of the inline <expr> arg (mutually exclusive), or --file - to read it from stdin (pipe a heredoc: --file - <<'EOF' ... EOF) with no tmp file. Runs as an async function body, so top-level return/await work. Same post-load budget as <expr> (--timeout).`)
383
383
  .option('--step <expr>', `Run another expression against the SAME loaded page, after <expr> (repeat, max ${MAX_EVAL_STEPS}). Whatever that page load paid for — a vision model coming up, a game booting, a socket connecting — stays up for every step, so an N-part check costs ONE page load instead of N. Each step gets its own in-page budget and its own reported result.`, (val, prev) => [...prev, val], [])
384
384
  .option('--fixture <path>', 'Host a local file and expose it to the eval as `fixtureUrl` (and under `fixtures` by basename) to fetch in-page. For verifying a render/parse path against a real binary (an MP3, an image) - no size limit, auto-deleted after the run. The hosted URL has permissive CORS, so `new Image(); img.crossOrigin="anonymous"; img.src=fixtureUrl` decodes untainted for a canvas/pixel read - this is the UPLOAD analog of --camera: feed a known photo/video to a file-upload vision app (web-vision-detect) and run its detector, no need to deploy a throwaway test asset into the app. Repeat for several files (single-value so it never swallows the inline <expr>).', (val, prev) => [...prev, val], [])
385
385
  .option('--reload <expr>', 'After the first eval, reload the page IN PLACE (localStorage/sessionStorage/cookies preserved) and evaluate this second expression against the post-reload DOM. One command verifies persisted state survives a reload: seed/assert state with <expr>, then assert the restored UI here.')
@@ -426,7 +426,7 @@ export const pageEvalCommand = new Command('eval')
426
426
  }
427
427
  catch {
428
428
  pageEvalCommand.error(opts.file === '-'
429
- ? 'error: --file - reads the script from stdin, but stdin was empty/unreadable pipe it in, e.g. gipity page eval "<url>" --file - <<\'EOF\' EOF'
429
+ ? 'error: --file - reads the script from stdin, but stdin was empty/unreadable - pipe it in, e.g. gipity page eval "<url>" --file - <<\'EOF\' ... EOF'
430
430
  : `error: Cannot read file: ${opts.file}`);
431
431
  }
432
432
  }
@@ -540,12 +540,12 @@ export const pageEvalCommand = new Command('eval')
540
540
  projectGuid = config.projectGuid;
541
541
  }
542
542
  if (opts.camera) {
543
- console.log(muted(`Hosting camera feed ${opts.camera}…`));
543
+ console.log(muted(`Hosting camera feed ${opts.camera}...`));
544
544
  camera = await uploadCameraFeed(projectGuid, opts.camera);
545
545
  }
546
546
  if (fixturePaths.length) {
547
547
  for (const p of fixturePaths) {
548
- console.log(muted(`Hosting fixture ${p}…`));
548
+ console.log(muted(`Hosting fixture ${p}...`));
549
549
  hosted.push(await uploadPublicFixture(projectGuid, p));
550
550
  }
551
551
  const map = {};
@@ -30,9 +30,6 @@ function looksLikeHtml(body) {
30
30
  const head = body.slice(0, 300).toLowerCase();
31
31
  return /<!doctype html|<html[\s>]/.test(head);
32
32
  }
33
- function sha256(s) {
34
- return createHash('sha256').update(s).digest('hex');
35
- }
36
33
  function baseWithSlash(url) {
37
34
  return url.endsWith('/') ? url : url + '/';
38
35
  }
@@ -43,8 +40,16 @@ function resolveUrl(base, path) {
43
40
  async function fetchRaw(url) {
44
41
  try {
45
42
  const res = await fetch(url, { redirect: 'follow' });
46
- const body = await res.text();
47
- return { status: res.status, contentType: res.headers.get('content-type'), body };
43
+ // Read raw bytes, not text(): decoding binary as UTF-8 turns every invalid
44
+ // byte into a 3-byte replacement char, inflating the measured size ~1.7x.
45
+ const buf = Buffer.from(await res.arrayBuffer());
46
+ return {
47
+ status: res.status,
48
+ contentType: res.headers.get('content-type'),
49
+ body: buf.toString('utf-8'),
50
+ bytes: buf.length,
51
+ sha256: createHash('sha256').update(buf).digest('hex'),
52
+ };
48
53
  }
49
54
  catch {
50
55
  return null; // DNS failure, connection refused, etc.
@@ -59,21 +64,20 @@ async function probeShell(base) {
59
64
  return {
60
65
  served: r.status >= 200 && r.status < 300,
61
66
  status: r.status,
62
- sha256: sha256(r.body),
67
+ sha256: r.sha256,
63
68
  isHtml: looksLikeHtml(r.body),
64
69
  };
65
70
  }
66
71
  function classify(path, r, shell) {
67
72
  if (!r)
68
73
  return { status: null, contentType: null, bytes: 0, verdict: 'MISSING', detail: 'fetch failed (host unreachable)' };
69
- const bytes = Buffer.byteLength(r.body);
70
- const base = { status: r.status, contentType: r.contentType, bytes };
74
+ const base = { status: r.status, contentType: r.contentType, bytes: r.bytes };
71
75
  // Honest 404/5xx - the file simply isn't there.
72
76
  if (r.status >= 400)
73
77
  return { ...base, verdict: 'MISSING', detail: `HTTP ${r.status}` };
74
78
  const expect = expectedType(path);
75
79
  // 1) Served the catch-all shell verbatim → 200, but the file isn't deployed.
76
- if (shell.served && shell.sha256 && sha256(r.body) === shell.sha256) {
80
+ if (shell.served && shell.sha256 && r.sha256 === shell.sha256) {
77
81
  return { ...base, verdict: 'MISSING', detail: `HTTP ${r.status} but served the SPA shell (file not deployed)` };
78
82
  }
79
83
  // 2) Asked for a non-HTML asset but got an HTML body → a per-path shell variant.
@@ -92,7 +96,7 @@ const TAG = {
92
96
  'WRONG-TYPE': warning('WRONG-TYPE'),
93
97
  };
94
98
  export const pageFetchCommand = new Command('fetch')
95
- .description('Verify deployed non-rendered files (llms.txt, AGENTS.md, robots.txt, served JSON) really exist - catches the static-host trap where a missing file returns 200 with the SPA shell instead of a 404')
99
+ .description('Verify deployed non-rendered files (llms.txt, AGENTS.md, robots.txt, served JSON...) really exist - catches the static-host trap where a missing file returns 200 with the SPA shell instead of a 404')
96
100
  .argument('<url>', 'Deployed app base URL; file paths resolve relative to it')
97
101
  .argument('<paths...>', 'One or more file paths to verify, e.g. llms.txt AGENTS.md robots.txt')
98
102
  .option('--json', 'Output as JSON')
@@ -26,10 +26,10 @@ function shortUrl(url, truncate = true, maxLen = 100) {
26
26
  }
27
27
  if (!truncate || result.length <= maxLen)
28
28
  return result;
29
- const keep = maxLen - 1;
29
+ const keep = maxLen - 3;
30
30
  const headLen = Math.ceil(keep / 2);
31
31
  const tailLen = Math.floor(keep / 2);
32
- return result.slice(0, headLen) + '' + result.slice(-tailLen);
32
+ return result.slice(0, headLen) + '...' + result.slice(-tailLen);
33
33
  }
34
34
  export const pageInspectCommand = new Command('inspect')
35
35
  .description('Inspect a web page (console, failed resources, timing, layout overflow). ONE passive load - to verify realtime/presence across concurrent clients use `page test --observe`')
@@ -235,8 +235,8 @@ export const pageScreenshotCommand = new Command('screenshot')
235
235
  .option('--wait <ms>', `Sleep this many ms after DOMContentLoaded before capturing (default 1000, max ${MAX_POST_LOAD_DELAY_MS}). With --camera it defaults to ${CAMERA_DEFAULT_DELAY_MS} so the vision model is up. Same flag as page eval/inspect.`)
236
236
  .option('--wait-for <selector>', `Wait until this CSS selector appears before capturing, then capture (deterministic - beats guessing a --wait). Same flag as page eval/inspect. Gate on what you are photographing ('[data-vision="ready"]', '#verdict:not(:empty)'). Max ${WAIT_FOR_MAX_MS}ms (--wait-timeout).`)
237
237
  .option('--wait-timeout <ms>', `Max ms to wait for --wait-for before giving up (default ${WAIT_FOR_DEFAULT_MS}, max ${WAIT_FOR_MAX_MS}). Timing out is reported - the capture still happens, so you see the state it got stuck in.`)
238
- .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.')
239
- .option('--file <path>', 'Read the pre-capture script from a file instead of inline --action (mutually exclusive), or --file - to read it from stdin (pipe a heredoc: --file - <<\'EOF\' EOF) with no tmp file. For a multi-step driver click through a flow, wait for it to render then capture. Same async-function-body semantics as --action; same --file flag as page eval.')
238
+ .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.')
239
+ .option('--file <path>', 'Read the pre-capture script from a file instead of inline --action (mutually exclusive), or --file - to read it from stdin (pipe a heredoc: --file - <<\'EOF\' ... EOF) with no tmp file. For a multi-step driver - click through a flow, wait for it to render - then capture. Same async-function-body semantics as --action; same --file flag as page eval.')
240
240
  .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.')
241
241
  .option('-o, --output <file>', 'Output path (single viewport only; default .gipity/screenshots/ss-<host>-<timestamp>.png)')
242
242
  .option('--no-reload-between', 'Skip reload between viewports (faster, lower fidelity - only safe for static pages)')
@@ -283,7 +283,7 @@ export const pageScreenshotCommand = new Command('screenshot')
283
283
  }
284
284
  catch {
285
285
  throw new Error(opts.file === '-'
286
- ? "--file - reads the pre-capture script from stdin, but stdin was empty/unreadable pipe it in, e.g. gipity page screenshot \"<url>\" --file - <<'EOF' EOF"
286
+ ? "--file - reads the pre-capture script from stdin, but stdin was empty/unreadable - pipe it in, e.g. gipity page screenshot \"<url>\" --file - <<'EOF' ... EOF"
287
287
  : `Cannot read file: ${opts.file}`);
288
288
  }
289
289
  }
@@ -372,7 +372,7 @@ export const pageScreenshotCommand = new Command('screenshot')
372
372
  assertCameraFile(opts.camera);
373
373
  if (!projectGuid)
374
374
  throw new Error('--camera needs a linked project (the frame is hosted for the browser to fetch) — run `gipity link` first.');
375
- console.log(muted(`Hosting camera feed ${opts.camera}…`));
375
+ console.log(muted(`Hosting camera feed ${opts.camera}...`));
376
376
  camera = await uploadCameraFeed(projectGuid, opts.camera);
377
377
  }
378
378
  // The pre-capture script the server runs after the post-load delay: the
@@ -407,7 +407,7 @@ export const pageScreenshotCommand = new Command('screenshot')
407
407
  try {
408
408
  entries = opts.json
409
409
  ? await doShoot()
410
- : await withSpinner('Capturing', doShoot, { done: null });
410
+ : await withSpinner('Capturing...', doShoot, { done: null });
411
411
  }
412
412
  catch (err) {
413
413
  // The sandbox budget covers the WHOLE capture — load, --action, settle,
@@ -268,7 +268,7 @@ export const pageTestCommand = new Command('test')
268
268
  // Interactive mode (--observe drives it):
269
269
  .option('--observe <expr>', 'Interactive: JS expression sampled in each client to read shared state (e.g. presence count). Switches on interactive mode.')
270
270
  .option('--action <expr>', 'Interactive: one-time JS run in each client before observing (e.g. fill a name + submit). {{label}}/{{i}} are substituted per client.')
271
- .option('--labels <csv>', 'Per-client labels substituted for {{label}} in the URL/--action/--observe (default client-0, client-1, )')
271
+ .option('--labels <csv>', 'Per-client labels substituted for {{label}} in the URL/--action/--observe (default client-0, client-1, ...)')
272
272
  .option('--hold <ms>', `Interactive: total observe window per client (${MIN_HOLD_MS}-${MAX_HOLD_MS}ms)`, '8000')
273
273
  .option('--samples <k>', 'Interactive: number of observations across the hold window (2-30)', '6')
274
274
  .option('--wait-for <selector>', 'Interactive: wait for this CSS selector before running --action (deterministic readiness gate)')
@@ -22,7 +22,7 @@ export const removeCommand = new Command('remove')
22
22
  const doRemove = () => post(`/projects/${config.projectGuid}/remove`, { name: kit });
23
23
  const res = opts.json
24
24
  ? await doRemove()
25
- : await withSpinner('Removing', doRemove, { done: null });
25
+ : await withSpinner('Removing...', doRemove, { done: null });
26
26
  const data = res.data;
27
27
  // Pull the kit's deletions locally. Whitelist ONLY the kit's own removed files
28
28
  // so they bypass the bulk-delete guard (the removal is explicit), while any
@@ -464,7 +464,7 @@ GCC/Rust).
464
464
  });
465
465
  const res = opts.json
466
466
  ? await doRun()
467
- : await withSpinner('Running in sandbox', doRun, { done: null });
467
+ : await withSpinner('Running in sandbox...', doRun, { done: null });
468
468
  // Pull sandbox-written outputs down to the local cwd automatically. The
469
469
  // server has already mirrored them into the project (VFS) and handed back
470
470
  // the exact list, so honoring it here means files land locally without a
@@ -52,7 +52,7 @@ export const saveCommand = new Command('save')
52
52
  const doExport = () => downloadWithHeaders(`/projects/${config.projectGuid}/export`);
53
53
  const { buffer, headers } = opts.json
54
54
  ? await doExport()
55
- : await withSpinner('Exporting', doExport, { done: null });
55
+ : await withSpinner('Exporting...', doExport, { done: null });
56
56
  const filename = dispositionFilename(headers) ?? `${config.projectSlug}.gip`;
57
57
  let dest = path.resolve(output ?? filename);
58
58
  if (output && fs.existsSync(dest) && fs.statSync(dest).isDirectory()) {
@@ -34,7 +34,7 @@ secretsCommand
34
34
  }
35
35
  console.log(bold(`${res.data.length} ${scope} secret${res.data.length === 1 ? '' : 's'}:`));
36
36
  for (const s of res.data) {
37
- const masked = s.preview ? muted(`…${s.preview}`) : muted('(hidden)');
37
+ const masked = s.preview ? muted(`...${s.preview}`) : muted('(hidden)');
38
38
  console.log(` ${s.name} ${masked} ${muted(`updated ${new Date(s.updated_at).toLocaleDateString()}`)}`);
39
39
  }
40
40
  }));
@@ -21,7 +21,7 @@ const SERVICES = [
21
21
  ];
22
22
  export const serviceCommand = new Command('service')
23
23
  .description('Call an app service (llm, tts, image, transcribe, ...)')
24
- .addHelpText('after', '\nCall a Gipity app service: LLM, image, music, TTS, and more.');
24
+ .addHelpText('after', '\nCall a Gipity app service: LLM, image, music, TTS, and more.\nPer-service docs: gipity skill read app-llm | app-tts | app-image | app-video | app-audio (transcribe, sound, music) | app-location');
25
25
  serviceCommand
26
26
  .command('list')
27
27
  .description('List callable app services')
@@ -19,8 +19,7 @@ import { Command } from 'commander';
19
19
  import { getAuth, sessionExpired, refreshTokenIfNeeded } from '../auth.js';
20
20
  import { interactiveLogin } from '../login-flow.js';
21
21
  import { runRelaySetup } from '../relay/onboarding.js';
22
- import * as relayState from '../relay/state.js';
23
- import { bold, brand, success, muted, error as clrError } from '../colors.js';
22
+ import { bold, muted, error as clrError } from '../colors.js';
24
23
  export const connectCommand = new Command('connect')
25
24
  .alias('setup') // legacy name, kept working but not advertised
26
25
  .description('Connect this computer to gipity.ai so the web CLI can drive it (no project, no launch)')
@@ -46,11 +45,10 @@ export const connectCommand = new Command('connect')
46
45
  console.log('');
47
46
  // ── Step 2: Relay setup (always run — the user asked for it) ───────
48
47
  const enabled = await runRelaySetup({ mode: 'run-now' });
49
- // ── Step 3: Done. No project, no Claude Code launch. ──────────────
48
+ // ── Step 3: Done. No project, no Claude Code launch. The shared flow
49
+ // already printed its own "Done!" line - just add the manage hint. ──
50
50
  if (enabled) {
51
- const running = relayState.isRelayEnabled() && !relayState.isPaused();
52
- console.log(` ${success('Done')} — your relay ${running ? 'is running in the background' : 'is set up'} and will start with your computer.`);
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`.')}`);
51
+ console.log(` ${muted('Manage it anytime with `gipity relay status`.')}`);
54
52
  }
55
53
  else {
56
54
  console.log(` ${muted('No relay set up. Run `gipity connect` again anytime, or `gipity build` to start building.')}`);