gipity 1.0.429 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,9 +50,15 @@ export const chatCommand = new Command('chat')
50
50
  ...(a.remoteSize != null ? { size: a.remoteSize } : {}),
51
51
  }));
52
52
  }
53
+ // The server's cost-consent gate stops the agentic loop on big-build
54
+ // requests: content comes back empty with an explicit costWarning.
55
+ // Surface it - before this the CLI printed a blank line and the request
56
+ // silently did nothing. Re-running the command consents via chat reply.
57
+ const displayContent = res.data.content || res.data.costWarning?.message || '';
53
58
  if (opts.json) {
54
59
  console.log(JSON.stringify({
55
- content: res.data.content,
60
+ content: displayContent,
61
+ costWarning: res.data.costWarning ?? null,
56
62
  toolsUsed: res.data.toolsUsed?.map(t => ({
57
63
  tool: t.toolName,
58
64
  success: t.success,
@@ -67,8 +73,8 @@ export const chatCommand = new Command('chat')
67
73
  }));
68
74
  }
69
75
  else {
70
- // Show agent response
71
- console.log(res.data.content);
76
+ // Show agent response (or the cost warning when the gate stopped the loop)
77
+ console.log(displayContent);
72
78
  // Show tools used
73
79
  if (res.data.toolsUsed && res.data.toolsUsed.length > 0) {
74
80
  const toolNames = [...new Set(res.data.toolsUsed.map(t => t.toolName))];
@@ -9,7 +9,7 @@ import { get, post, ApiError, getAccountSlug } from '../api.js';
9
9
  import { interactiveLogin } from '../login-flow.js';
10
10
  import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, DEFAULT_API_BASE, getConfigPath } from '../config.js';
11
11
  import { sync } from '../sync.js';
12
- import { slugify, setupClaudeHooks, ensureGipityPluginInstalled, setupClaudeMd, setupAgentsMd, setupGitignore, DEFAULT_SYNC_IGNORE, isSyncIgnored } from '../setup.js';
12
+ import { slugify, ensureGipityPluginInstalled, setupProjectTools, DEFAULT_SYNC_IGNORE, isSyncIgnored } from '../setup.js';
13
13
  import { buildProjectContextBlock as buildProjectContextBlockText, buildNewProjectPrompt, buildResumeWrap, buildFreshWrap, } from '../prompts.js';
14
14
  import * as relayState from '../relay/state.js';
15
15
  import { maybeOfferRelayOn, ensureDaemonRunning } from '../relay/onboarding.js';
@@ -481,10 +481,7 @@ export const claudeCommand = new Command('claude')
481
481
  catch (err) {
482
482
  console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
483
483
  }
484
- setupClaudeHooks();
485
- setupClaudeMd();
486
- setupAgentsMd();
487
- setupGitignore();
484
+ setupProjectTools();
488
485
  if (nonInteractive) {
489
486
  // Headless: the -p message is wrapped later (Step 3). Just record
490
487
  // whether to use the new-project framing for that wrap.
@@ -503,10 +500,7 @@ export const claudeCommand = new Command('claude')
503
500
  // cwd already has (or inherits) a .gipity.json - run inside it.
504
501
  console.log(` Project: ${brand(existing.projectSlug)} ${muted(`(${existing.projectGuid})`)}`);
505
502
  console.log(` ${success('Already set up.')}\n`);
506
- setupClaudeHooks();
507
- setupClaudeMd();
508
- setupAgentsMd();
509
- setupGitignore();
503
+ setupProjectTools();
510
504
  // Warn before syncing a very large project tree. An oversized root
511
505
  // (or a config accidentally rooted high up, e.g. at $HOME) makes the
512
506
  // sync slow, and a silent multi-GB walk reads as a hang.
@@ -658,10 +652,7 @@ export const claudeCommand = new Command('claude')
658
652
  // of done, and a welcome banner prints before launch - the user tells
659
653
  // Claude directly what they want to build or do.
660
654
  initialPrompt = '';
661
- setupClaudeHooks();
662
- setupClaudeMd();
663
- setupAgentsMd();
664
- setupGitignore();
655
+ setupProjectTools();
665
656
  console.log(` ${success(`Project "${project.name}" ready.`)}\n`);
666
657
  }
667
658
  // ── Step 3: Launch Claude Code ────────────────────────────────────
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { get, post, sendMessage } from '../api.js';
2
+ import { get, post } from '../api.js';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { error as clrError, success } from '../colors.js';
5
5
  import { run, printList, emitField } from '../helpers/index.js';
@@ -106,13 +106,20 @@ dbCommand
106
106
  .description('Create a database')
107
107
  .option('--json', 'Output as JSON')
108
108
  .action((name, opts) => run('Create', async () => {
109
- // DDL delegates to agent (needs confirmation flow)
110
- const response = await sendMessage(`Create a new database called "${name}" for this project. Confirm when done, no explanation.`);
109
+ // Direct REST create - same endpoint `db drop` uses. This used to delegate
110
+ // to agent chat, which burned LLM tokens and, after the cost-consent gate
111
+ // landed server-side, could return an empty response having created
112
+ // nothing. Creating a database isn't destructive, so no confirmation.
113
+ const config = requireConfig();
114
+ await post(`/projects/${config.projectGuid}/db/manage`, {
115
+ action: 'create',
116
+ name,
117
+ });
111
118
  if (opts.json) {
112
- console.log(JSON.stringify({ response }));
119
+ console.log(JSON.stringify({ success: true, name }));
113
120
  }
114
121
  else {
115
- console.log(response);
122
+ console.log(success(`Created database '${name}'.`));
116
123
  }
117
124
  }));
118
125
  dbCommand
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { existsSync, readFileSync, statSync } from 'fs';
3
- import { join } from 'path';
3
+ import { join, resolve } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { LOCAL_PKG_DIR, LOCAL_ENTRY, STATE_FILE, SETTINGS_FILE, UPDATE_LOG, readState, readSettings, updatesDisabled } from '../updater/state.js';
6
6
  import { bold, dim, success, warning, error as clrError, muted } from '../colors.js';
@@ -162,7 +162,13 @@ export const doctorCommand = new Command('doctor')
162
162
  console.log(`${muted('claude code ')} installed ${yn(env.claude.installed)} · authenticated ${yn(env.claude.authenticated)}`);
163
163
  const autostartLabel = env.relay.autostart === null ? muted('n/a') : yn(env.relay.autostart);
164
164
  console.log(`${muted('relay ')} paired ${yn(env.relay.paired)} · running ${yn(env.relay.running)} · autostart ${autostartLabel}${env.relay.paused ? warning(' · paused') : ''}${env.relay.device ? muted(` (${env.relay.device.name})`) : ''}`);
165
- console.log(`${muted('ready ')} ${env.ready ? success('yes') : warning('no - run `gipity claude` (or the desktop app) to finish setup')}`);
165
+ // Codex approves project hooks interactively and stores the decision in
166
+ // its own opaque state - we can't verify it from here, so surface the
167
+ // one manual step whenever this project ships Codex hooks.
168
+ if (existsSync(resolve(process.cwd(), '.codex', 'hooks.json'))) {
169
+ console.log(`${muted('codex hooks ')} ${warning('written')} - session capture needs a one-time approval: run /hooks inside Codex in this project`);
170
+ }
171
+ console.log(`${muted('ready ')} ${env.ready ? success('yes') : warning('no - run `gipity build` (or the desktop app) to finish setup')}`);
166
172
  // ── CLI install / update health ────────────────────────────────────
167
173
  const state = readState();
168
174
  const settings = readSettings();
@@ -4,7 +4,7 @@ import { existsSync, readFileSync } from 'fs';
4
4
  import { getAccountSlug } from '../api.js';
5
5
  import { getConfig, getConfigPath, saveConfigAt } from '../config.js';
6
6
  import { getAuth } from '../auth.js';
7
- import { slugify, setupClaudeHooks, setupGitignore, SUPPORTED_TOOLS, DEFAULT_TOOLS, DEFAULT_SYNC_IGNORE } from '../setup.js';
7
+ import { slugify, setupProjectTools, SUPPORTED_TOOLS, DEFAULT_TOOLS, DEFAULT_SYNC_IGNORE } from '../setup.js';
8
8
  import { success, error as clrError, info, muted, bold } from '../colors.js';
9
9
  import { confirm } from '../utils.js';
10
10
  import { scanForAdoption, adoptCurrentDir, canAdoptCwd, formatBytes, formatCwdLabel, ADOPT_THRESHOLDS, } from '../adopt-cwd.js';
@@ -23,7 +23,7 @@ function resolveTools(forFlag) {
23
23
  }
24
24
  export const initCommand = new Command('init')
25
25
  .description('Link this directory to a project')
26
- .addHelpText('after', '\nWrites CLAUDE.md/AGENTS.md primer files so your AI coding tool understands Gipity.')
26
+ .addHelpText('after', '\nWrites CLAUDE.md/AGENTS.md primer files so your AI coding tool understands Gipity, and installs the Gipity skills + file-sync hooks into the agent CLIs found on this machine (Claude Code, Codex, Grok).')
27
27
  .argument('[name]', 'Project name/slug (defaults to current directory name)')
28
28
  .option('--agent <guid>', 'Agent GUID to use')
29
29
  .option('--no-capture', 'Don\'t record Claude Code sessions in this directory to your Gipity project (sets captureHooks: false in .gipity.json)')
@@ -32,7 +32,8 @@ export const initCommand = new Command('init')
32
32
  Examples:
33
33
  $ gipity init Link cwd as a new project (slug = dir name).
34
34
  $ gipity init my-app Link cwd with an explicit slug.
35
- $ gipity init --for codex Write only AGENTS.md (skip Claude/Cursor/etc).
35
+ $ gipity init --for codex AGENTS.md + Codex skills/sync hooks only.
36
+ $ gipity init --for grok AGENTS.md + the Gipity plugin in Grok only.
36
37
  $ gipity init --for cursor,gemini Write only the Cursor + Gemini primers.
37
38
  $ gipity init --for aider AGENTS.md + a read: entry in .aider.conf.yml
38
39
  (aider auto-reads nothing, so it's opt-in).
@@ -57,10 +58,6 @@ Working with an existing Gipity project:
57
58
  process.exit(1);
58
59
  }
59
60
  const wantsClaude = tools.some(t => t.key === 'claude');
60
- const writeAllPrimers = () => {
61
- for (const t of tools)
62
- t.setup();
63
- };
64
61
  const primerSummary = tools.map(t => t.label).join(', ');
65
62
  try {
66
63
  // Check auth
@@ -77,12 +74,9 @@ Working with an existing Gipity project:
77
74
  if (existsSync(resolve(cwd, '.gipity.json'))) {
78
75
  const existing = getConfig();
79
76
  console.log(`Already linked to ${info(`"${existing?.projectSlug ?? ''}"`)} ${muted(`(${existing?.projectGuid ?? ''})`)}`);
80
- // Re-run setup in case hooks/skills are missing. Claude Code hooks
81
- // only matter when the Claude primer is being written.
82
- if (wantsClaude)
83
- setupClaudeHooks();
84
- writeAllPrimers();
85
- setupGitignore();
77
+ // Re-run setup in case primers/hooks/skills are missing - each tool's
78
+ // integration self-gates on its binary and current-version state.
79
+ setupProjectTools(tools);
86
80
  // The config's ignore list was frozen at link time, so a workstation
87
81
  // artifact introduced by a newer CLI (e.g. aider's .aider.conf.yml)
88
82
  // would sync up as project content. Union in the current defaults.
@@ -143,7 +137,7 @@ Working with an existing Gipity project:
143
137
  const sizeStr = scan.truncated ? `>${formatBytes(ADOPT_THRESHOLDS.REFUSE_BYTES)}` : formatBytes(scan.bytes);
144
138
  const fileStr = scan.truncated ? `>${ADOPT_THRESHOLDS.REFUSE_FILES}` : `${scan.files}`;
145
139
  console.error(clrError(`Directory has ${fileStr} files (${sizeStr}) - too large to adopt as a Gipity project.`));
146
- console.error(muted('Move into a subdirectory, or use `gipity claude` and pick "Create new project".'));
140
+ console.error(muted('Move into a subdirectory, or use `gipity build` and pick "Create new project".'));
147
141
  process.exit(1);
148
142
  }
149
143
  if (scan.tier === 'moderate') {
@@ -183,7 +177,7 @@ Working with an existing Gipity project:
183
177
  }
184
178
  console.log(success(`Wrote primer files: ${primerSummary}.`));
185
179
  if (wantsClaude) {
186
- console.log(success('Ready! Run `gipity claude` for Claude Code, or open this directory in your other AI coding tool.'));
180
+ console.log(success('Ready! Run your coding agent here (claude, codex, grok), or `gipity build` to launch one with a picker.'));
187
181
  // Recording happens by default (however Claude Code is launched), so
188
182
  // say so up front - consent should be explicit, not discovered later.
189
183
  if (opts.capture === false) {
@@ -8,8 +8,10 @@ import { resolveProjectContext } from '../config.js';
8
8
  import { uploadPublicFixture, uploadCameraFeed, assertCameraFile, deleteFixture } from '../page-fixtures.js';
9
9
  // Shown when an eval runs cleanly but returns nothing serializable. Turns a
10
10
  // bare/opaque `null` into a deterministic, actionable nudge so the agent shapes
11
- // a returnable value instead of guessing and retrying.
12
- export const EVAL_NO_VALUE_HINT = 'The eval ran but returned no JSON-serializable value. A statement body with no `return`, an assignment, a void call, or a DOM node/function all serialize to null. ' +
11
+ // a returnable value instead of guessing and retrying. (A body ending in a bare
12
+ // expression is auto-returned SERVER-side before this can fire so this is the
13
+ // residue: block/loop endings, explicit undefined returns, DOM nodes, functions.)
14
+ export const EVAL_NO_VALUE_HINT = 'The eval ran but returned no JSON-serializable value. A body ending in a loop/if-block, a `return` of undefined, or a DOM node/function all serialize to null. ' +
13
15
  'End the script with an expression — or an explicit `return` — that yields plain data, e.g. `return { label: input.value, count: items.length }` or `return JSON.stringify(payload)`.';
14
16
  /** Normalize a raw eval result for display. The eval can come back as a useful
15
17
  * serialized value, the literal `null`/`undefined`/empty string, or — when the
@@ -346,7 +348,9 @@ export function evalExecTimeoutMessage(result, budgetMs) {
346
348
  // a trap, since --json is a real flag that changes output but still leaves the
347
349
  // script unset, sending the agent in a loop. Capture the common guesses as
348
350
  // hidden decoy options so the action can redirect to the positional arg exactly.
349
- const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr', '--eval', '--exec'];
351
+ // `--action` rides along: it's the sibling `page screenshot`'s spelling of
352
+ // "run JS on the page", so an agent that learned it there guesses it here.
353
+ const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr', '--eval', '--exec', '--action'];
350
354
  // The long-tail escape hatch alongside `page inspect`'s fixed bundle: when the
351
355
  // curated metrics don't cover what you need (computed styles, element rects,
352
356
  // visibility, z-index stacks), eval an expression in page context and get the
@@ -360,7 +364,7 @@ const JS_DECOY_FLAGS = ['--js', '--javascript', '--script', '--code', '--expr',
360
364
  export const pageEvalCommand = new Command('eval')
361
365
  .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')
362
366
  .argument('<url>', 'URL to load')
363
- .argument('[expr]', `JavaScript to evaluate in page context (inline expression or statement body with return/await; 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.`)
367
+ .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.`)
364
368
  .option('--file <path>', `Read the script body from a file instead of the inline <expr> arg (mutually exclusive). Runs as an async function body, so top-level return/await work. Same post-load budget as <expr> (--timeout).`)
365
369
  .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], [])
366
370
  .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. Repeat for several files (single-value so it never swallows the inline <expr>).', (val, prev) => [...prev, val], [])
@@ -372,7 +376,7 @@ export const pageEvalCommand = new Command('eval')
372
376
  .option('--wait-for <selector>', `Wait until this CSS selector appears before evaluating, then evaluate (deterministic - beats guessing a --wait). Gate on the state you are ASSERTING, not just a ready flag: '#verdict:not(:empty)' returns the instant the round lands, where a fixed wait snapshots mid-sequence. Same flag on page inspect/screenshot. Max ${WAIT_FOR_MAX_MS}ms (--wait-timeout).`)
373
377
  .option('--wait-timeout <ms>', `Max ms to wait for --wait-for before giving up (max ${WAIT_FOR_MAX_MS})`, '5000')
374
378
  .option('--timeout <ms>', `How long the script itself may run IN the page - its own await/setTimeout pauses count (default ${EVAL_SCRIPT_BUDGET_MS}, ${EVAL_SCRIPT_BUDGET_CAMERA_MS} with --camera/--fake-media; max ${EVAL_SCRIPT_BUDGET_MAX_MS}). Raise it to trace a sequence that unfolds over time (a game round, an animation). Distinct from --wait, which only sleeps BEFORE the script.`)
375
- .option('--auth', 'Evaluate signed in as you (your Gipity account), so a page behind a Sign-in-with-Gipity login is reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
379
+ .option('--auth', 'Evaluate signed in as you (your Gipity account), so a page behind a Sign-in-with-Gipity login is reachable. 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.')
376
380
  .option('--json', 'Output as JSON')
377
381
  .action((url, exprArg, opts) => run('Page eval', async () => {
378
382
  // A JS-intent flag guess (captured as a hidden decoy below): redirect to the
@@ -470,6 +474,9 @@ export const pageEvalCommand = new Command('eval')
470
474
  // cleaned up, without shifting the fixture map the caller indexes into.
471
475
  let camera;
472
476
  let projectGuid;
477
+ // Completion-value semantics (a body ending in a bare expression returns it,
478
+ // REPL-style) are applied SERVER-side to every leg — one source of truth for
479
+ // every caller; the CLI sends the script as written.
473
480
  let sentExpr = expr;
474
481
  let sentSteps = steps;
475
482
  // Validate the camera file locally first: a wrong file type should cost one
@@ -574,14 +581,18 @@ export const pageEvalCommand = new Command('eval')
574
581
  if (d.slowRender) {
575
582
  console.log(slowRenderMessage(d.slowRender.fps, { camera: !!camera, waitMs }));
576
583
  }
577
- // Auth state: without this line an agent can't distinguish "signed-in
578
- // eval" from "--auth silently no-op'd against the anonymous page".
584
+ // Identity line on EVERY run: without it an agent can't distinguish
585
+ // "signed-in eval" from "--auth silently no-op'd against the anonymous
586
+ // page" — or, worse, assume an unflagged run carried a signed-in session.
579
587
  if (d.auth?.requested) {
580
588
  const who = getAuth()?.email;
581
589
  console.log(d.auth.established
582
590
  ? `${muted('Auth:')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
583
591
  : `${warning('Auth: session NOT established')}${d.auth.detail ? ` — ${d.auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
584
592
  }
593
+ else {
594
+ console.log(muted('Auth: anonymous visitor (signed out; pass --auth to run as your Gipity account)'));
595
+ }
585
596
  if (camera)
586
597
  console.log(`${muted('Camera:')} ${camera.name} ${muted('(played as the webcam feed; getUserMedia resolves)')}`);
587
598
  if (hosted.length)
@@ -43,7 +43,7 @@ export const pageInspectCommand = new Command('inspect')
43
43
  .option('--all', 'Include render-blocking, large resources, oversized images, overflow culprits, and LCP detail')
44
44
  .option('--no-fake-media', "Inspect with NO camera or microphone present, so a getUserMedia app takes its no-device error path (use this only when that fallback IS what you're inspecting)")
45
45
  .option('--device <name>', `Inspect as a real touch device: ${INSPECT_DEVICES.join(', ')}. Emulates touch events, mobile user-agent and DPR, so a touch-gated mobile layout is what gets inspected.`)
46
- .option('--auth', 'Load the page signed in as you (your Gipity account), so pages behind a Sign-in-with-Gipity login are reachable. Only works for apps using Sign in with Gipity, hosted on *.gipity.ai.')
46
+ .option('--auth', 'Load the page signed in as you (your Gipity account), so pages behind a Sign-in-with-Gipity login are reachable. 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.')
47
47
  // Hidden redirect: agents reach for `page inspect --screenshot`. We don't take
48
48
  // an image here (`page screenshot` is the single path for that) — just point there.
49
49
  .addOption(new Option('--screenshot [path]', 'Capture a screenshot').hideHelp())
@@ -164,14 +164,18 @@ export async function inspectPage(url, opts = {}) {
164
164
  if (b.navigationIncomplete) {
165
165
  console.log(`${warning('⚠ Navigation incomplete:')} ${b.note || 'page did not reach full load'}`);
166
166
  }
167
- // Auth state: without this line an agent can't distinguish "signed-in render"
168
- // from "--auth silently no-op'd and I'm looking at the anonymous page".
167
+ // Identity line on EVERY run: without it an agent can't distinguish
168
+ // "signed-in render" from "--auth silently no-op'd and I'm looking at the
169
+ // anonymous page" — or assume an unflagged run carried a signed-in session.
169
170
  if (b.auth?.requested) {
170
171
  const who = getAuth()?.email;
171
172
  console.log(b.auth.established
172
173
  ? `${muted('Auth:')} ${success('session established')}${who ? muted(` as ${who}`) : ''} ${muted('(what the page renders with it is app-defined)')}`
173
174
  : `${warning('Auth: session NOT established')}${b.auth.detail ? ` — ${b.auth.detail}` : ''} ${muted('(this is the anonymous view)')}`);
174
175
  }
176
+ else {
177
+ console.log(muted('Auth: anonymous visitor (signed out; pass --auth to load as your Gipity account)'));
178
+ }
175
179
  console.log(`${muted('Title:')} ${b.title || '(none)'}`);
176
180
  console.log(`${muted('Elements:')} ${b.elementCount || 0}`);
177
181
  console.log(`${muted('Page weight:')} ${info(formatSize(b.totalBytes || 0))}`);
@@ -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;
@@ -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;