gipity 1.0.374 → 1.0.380

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.
@@ -22,10 +22,10 @@ import { get, post, publicPost, ApiError, getAccountSlug } from '../api.js';
22
22
  import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, getConfigPath } from '../config.js';
23
23
  import { sync } from '../sync.js';
24
24
  import { slugify, setupClaudeHooks, setupClaudeMd, setupAgentsMd, setupGitignore, DEFAULT_SYNC_IGNORE, isSyncIgnored } from '../setup.js';
25
- import { buildProjectContextBlock as buildProjectContextBlockText, buildExistingProjectPrompt as buildExistingProjectPromptText, buildNewProjectPrompt, buildResumeWrap, buildFreshWrap, } from '../prompts.js';
25
+ import { buildProjectContextBlock as buildProjectContextBlockText, buildNewProjectPrompt, buildResumeWrap, buildFreshWrap, } from '../prompts.js';
26
26
  import * as relayState from '../relay/state.js';
27
27
  import { maybeOfferRelayOn, ensureDaemonRunning } from '../relay/onboarding.js';
28
- import { prompt, promptBoxed, pickOne, decodeJwtExp, confirm } from '../utils.js';
28
+ import { prompt, pickOne, decodeJwtExp, confirm } from '../utils.js';
29
29
  import { brand, bold, info, success, error as clrError, muted } from '../colors.js';
30
30
  import { createProgressReporter } from '../progress.js';
31
31
  import { printBanner } from '../banner.js';
@@ -107,10 +107,6 @@ async function buildProjectContextBlock(opts) {
107
107
  const stats = await fetchProjectStats(opts.projectGuid, opts.cwd);
108
108
  return buildProjectContextBlockText({ ...opts, ...stats });
109
109
  }
110
- async function buildExistingProjectPrompt(opts) {
111
- const stats = await fetchProjectStats(opts.projectGuid, opts.cwd);
112
- return buildExistingProjectPromptText({ ...opts, ...stats });
113
- }
114
110
  /** Interactive email+code login flow. Used on first login and when the
115
111
  * server returns 401 mid-command (session expired). Writes the new auth
116
112
  * to disk and returns it. */
@@ -410,30 +406,12 @@ export const claudeCommand = new Command('claude')
410
406
  // whether to use the new-project framing for that wrap.
411
407
  headlessNewProject = isNewProject;
412
408
  }
413
- else if (isNewProject) {
414
- console.log('');
415
- console.log(` ${bold("What's next? What would you like to build?")}`);
416
- console.log('');
417
- const buildIdea = (await promptBoxed()).trim();
418
- const stats = await fetchProjectStats(project.short_guid, process.cwd());
419
- initialPrompt = buildNewProjectPrompt({
420
- projectName: project.name,
421
- projectSlug: project.slug,
422
- projectGuid: project.short_guid,
423
- accountSlug,
424
- cwd: process.cwd(),
425
- ...stats,
426
- buildIdea,
427
- });
428
- }
429
409
  else {
430
- initialPrompt = await buildExistingProjectPrompt({
431
- projectName: project.name,
432
- projectSlug: project.slug,
433
- projectGuid: project.short_guid,
434
- accountSlug,
435
- cwd: process.cwd(),
436
- });
410
+ // Interactive: launch with no seeded first message. The per-project
411
+ // CLAUDE.md (refreshed just above) carries the project identity,
412
+ // scaffold rule, and definition of done, so Claude has full context
413
+ // the moment the user types. A welcome banner prints before launch.
414
+ initialPrompt = '';
437
415
  }
438
416
  console.log(` ${success(`Project "${project.name}" ready.`)}\n`);
439
417
  }
@@ -470,13 +448,8 @@ export const claudeCommand = new Command('claude')
470
448
  console.log(' Could not sync files (will retry on next prompt).');
471
449
  }
472
450
  }
473
- initialPrompt = await buildExistingProjectPrompt({
474
- projectName: existing.projectSlug,
475
- projectSlug: existing.projectSlug,
476
- projectGuid: existing.projectGuid,
477
- accountSlug: existing.accountSlug,
478
- cwd: process.cwd(),
479
- });
451
+ // Interactive: no seeded prompt - CLAUDE.md carries the context now.
452
+ initialPrompt = '';
480
453
  }
481
454
  else {
482
455
  // Fetch user's projects. If the session expired (401), re-run the
@@ -596,38 +569,11 @@ export const claudeCommand = new Command('claude')
596
569
  console.log(' Could not sync files (will retry on next prompt).');
597
570
  }
598
571
  }
599
- // ── Step 2b: What do you want to build? (new projects only) ────
600
- if (isNewProject) {
601
- console.log('');
602
- console.log(` ${bold('Claude Code enabled with Gipity!')}`);
603
- console.log('');
604
- console.log(` ${bold("What's next? What would you like to build?")}`);
605
- console.log(` ${muted('Examples: a landing page, a Pac-Man game, a full web app,')}`);
606
- console.log(` ${muted('an API that returns random facts, an image, just answer questions?')}`);
607
- console.log('');
608
- console.log(` ${muted('Claude Code with Gipity can do everything your old Claude Code could do but so much more now!')}`);
609
- console.log('');
610
- const buildIdea = (await promptBoxed()).trim();
611
- const stats = await fetchProjectStats(project.short_guid, process.cwd());
612
- initialPrompt = buildNewProjectPrompt({
613
- projectName: project.name,
614
- projectSlug: project.slug,
615
- projectGuid: project.short_guid,
616
- accountSlug,
617
- cwd: process.cwd(),
618
- ...stats,
619
- buildIdea,
620
- });
621
- }
622
- else {
623
- initialPrompt = await buildExistingProjectPrompt({
624
- projectName: project.name,
625
- projectSlug: project.slug,
626
- projectGuid: project.short_guid,
627
- accountSlug,
628
- cwd: process.cwd(),
629
- });
630
- }
572
+ // Interactive launch: no seeded first message (new or existing). The
573
+ // per-project CLAUDE.md carries identity + scaffold rule + definition
574
+ // of done, and a welcome banner prints before launch - the user tells
575
+ // Claude directly what they want to build or do.
576
+ initialPrompt = '';
631
577
  setupClaudeHooks();
632
578
  setupClaudeMd();
633
579
  setupAgentsMd();
@@ -729,7 +675,10 @@ export const claudeCommand = new Command('claude')
729
675
  claudeArgs.push(arg);
730
676
  }
731
677
  if (!nonInteractive) {
732
- console.log(` ${info('Launching Claude Code...')}\n`);
678
+ console.log(` ${bold('Launching Claude Code, powered by Gipity.')}`);
679
+ console.log(` ${muted("Just tell Claude what you'd like to build or do - everything Claude could do")}`);
680
+ console.log(` ${muted('before, and now, on Gipity, so much more.')}`);
681
+ console.log('');
733
682
  }
734
683
  // In non-interactive (-p) mode, prepend a Gipity preamble to the
735
684
  // user's raw message. Two flavors:
@@ -56,7 +56,7 @@ export const deployCommand = new Command('deploy')
56
56
  else {
57
57
  // Fallback for simple deploys without phases
58
58
  const size = formatSize(d.totalBytes);
59
- console.log(`${success('✓')} ${d.fileCount} files (${size}) → ${success(d.url)}`);
59
+ console.log(`${success('✓')} ${d.fileCount} files (${size})`);
60
60
  }
61
61
  if (d.customDomains?.length) {
62
62
  console.log(`${muted('Also:')} ${d.customDomains.join(', ')}`);
@@ -80,6 +80,11 @@ export const deployCommand = new Command('deploy')
80
80
  }
81
81
  else {
82
82
  console.log(success(`✓ Deployed to ${target}`) + muted(` (${d.elapsedMs}ms)`));
83
+ // The live URL is the one thing the caller (often an agent) needs next
84
+ // - to open it, inspect it, or report it. Always surface it so nobody
85
+ // has to reconstruct the URL convention or guess a subdomain.
86
+ if (d.url)
87
+ console.log(`${muted('Live:')} ${brand(d.url)}`);
83
88
  }
84
89
  }));
85
90
  //# sourceMappingURL=deploy.js.map
@@ -5,11 +5,13 @@ import { run } from '../helpers/index.js';
5
5
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
6
  /** Poll the async eval job until it finishes. Eval runs server-side as a
7
7
  * short-lived job (so a long --wait can't trip the gateway idle timeout);
8
- * we submit, then poll the result out of the job store. */
9
- async function pollEvalResult(evalJobId, waitMs) {
8
+ * we submit, then poll the result out of the job store. `expectedWorkMs` is
9
+ * the time the server-side work is expected to take (settle + any in-page
10
+ * awaits); the client budget is that plus 60s of headroom. */
11
+ export async function pollEvalResult(evalJobId, expectedWorkMs) {
10
12
  // Generous client budget: the server work is bounded by --wait plus browser
11
13
  // open/settle overhead; give it that plus headroom before giving up.
12
- const deadline = Date.now() + waitMs + 60_000;
14
+ const deadline = Date.now() + expectedWorkMs + 60_000;
13
15
  let missCount = 0;
14
16
  while (Date.now() < deadline) {
15
17
  let rec;
@@ -69,4 +71,15 @@ export const pageEvalCommand = new Command('eval')
69
71
  if (d.truncated)
70
72
  console.log(muted('\n(result truncated to fit context - narrow the expression for the full value)'));
71
73
  }));
74
+ // Each `page eval` call runs to completion before the next starts, so two evals
75
+ // fired back-to-back never coexist in time - they CANNOT test whether two live
76
+ // clients see each other (presence, shared state). For that, use the genuinely-
77
+ // concurrent `page test --observe` instead, which overlaps N clients and reports
78
+ // whether they actually ran together.
79
+ pageEvalCommand.addHelpText('after', `
80
+ Testing realtime/shared state across clients?
81
+ Separate 'page eval' calls run sequentially (one finishes before the next
82
+ starts), so they never overlap and will each see only themselves - a false
83
+ negative. Use 'gipity page test <url> --observe <expr>' for genuinely
84
+ concurrent clients with overlap verification.`);
72
85
  //# sourceMappingURL=page-eval.js.map
@@ -105,6 +105,10 @@ export const pageScreenshotCommand = new Command('screenshot')
105
105
  .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so voice/camera apps render headlessly (audio is a built-in tone, not real speech)')
106
106
  .option('--json', 'Output JSON metadata instead of a friendly summary')
107
107
  .addOption(new Option('--wait <ms>', 'Alias for --post-load-delay').hideHelp())
108
+ // `--full-page` is the Puppeteer/Playwright name for this (their `fullPage`),
109
+ // so agents reach for it by reflex. Accept it as a hidden alias for `--full`
110
+ // rather than reject it as an unknown option and send them on a --help detour.
111
+ .addOption(new Option('--full-page', 'Alias for --full').hideHelp())
108
112
  .action((url, opts) => run('Page screenshot', async () => {
109
113
  const delayRaw = opts.postLoadDelay ?? opts.wait;
110
114
  const postLoadDelayMs = delayRaw !== undefined ? parseInt(String(delayRaw), 10) : undefined;
@@ -126,7 +130,7 @@ export const pageScreenshotCommand = new Command('screenshot')
126
130
  const body = {
127
131
  url,
128
132
  postLoadDelayMs,
129
- full: !!opts.full,
133
+ full: !!(opts.full || opts.fullPage),
130
134
  reloadBetween: opts.reloadBetween !== false,
131
135
  ...(userSpecifiedViewports ? { viewports: customViewports } : {}),
132
136
  ...(opts.fakeMedia ? { fakeMedia: true } : {}),
@@ -2,6 +2,7 @@ import { Command } from 'commander';
2
2
  import { post } from '../api.js';
3
3
  import { brand, bold, muted, warning, success, error as clrError } from '../colors.js';
4
4
  import { run } from '../helpers/index.js';
5
+ import { pollEvalResult } from './page-eval.js';
5
6
  // Lines worth surfacing - genuine errors and crash signatures, not benign warnings.
6
7
  const BAD = /^error:|uncaught|unhandled|message handler error|\bcrash|RuntimeError/i;
7
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -15,25 +16,148 @@ async function inspectClient(url, waitMs, i) {
15
16
  return { i, lines: [], error: err instanceof Error ? err.message : String(err) };
16
17
  }
17
18
  }
18
- // Headless multi-client realtime check: spin N staggered browser clients at a
19
- // deployed URL and flag error/crash lines across their consoles. The verifier
20
- // for realtime apps (host election, presence, world sync, reconnection) -
21
- // promotes the internal multi-client-test script to a first-class command.
22
- //
23
- // Passive page loads only: each client just loads the URL and settles. An app
24
- // that connects to realtime only after a user action (e.g. a lobby that joins
25
- // on a button press) needs a URL-param test mode so the load alone exercises
26
- // the path - see the app-realtime skill.
27
- export const pageTestCommand = new Command('test')
28
- .description('Multi-client realtime check: load a URL in N staggered headless clients, flag console errors')
29
- .argument('<url>', 'Deployed URL to load in every client')
30
- .option('--clients <n>', 'Number of headless clients to launch', '2')
31
- .option('--stagger <s>', 'Seconds between client starts (client 0 settles first, e.g. as host)', '12')
32
- .option('--wait <ms>', 'Milliseconds each client stays open after load (max 30000)', '24000')
33
- .option('--json', 'Output as JSON')
34
- .action((url, opts) => run('Page test', async () => {
19
+ const MAX_HOLD_MS = 15_000; // keep each in-page await under the ~20s browser action timeout
20
+ const MIN_HOLD_MS = 1_000;
21
+ /** Splice per-client values into a user expression. `{{label}}` the client's
22
+ * label, `{{i}}` its 0-based index. Plain string replace (no regex) so the
23
+ * expression's own characters are never treated as patterns. */
24
+ function subst(expr, label, i) {
25
+ return expr.split('{{label}}').join(label).split('{{i}}').join(String(i));
26
+ }
27
+ /** Build the statement-body script one client runs: do the one-time action,
28
+ * then sample `observe` `samples` times across `holdMs`, stamping in-page
29
+ * start/end so the caller can confirm the clients overlapped. */
30
+ function buildHarness(action, observe, label, holdMs, samples) {
31
+ const n = Math.max(2, samples);
32
+ const interval = Math.max(0, Math.floor(holdMs / (n - 1)));
33
+ const lines = [
34
+ `const __label=${JSON.stringify(label)};`,
35
+ `const __t0=Date.now();`,
36
+ ];
37
+ if (action && action.trim()) {
38
+ lines.push(`try{ ${action} }catch(__e){ return {label:__label,startedAt:__t0,endedAt:Date.now(),samples:[],actionError:String((__e&&__e.message)||__e)}; }`);
39
+ }
40
+ lines.push(`const __s=[];`, `for(let __k=0;__k<${n};__k++){`, ` let __v; try{ __v=(${observe}); }catch(__e){ __v='ObserveError: '+String((__e&&__e.message)||__e); }`, ` __s.push(__v);`, ` if(__k<${n - 1}) await new Promise(function(r){setTimeout(r,${interval});});`, `}`, `return {label:__label,startedAt:__t0,endedAt:Date.now(),samples:__s};`);
41
+ return lines.join('\n');
42
+ }
43
+ /** Kick off and poll one client's eval job, then parse the harness payload. */
44
+ async function observeClient(url, expr, i, label, settleMs, holdMs, waitForSelector) {
45
+ const base = { i, label, samples: [], startedAt: 0, endedAt: 0 };
46
+ try {
47
+ const kickoff = await post('/tools/browser/eval', {
48
+ url,
49
+ expr,
50
+ waitMs: settleMs,
51
+ waitForSelector: waitForSelector || undefined,
52
+ waitForTimeoutMs: waitForSelector ? 5000 : undefined,
53
+ });
54
+ // Server work ≈ nav + settle + the in-page hold; pollEvalResult adds 60s headroom.
55
+ const d = await pollEvalResult(kickoff.data.evalJobId, settleMs + holdMs);
56
+ const raw = d.result?.trim() ?? '';
57
+ if (raw.startsWith('EvalError:') || raw.startsWith('ObserveError:')) {
58
+ return { ...base, error: raw };
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(raw);
63
+ }
64
+ catch {
65
+ return { ...base, error: `unparseable client result: ${raw.slice(0, 200)}` };
66
+ }
67
+ if (parsed.actionError) {
68
+ return { ...base, error: `action failed: ${parsed.actionError}` };
69
+ }
70
+ return {
71
+ i,
72
+ label: typeof parsed.label === 'string' ? parsed.label : label,
73
+ samples: Array.isArray(parsed.samples) ? parsed.samples : [],
74
+ startedAt: typeof parsed.startedAt === 'number' ? parsed.startedAt : 0,
75
+ endedAt: typeof parsed.endedAt === 'number' ? parsed.endedAt : 0,
76
+ };
77
+ }
78
+ catch (err) {
79
+ return { ...base, error: err instanceof Error ? err.message : String(err) };
80
+ }
81
+ }
82
+ /** Compute the window during which ALL successful clients were live at once.
83
+ * Intersection of every [startedAt, endedAt]; positive duration ⇒ they
84
+ * genuinely coexisted, so any shared-state reading is trustworthy. */
85
+ function overlapMs(results) {
86
+ const live = results.filter((r) => !r.error && r.startedAt > 0 && r.endedAt > r.startedAt);
87
+ if (live.length < 2)
88
+ return 0;
89
+ const start = Math.max(...live.map((r) => r.startedAt));
90
+ const end = Math.min(...live.map((r) => r.endedAt));
91
+ return Math.max(0, end - start);
92
+ }
93
+ function fmtSamples(samples) {
94
+ if (samples.length === 0)
95
+ return muted('(no samples)');
96
+ return samples.map((s) => (typeof s === 'string' ? s : JSON.stringify(s))).join(' → ');
97
+ }
98
+ async function runInteractive(url, observe, opts) {
99
+ const clients = Math.max(1, parseInt(opts.clients, 10) || 2);
100
+ const stagger = opts.stagger != null ? Math.max(0, parseInt(opts.stagger, 10) || 0) : 0;
101
+ const hold = Math.min(MAX_HOLD_MS, Math.max(MIN_HOLD_MS, parseInt(opts.hold, 10) || 8000));
102
+ const samples = Math.min(30, Math.max(2, parseInt(opts.samples, 10) || 6));
103
+ const settle = opts.waitFor ? 200 : 1000;
104
+ const labels = (opts.labels ? String(opts.labels).split(',').map((s) => s.trim()) : []).filter(Boolean);
105
+ const labelFor = (i) => labels[i] ?? `client-${i}`;
106
+ if (!opts.json) {
107
+ console.log(`${brand('Page test')} ${muted('(interactive)')} ${bold(url)}`);
108
+ console.log(muted(`${clients} client(s), stagger ${stagger}s, hold ${hold}ms, ${samples} samples each`));
109
+ }
110
+ const runs = [];
111
+ for (let i = 0; i < clients; i++) {
112
+ runs.push((async () => {
113
+ await sleep(i * stagger * 1000);
114
+ if (!opts.json)
115
+ console.log(muted(`client ${i} (${labelFor(i)}) joining`));
116
+ const expr = buildHarness(opts.action ? subst(opts.action, labelFor(i), i) : undefined, subst(observe, labelFor(i), i), labelFor(i), hold, samples);
117
+ return observeClient(url, expr, i, labelFor(i), settle, hold, opts.waitFor);
118
+ })());
119
+ }
120
+ const results = (await Promise.all(runs)).sort((a, b) => a.i - b.i);
121
+ const errored = results.filter((r) => r.error);
122
+ const ovl = overlapMs(results);
123
+ const overlapped = ovl > 0;
124
+ if (opts.json) {
125
+ console.log(JSON.stringify({
126
+ url, mode: 'interactive', clients, stagger, hold, samples,
127
+ overlapMs: ovl, overlapped, results,
128
+ }));
129
+ if (errored.length > 0 || (clients > 1 && !overlapped))
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ for (const r of results) {
134
+ console.log(`\n${bold(`=== client ${r.i} (${r.label}) ===`)}`);
135
+ if (r.error) {
136
+ console.log(clrError(`✗ ${r.error}`));
137
+ continue;
138
+ }
139
+ console.log(`${muted('samples:')} ${fmtSamples(r.samples)}`);
140
+ }
141
+ console.log('');
142
+ if (errored.length > 0) {
143
+ console.log(clrError(`⚠ ${errored.length} client(s) failed (see above)`));
144
+ }
145
+ if (clients < 2) {
146
+ console.log(muted('Note: a single client cannot verify cross-client visibility — run with --clients 2+.'));
147
+ }
148
+ else if (overlapped) {
149
+ console.log(success(`✓ all clients overlapped for ~${(ovl / 1000).toFixed(1)}s — genuine concurrency, so the readings above are trustworthy`));
150
+ }
151
+ else {
152
+ console.log(clrError('⚠ clients did NOT overlap in time — each ran in isolation, so any shared-state reading here is a FALSE NEGATIVE, not proof the app is broken.'));
153
+ console.log(muted(' Likely causes: --stagger ≥ --hold, or more --clients than free browser slots (they queued). Lower --stagger / raise --hold / fewer --clients and retry.'));
154
+ }
155
+ if (errored.length > 0 || (clients > 1 && !overlapped))
156
+ process.exitCode = 1;
157
+ }
158
+ async function runPassive(url, opts) {
35
159
  const clients = Math.max(1, parseInt(opts.clients, 10) || 2);
36
- const stagger = Math.max(0, parseInt(opts.stagger, 10) || 0);
160
+ const stagger = opts.stagger != null ? Math.max(0, parseInt(opts.stagger, 10) || 0) : 12;
37
161
  const wait = Math.min(30000, Math.max(2000, parseInt(opts.wait, 10) || 24000));
38
162
  if (!opts.json) {
39
163
  console.log(`${brand('Page test')} ${bold(url)}`);
@@ -82,5 +206,51 @@ export const pageTestCommand = new Command('test')
82
206
  : `\n${clrError(`⚠ ${problems} error/crash line(s) flagged above`)}`);
83
207
  if (problems > 0)
84
208
  process.exitCode = 1;
209
+ }
210
+ // Headless multi-client realtime check. Two modes:
211
+ //
212
+ // Passive (default): spin N staggered browser clients at a deployed URL and
213
+ // flag error/crash lines across their consoles. Each client just loads the URL
214
+ // and settles - good for apps that connect on load (or via a URL-param test
215
+ // mode; see the app-realtime skill).
216
+ //
217
+ // Interactive (--observe, optionally with --action): each client loads the
218
+ // URL, runs a one-time --action (e.g. submit a name), then samples --observe
219
+ // across a hold window. The clients run genuinely concurrently and the command
220
+ // VERIFIES they overlapped in time - so a presence/shared-state app whose
221
+ // readings only make sense when clients coexist can't be misread as broken
222
+ // just because the clients never actually ran together.
223
+ export const pageTestCommand = new Command('test')
224
+ .description('Multi-client realtime check: load a URL in N concurrent headless clients; flag console errors, or drive an action and observe shared state (--observe)')
225
+ .argument('<url>', 'Deployed URL to load in every client')
226
+ .option('--clients <n>', 'Number of headless clients to launch', '2')
227
+ .option('--stagger <s>', 'Seconds between client starts (passive default 12; interactive default 0)')
228
+ .option('--wait <ms>', 'Passive mode: ms each client stays open after load (max 30000)', '24000')
229
+ // Interactive mode (--observe drives it):
230
+ .option('--observe <expr>', 'Interactive: JS expression sampled in each client to read shared state (e.g. presence count). Switches on interactive mode.')
231
+ .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.')
232
+ .option('--labels <csv>', 'Interactive: per-client labels substituted for {{label}} (default client-0, client-1, …)')
233
+ .option('--hold <ms>', `Interactive: total observe window per client (${MIN_HOLD_MS}-${MAX_HOLD_MS}ms)`, '8000')
234
+ .option('--samples <k>', 'Interactive: number of observations across the hold window (2-30)', '6')
235
+ .option('--wait-for <selector>', 'Interactive: wait for this CSS selector before running --action (deterministic readiness gate)')
236
+ .option('--json', 'Output as JSON')
237
+ .addHelpText('after', `
238
+ Examples:
239
+ # Passive: load in 3 staggered clients, flag console errors
240
+ gipity page test "https://dev.gipity.ai/me/app/" --clients 3 --stagger 8
241
+
242
+ # Interactive: two concurrent clients each join with a name, then watch the
243
+ # live presence count. The command confirms the clients actually overlapped.
244
+ gipity page test "https://dev.gipity.ai/me/app/" --clients 2 \\
245
+ --action "document.querySelector('#name').value='{{label}}'; document.querySelector('form').requestSubmit();" \\
246
+ --observe "document.querySelectorAll('.present').length" \\
247
+ --labels Alice,Bob`)
248
+ .action((url, opts) => run('Page test', async () => {
249
+ if (opts.observe) {
250
+ await runInteractive(url, opts.observe, opts);
251
+ }
252
+ else {
253
+ await runPassive(url, opts);
254
+ }
85
255
  }));
86
256
  //# sourceMappingURL=page-test.js.map
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { join } from 'path';
3
3
  import { mkdirSync } from 'fs';
4
4
  import { get, post, put, del, getAccountSlug } from '../api.js';
5
- import { requireConfig, saveConfig } from '../config.js';
5
+ import { requireConfig, saveConfig, liveUrl } from '../config.js';
6
6
  import { slugify } from '../setup.js';
7
7
  import { error as clrError, brand, muted, info, success } from '../colors.js';
8
8
  import { confirm } from '../utils.js';
@@ -161,6 +161,7 @@ projectCommand
161
161
  console.log(`Name: ${p.name}`);
162
162
  console.log(`Slug: ${p.slug}`);
163
163
  console.log(`GUID: ${p.short_guid}`);
164
+ console.log(`Live: ${liveUrl(config)}`);
164
165
  console.log(`Created: ${new Date(p.created_at).toLocaleDateString()}`);
165
166
  if (p.description)
166
167
  console.log(`Desc: ${p.description}`);
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
- import { dirname, relative } from 'path';
2
+ import { readFileSync } from 'fs';
3
+ import { dirname, extname, relative } from 'path';
3
4
  import { post } from '../api.js';
4
5
  import { resolveProjectContext, getConfigPath } from '../config.js';
5
6
  import { sync } from '../sync.js';
@@ -28,9 +29,10 @@ function resolveRelativeCwd() {
28
29
  export const sandboxCommand = new Command('sandbox')
29
30
  .description('Run code in a sandbox');
30
31
  sandboxCommand
31
- .command('run <code>')
32
+ .command('run [code]')
32
33
  .description('Run code')
33
34
  .option('--language <language>', 'Language: js, py, or bash', 'js')
35
+ .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')
34
36
  .option('--timeout <seconds>', 'Execution timeout in seconds', '30')
35
37
  .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])
36
38
  .option('--json', 'Output as JSON')
@@ -55,6 +57,9 @@ Examples:
55
57
  $ gipity sandbox run --language python \\
56
58
  "import pandas as pd; print(pd.read_csv('data/sales.csv').describe())"
57
59
 
60
+ # Run a script file directly (language inferred from .py)
61
+ $ gipity sandbox run --file build_report.py
62
+
58
63
  # Surgical: only these files are mirrored in
59
64
  $ gipity sandbox run --language bash \\
60
65
  --input src/images/hero.png \\
@@ -68,9 +73,31 @@ Pre-installed: Python (pandas, numpy, matplotlib, Pillow, scipy, bs4),
68
73
  CLI tools (ImageMagick, FFmpeg, webp/cwebp, optipng, jq, pandoc, exiftool,
69
74
  GCC/Rust).
70
75
  `)
71
- .action((code, opts) => run('Sandbox', async () => {
76
+ .action((code, opts, command) => run('Sandbox', async () => {
72
77
  const { config } = await resolveProjectContext();
73
- const language = LANG_MAP[opts.language] || opts.language;
78
+ if (code !== undefined && opts.file) {
79
+ console.error(clrError('Pass either an inline <code> arg or --file <path>, not both'));
80
+ process.exit(1);
81
+ }
82
+ if (code === undefined && !opts.file) {
83
+ console.error(clrError('Provide an inline <code> arg or --file <path>'));
84
+ process.exit(1);
85
+ }
86
+ let source = code;
87
+ if (opts.file) {
88
+ try {
89
+ source = readFileSync(opts.file, 'utf8');
90
+ }
91
+ catch {
92
+ console.error(clrError(`Cannot read file: ${opts.file}`));
93
+ process.exit(1);
94
+ }
95
+ }
96
+ // Infer language from the file extension unless --language was given explicitly.
97
+ const fromExt = opts.file && command.getOptionValueSource('language') === 'default'
98
+ ? LANG_MAP[extname(opts.file).slice(1).toLowerCase()]
99
+ : undefined;
100
+ const language = fromExt || LANG_MAP[opts.language] || opts.language;
74
101
  if (!['javascript', 'python', 'bash'].includes(language)) {
75
102
  console.error(clrError(`Invalid language: ${opts.language}. Use: js, py, or bash`));
76
103
  process.exit(1);
@@ -78,7 +105,7 @@ GCC/Rust).
78
105
  const timeout = parseInt(opts.timeout, 10);
79
106
  const cwd = resolveRelativeCwd();
80
107
  const res = await post(`/projects/${config.projectGuid}/sandbox/execute`, {
81
- code,
108
+ code: source,
82
109
  language,
83
110
  timeout: isNaN(timeout) ? 30 : timeout,
84
111
  input_files: opts.input,
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { existsSync, readFileSync } from 'fs';
3
3
  import { join, resolve } from 'path';
4
4
  import { getAuth, getTimeRemaining } from '../auth.js';
5
- import { getConfig } from '../config.js';
5
+ import { getConfig, liveUrl } from '../config.js';
6
6
  import { brand, success, warning, muted, error as clrError } from '../colors.js';
7
7
  import { HOOKS_SETTINGS, setupClaudeHooks } from '../setup.js';
8
8
  /** Inspect `.claude/settings.json` against the current `HOOKS_SETTINGS`.
@@ -50,6 +50,7 @@ export const statusCommand = new Command('status')
50
50
  slug: config.projectSlug,
51
51
  account: config.accountSlug,
52
52
  apiBase: config.apiBase,
53
+ url: liveUrl(config),
53
54
  } : null,
54
55
  auth: auth ? {
55
56
  email: auth.email,
@@ -66,6 +67,7 @@ export const statusCommand = new Command('status')
66
67
  else {
67
68
  console.log(`${muted('Project:')} ${brand(config.projectSlug)} ${muted(`(${config.projectGuid})`)}`);
68
69
  console.log(`${muted('Account:')} ${config.accountSlug}`);
70
+ console.log(`${muted('Live:')} ${liveUrl(config)}`);
69
71
  console.log(`${muted('API:')} ${config.apiBase}`);
70
72
  if (config.agentGuid)
71
73
  console.log(`${muted('Agent:')} ${config.agentGuid}`);
package/dist/config.js CHANGED
@@ -127,6 +127,17 @@ export async function resolveProjectContext(opts) {
127
127
  oneOff: true,
128
128
  };
129
129
  }
130
+ /** The canonical live URL for a deployed project. This is THE place the
131
+ * dev/prod URL convention lives - every command that tells the user (or an
132
+ * agent) where their app is (`deploy`, `status`, `project info`) derives it
133
+ * here, so nothing ever has to reconstruct `dev.gipity.ai/<account>/<slug>/`
134
+ * by hand or guess a subdomain like `<slug>.gipity.app` (which doesn't
135
+ * resolve). Mirrors the server's deploy URL; `deploy` itself prints the
136
+ * server-authoritative URL, the read-only commands derive it from config. */
137
+ export function liveUrl(config, target = 'dev') {
138
+ const host = target === 'prod' ? 'app.gipity.ai' : 'dev.gipity.ai';
139
+ return `https://${host}/${config.accountSlug}/${config.projectSlug}/`;
140
+ }
130
141
  export function clearConfigCache() {
131
142
  cached = null;
132
143
  cachedPath = null;
@@ -62,9 +62,17 @@ function collectRealFlags(argv, program) {
62
62
  const args = argv.slice(2);
63
63
  let cmd = program;
64
64
  const chain = [program];
65
- for (const tok of args) {
66
- if (tok.startsWith('-'))
67
- break; // first flag ends command resolution
65
+ // Root value-options (e.g. `--api-base <url>`) may legitimately precede the
66
+ // subcommand; skip them (and their value token) rather than ending resolution
67
+ // there, so a command's own flag is still discovered when a global flag leads.
68
+ const rootValueFlags = new Set(program.options.filter(o => o.long && o.required).map(o => o.long));
69
+ for (let i = 0; i < args.length; i++) {
70
+ const tok = args[i];
71
+ if (tok.startsWith('-')) {
72
+ if (!tok.includes('=') && rootValueFlags.has(tok))
73
+ i++; // consume its value
74
+ continue;
75
+ }
68
76
  const next = cmd.commands.find(c => c.name() === tok || c.aliases().includes(tok));
69
77
  if (!next)
70
78
  break;
package/dist/knowledge.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * AUTO-GENERATED - do not edit directly.
5
5
  * Source: platform/docs/knowledge/*.md + docs/skills/*.md frontmatter + gipity-overview.ts
6
- * Run `just sync-knowledge` to refresh.
6
+ * Run `just build-knowledge` to refresh.
7
7
  */
8
8
  export const BUILD_VS_NON_BUILD_RULE = `## When to add a template
9
9
  If the user wants a deployable app (web, game, API): run \`gipity add <template>\` before writing any files. A template wires up \`gipity.yaml\`, deploy config, and sync; hand-written files miss all of it.
@@ -63,9 +63,9 @@ When a user asks for a foreign stack ("build it in React", "use MS SQL Server",
63
63
 
64
64
  The one exception is app-level libraries the user imports into their own \`src/\` code - Three.js, Rapier, Phaser, MediaPipe, a charting or animation library. Those are fine. The opinionation is about the *platform* layer (framework, backend, database, styling system, hosting, auth, services), not every npm package.
65
65
 
66
- ## When to add a template
66
+ ## Build loop
67
67
 
68
- The full rule and definition of done are injected at the top of every session context. In short: if the user asks you to build something deployable (web app, game, API), run \`gipity add <template>\` first (default \`web-simple\`); if it's a one-off task (analysis, PDFs, data work), use \`gipity sandbox run\` instead. To add a reusable building block to an existing app (e.g. multiplayer), \`gipity add <kit>\`.
68
+ The full "when to add a template" rule and the definition of done are spelled out in the two sections at the end of this document. In short: if the user wants something deployable (web app, game, API), \`gipity add <template>\` first (default \`web-simple\`); for a one-off task (analysis, PDFs, data work), use \`gipity sandbox run\` instead; to add a reusable building block to an existing app (e.g. multiplayer), \`gipity add <kit>\`.
69
69
 
70
70
  Build loop: \`gipity add\` → edit files → \`gipity deploy dev\` → \`gipity page inspect <url>\` → fix any errors → repeat until the definition of done is met.
71
71
 
@@ -75,7 +75,7 @@ Before telling the user the app is online, verify the source tree is consistent:
75
75
 
76
76
  ## CLI quick reference
77
77
 
78
- Key commands: \`gipity add <template|kit>\`, \`gipity deploy dev\`, \`gipity sandbox run\`, \`gipity page inspect <url>\`, \`gipity db query "SQL"\`, \`gipity fn call <name>\`, \`gipity logs fn <name>\`, \`gipity skill read <name>\`.
78
+ Key commands: \`gipity add <template|kit>\`, \`gipity deploy dev\`, \`gipity sandbox run\`, \`gipity page inspect <url>\`, \`gipity page screenshot <url>\`, \`gipity db query "SQL"\`, \`gipity fn call <name>\`, \`gipity logs fn <name>\`, \`gipity skill read <name>\`.
79
79
  Run \`gipity --help\` for the full list. Use \`--help\` on any command for details.
80
80
 
81
81
  ## Tool output is complete and synchronous
package/dist/progress.js CHANGED
@@ -19,11 +19,26 @@ class TerminalProgress {
19
19
  /** True while an in-place transfer line is on screen and not yet committed. */
20
20
  liveOpen = false;
21
21
  lastRenderAt = 0;
22
+ /** The label of the current transfer session; a change starts a fresh one. */
23
+ barLabel = null;
24
+ /** True once the current session hit 100% - late/overshoot ticks are dropped. */
25
+ barSettled = false;
22
26
  phase(message) {
23
27
  this.commitLive();
24
28
  process.stdout.write(` ${muted(message)}\n`);
25
29
  }
26
30
  transfer(label, doneBytes, totalBytes) {
31
+ // A new label begins a fresh transfer session (e.g. downloads → uploads on
32
+ // the same reporter). Within a session, once we've drawn the 100% frame we
33
+ // drop any further ticks - download byte totals are estimated, so the wire
34
+ // can deliver a hair more or fewer bytes than expected and we don't want a
35
+ // late chunk reopening a second "100%" line.
36
+ if (label !== this.barLabel) {
37
+ this.barLabel = label;
38
+ this.barSettled = false;
39
+ }
40
+ if (this.barSettled)
41
+ return;
27
42
  const finished = totalBytes > 0 && doneBytes >= totalBytes;
28
43
  // Throttle mid-flight redraws; always paint the first and final frames.
29
44
  const now = Date.now();
@@ -32,8 +47,10 @@ class TerminalProgress {
32
47
  this.lastRenderAt = now;
33
48
  this.liveOpen = true;
34
49
  process.stdout.write('\r' + this.frame(label, doneBytes, totalBytes) + CLEAR_TO_EOL);
35
- if (finished)
50
+ if (finished) {
36
51
  this.commitLive();
52
+ this.barSettled = true;
53
+ }
37
54
  }
38
55
  finish() {
39
56
  this.commitLive();
package/dist/prompts.js CHANGED
@@ -105,14 +105,6 @@ export function buildProjectContextBlock(opts) {
105
105
  DEFINITION_OF_DONE,
106
106
  ].join('\n').replace(/\n{3,}/g, '\n\n');
107
107
  }
108
- /** Project-context block + a brief greeting instruction. */
109
- export function buildExistingProjectPrompt(opts) {
110
- const isEmpty = opts.fileCount === 0;
111
- const greeting = isEmpty
112
- ? `Briefly greet the user and ask what they want to build.`
113
- : `Briefly greet the user, summarize what this project appears to be (based on the file listing and any README/CLAUDE.md/gipity.yaml), and ask what they want to work on next.`;
114
- return [buildProjectContextBlock(opts), ``, greeting].join('\n');
115
- }
116
108
  /** First-launch prompt for a brand-new (empty) project. Reuses buildProjectContextBlock. */
117
109
  export function buildNewProjectPrompt(opts) {
118
110
  const base = buildProjectContextBlock(opts);
package/dist/setup.js CHANGED
@@ -5,7 +5,7 @@ import { resolve, join, dirname } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
7
7
  import { SCAFFOLD_HOOK_WARNING } from './prompts.js';
8
- import { SKILLS_CONTENT } from './knowledge.js';
8
+ import { SKILLS_CONTENT, BUILD_VS_NON_BUILD_RULE, DEFINITION_OF_DONE } from './knowledge.js';
9
9
  import { getConfig } from './config.js';
10
10
  export { SKILLS_CONTENT };
11
11
  /** Canonical list of workstation artifacts that are NOT part of the project.
@@ -218,9 +218,20 @@ export function setupClaudeHooks() {
218
218
  * are stable - changing them would orphan the blocks in existing files. */
219
219
  export const GIPITY_BLOCK_BEGIN = '<!-- BEGIN GIPITY INTEGRATION - auto-generated by gipity, do not edit this block -->';
220
220
  export const GIPITY_BLOCK_END = '<!-- END GIPITY INTEGRATION -->';
221
- /** The Gipity-owned section: current SKILLS_CONTENT wrapped in the markers. */
221
+ /** The Gipity-owned section, marker-wrapped: the integration guide + the full
222
+ * scaffold rule + the definition of done. The rule and DoD used to be injected
223
+ * only into the interactive `gipity claude` seed; folding the *static* parts
224
+ * into the primer means every agent (Claude, Codex, Gemini, ...) gets them, and
225
+ * the seed no longer has to carry that context.
226
+ *
227
+ * Per-project values (GUID, live URL) deliberately do NOT live here - baking
228
+ * them into a generated doc is the wrong layer. The CLI surfaces them where the
229
+ * agent actually looks: `gipity deploy` prints the live URL, `gipity status` and
230
+ * `gipity project info` show the URL + GUID. That keeps them authoritative and
231
+ * avoids stale values frozen into a file. */
222
232
  function renderManagedBlock() {
223
- return `${GIPITY_BLOCK_BEGIN}\n${SKILLS_CONTENT}\n${GIPITY_BLOCK_END}`;
233
+ const body = [SKILLS_CONTENT, BUILD_VS_NON_BUILD_RULE, DEFINITION_OF_DONE].join('\n\n');
234
+ return `${GIPITY_BLOCK_BEGIN}\n${body}\n${GIPITY_BLOCK_END}`;
224
235
  }
225
236
  /**
226
237
  * Pure core of `writeSkillsFile`: given a file's current content (or `null`
package/dist/sync.js CHANGED
@@ -195,14 +195,14 @@ async function fetchRemote(projectGuid) {
195
195
  }
196
196
  return out;
197
197
  }
198
- async function downloadAll(projectGuid) {
198
+ async function downloadAll(projectGuid, onBytes) {
199
199
  const stream = await downloadStream(`/projects/${projectGuid}/files/tree?content=tar`);
200
200
  const extract = tar.extract();
201
201
  const files = new Map();
202
202
  return new Promise((resolve, reject) => {
203
203
  extract.on('entry', (header, entryStream, next) => {
204
204
  const chunks = [];
205
- entryStream.on('data', (c) => chunks.push(c));
205
+ entryStream.on('data', (c) => { chunks.push(c); onBytes?.(c.length); });
206
206
  entryStream.on('end', () => { files.set(header.name, Buffer.concat(chunks)); next(); });
207
207
  entryStream.resume();
208
208
  });
@@ -519,9 +519,23 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
519
519
  const downloadedBytes = new Map();
520
520
  const needsBulkDownload = plannedToApply.some(a => a.kind === 'download' || a.kind === 'conflict');
521
521
  if (needsBulkDownload) {
522
- p?.phase('Downloading updates from Gipity…');
522
+ // The tree endpoint streams the *whole* remote tree as one tar (the caller
523
+ // then picks out only the paths it planned to apply), so the bytes that
524
+ // actually move = the sum of every remote file's size. That's the honest
525
+ // denominator for the bar - it tracks real wire progress, not just the
526
+ // handful of changed files.
527
+ const downloadLabel = 'Downloading updates from Gipity';
528
+ const totalDownloadBytes = [...remote.values()].reduce((sum, r) => sum + r.size, 0);
529
+ let recvBytes = 0;
530
+ p?.transfer(downloadLabel, 0, totalDownloadBytes);
531
+ const onBytes = p
532
+ ? (delta) => {
533
+ recvBytes = Math.min(recvBytes + delta, totalDownloadBytes);
534
+ p.transfer(downloadLabel, recvBytes, totalDownloadBytes);
535
+ }
536
+ : undefined;
523
537
  try {
524
- const all = await downloadAll(config.projectGuid);
538
+ const all = await downloadAll(config.projectGuid, onBytes);
525
539
  for (const a of plannedToApply) {
526
540
  if (a.kind === 'download' || a.kind === 'conflict') {
527
541
  const buf = all.get(a.path);
@@ -533,6 +547,11 @@ async function syncInner(projectGuid, root, ignore, opts, interactive) {
533
547
  catch (err) {
534
548
  errors.push(`Download batch failed: ${err.message}`);
535
549
  }
550
+ finally {
551
+ // Settle the bar even if the extracted-byte tally fell short of the
552
+ // estimate (the live line stays open until something hits 100% or finish()).
553
+ p?.finish();
554
+ }
536
555
  }
537
556
  // ── Writes pass: uploads, downloads, conflicts (rename + download + upload copy) ──
538
557
  // We serialize conflicts; uploads run with bounded concurrency.
@@ -68,7 +68,10 @@ export function buildTemplateVars(v) {
68
68
  '{{DESCRIPTION_META}}': v.description ? `\n <meta name="description" content="${safeDesc}">` : '',
69
69
  '{{OG_DESCRIPTION}}': v.description ? `\n <meta property="og:description" content="${safeDesc}">` : '',
70
70
  '{{JSON_LD_BLOCK}}': `<script type="application/ld+json">\n${jsonLd}\n </script>`,
71
- '{{ANALYTICS_SCRIPT}}': `<script defer src="https://media.gipity.ai/client/v1/gipity.js" data-app="${v.projectGuid}"></script>`,
71
+ // `crossorigin="anonymous"` so SDK errors surface with a real message/stack
72
+ // (CORS mode) instead of a sanitized message-less "Script error". The CDN
73
+ // returns Access-Control-Allow-Origin:*, so it works on any app domain.
74
+ '{{ANALYTICS_SCRIPT}}': `<script defer crossorigin="anonymous" src="https://media.gipity.ai/client/v1/gipity.js" data-app="${v.projectGuid}"></script>`,
72
75
  };
73
76
  }
74
77
  /** Pure string substitution — exported so the test can exercise it without
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gipity",
3
- "version": "1.0.374",
3
+ "version": "1.0.380",
4
4
  "description": "The full-stack platform tuned for AI agents. Database, storage, auth, functions, deploy, and drop-in kits - all agent-tuned. Pair with Claude Code or use standalone.",
5
5
  "bin": {
6
6
  "gipity": "dist/updater/shim.js",