gipity 1.1.5 → 1.1.6

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.
@@ -0,0 +1,52 @@
1
+ /** LLM_MODELS canonical id -> agy's own spaced --model display string, for the
2
+ * subset of the Gemini catalog agy actually offers. Anything not listed here
3
+ * passes through translateModel() unchanged - either it's already agy's own
4
+ * format (a human typed it directly), or it's a Gemini id agy doesn't have an
5
+ * equivalent for, in which case agy's own `--model` validation is the right
6
+ * place to reject it, not a guess made here. */
7
+ const MODEL_ID_TO_AGY_DISPLAY = {
8
+ 'gemini-3.5-flash': 'Gemini 3.5 Flash (Medium)',
9
+ 'gemini-3.1-pro-preview': 'Gemini 3.1 Pro (High)',
10
+ };
11
+ function translateModel(model) {
12
+ return MODEL_ID_TO_AGY_DISPLAY[model] ?? model;
13
+ }
14
+ function projectArgs(resume) {
15
+ return resume ? ['--conversation', resume] : ['--new-project'];
16
+ }
17
+ export const agyAdapter = {
18
+ key: 'agy',
19
+ source: 'agy',
20
+ displayName: 'Antigravity',
21
+ providerName: 'Google',
22
+ binary: 'agy',
23
+ buildInteractiveArgs({ resume, model }) {
24
+ const args = [...projectArgs(resume)];
25
+ if (model)
26
+ args.push('--model', translateModel(model));
27
+ return args;
28
+ },
29
+ buildHeadlessArgs({ message, resume, model, bypassApprovals }) {
30
+ const args = ['-p', message, ...projectArgs(resume)];
31
+ if (model)
32
+ args.push('--model', translateModel(model));
33
+ if (bypassApprovals)
34
+ args.push('--dangerously-skip-permissions');
35
+ return args;
36
+ },
37
+ sessionIdFromStreamEvent() {
38
+ // agy has no --json/--output-format flag (confirmed: `agy --help` lists
39
+ // none, and `-p` stdout is plain prose) - there is no stream to read a
40
+ // session id off. Capture relies entirely on hooks (see class comment).
41
+ return null;
42
+ },
43
+ hooksSupportedOnPlatform(platform) {
44
+ return platform !== 'win32'; // agy's hooks.json wiring is POSIX-only, like Codex's
45
+ },
46
+ // No stream-json ingest mapper for agy: hook capture carries the full
47
+ // conversationId on every event (confirmed live, including headless -p),
48
+ // so the daemon doesn't need to parse agy's stdout at all.
49
+ daemonStreamCapture: false,
50
+ installHint: 'see https://antigravity.google (Google account sign-in required)',
51
+ };
52
+ //# sourceMappingURL=agy.js.map
@@ -1,11 +1,13 @@
1
1
  import { claudeCodeAdapter } from './claude-code.js';
2
2
  import { codexAdapter } from './codex.js';
3
3
  import { grokAdapter } from './grok.js';
4
+ import { agyAdapter } from './agy.js';
4
5
  /** Picker/help order: Claude first (the default), then the others. */
5
6
  export const AGENT_ADAPTERS = [
6
7
  claudeCodeAdapter,
7
8
  codexAdapter,
8
9
  grokAdapter,
10
+ agyAdapter,
9
11
  ];
10
12
  export const AGENT_KEYS = AGENT_ADAPTERS.map(a => a.key);
11
13
  export function getAdapter(key) {
package/dist/api.js CHANGED
@@ -366,12 +366,12 @@ export async function getAccountSlug() {
366
366
  /** Unauthenticated request (for login/verify, and anonymous-visitor calls like
367
367
  * `fn call --anon`). extraHeaders lets callers attach non-auth headers such as
368
368
  * X-App-Token; no Authorization header is ever sent. */
369
- export async function publicPost(path, body, extraHeaders) {
369
+ export async function publicRequest(method, path, body, extraHeaders) {
370
370
  const url = `${baseUrl()}${path}`;
371
371
  const res = await fetch(url, {
372
- method: 'POST',
372
+ method,
373
373
  headers: { ...clientHeaders(), 'Content-Type': 'application/json', ...extraHeaders },
374
- body: JSON.stringify(body),
374
+ body: body === undefined ? undefined : JSON.stringify(body),
375
375
  });
376
376
  if (!res.ok) {
377
377
  const json = await res.json().catch(() => ({ error: { code: 'UNKNOWN', message: res.statusText } }));
@@ -380,4 +380,20 @@ export async function publicPost(path, body, extraHeaders) {
380
380
  }
381
381
  return res.json();
382
382
  }
383
+ export function publicPost(path, body, extraHeaders) {
384
+ return publicRequest('POST', path, body, extraHeaders);
385
+ }
386
+ /** Mint the short-lived public app token the browser SDK uses, so a CLI call can
387
+ * exercise the exact path a signed-out visitor hits. Best-effort: fully public
388
+ * surfaces work with no token at all, and an auth-gated one should 401 with the
389
+ * server's real reason rather than a CLI-invented one. */
390
+ export async function mintAppToken(projectGuid) {
391
+ try {
392
+ const minted = await publicPost('/api/token', { app: projectGuid });
393
+ return { 'X-App-Token': minted.data.token };
394
+ }
395
+ catch {
396
+ return undefined;
397
+ }
398
+ }
383
399
  //# sourceMappingURL=api.js.map
@@ -0,0 +1,88 @@
1
+ const USER_REQUEST_RE = /<USER_REQUEST>\s*([\s\S]*?)\s*<\/USER_REQUEST>/;
2
+ function extractUserRequest(content) {
3
+ const m = USER_REQUEST_RE.exec(content);
4
+ return m ? m[1].trim() : content.trim();
5
+ }
6
+ function positional(conversationId, idx) {
7
+ return `${conversationId}#${idx}`;
8
+ }
9
+ function watermarkIndex(afterUuid, conversationId) {
10
+ if (!afterUuid || !conversationId)
11
+ return null;
12
+ const prefix = `${conversationId}#`;
13
+ if (!afterUuid.startsWith(prefix))
14
+ return null;
15
+ const idx = parseInt(afterUuid.slice(prefix.length), 10);
16
+ return Number.isInteger(idx) && idx >= 0 ? idx : null;
17
+ }
18
+ function lineToEntries(parsed, conversationId, idx) {
19
+ const ts = typeof parsed?.created_at === 'string' ? parsed.created_at : undefined;
20
+ const type = parsed?.type;
21
+ const content = parsed?.content;
22
+ const uuid = positional(conversationId, idx);
23
+ if (type === 'USER_INPUT' && typeof content === 'string' && content) {
24
+ const prompt = extractUserRequest(content);
25
+ if (!prompt)
26
+ return [];
27
+ return [{ kind: 'prompt', prompt, source_uuid: uuid, ...(ts ? { ts } : {}) }];
28
+ }
29
+ if (type === 'PLANNER_RESPONSE') {
30
+ if (typeof content !== 'string' || !content)
31
+ return []; // thinking-only turn, nothing user-visible
32
+ return [{
33
+ kind: 'assistant',
34
+ text: content,
35
+ blocks: [{ type: 'text', text: content }],
36
+ source_uuid: uuid,
37
+ ...(ts ? { ts } : {}),
38
+ }];
39
+ }
40
+ if (type === 'CODE_ACTION') {
41
+ if (typeof content !== 'string' || !content)
42
+ return [];
43
+ return [{ kind: 'system', content, source_uuid: uuid, ...(ts ? { ts } : {}) }];
44
+ }
45
+ return []; // CONVERSATION_HISTORY, EPHEMERAL_MESSAGE, CHECKPOINT, SYSTEM_MESSAGE, unknown
46
+ }
47
+ /** Same contract as the other source parsers: entries after `afterUuid`, the
48
+ * new watermark, and whether the old watermark was still valid for this
49
+ * file. `conversationId` comes from the hook payload (capture-runner passes
50
+ * it as `hook.session_id`), not from the file content - agy's transcript
51
+ * lines carry no conversation/session id field of their own. */
52
+ export function parseTranscript(content, afterUuid, opts = {}) {
53
+ const conversationId = opts.conversationId ?? '';
54
+ const lines = content.split('\n');
55
+ // A watermark past EOF means the file was replaced by a shorter one under
56
+ // the same conversation id (mirrors the Codex parser's resume-rotation
57
+ // guard) - treat as not-found so the caller replays from the top instead of
58
+ // wedging forever.
59
+ let startAfter = watermarkIndex(afterUuid, conversationId);
60
+ if (startAfter !== null && startAfter >= lines.length)
61
+ startAfter = null;
62
+ const foundWatermark = afterUuid === null || startAfter !== null;
63
+ const out = [];
64
+ let lastIdx = startAfter;
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const line = lines[i].trim();
67
+ if (!line)
68
+ continue;
69
+ if (startAfter !== null && i <= startAfter)
70
+ continue;
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(line);
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ for (const e of lineToEntries(parsed, conversationId, i))
79
+ out.push(e);
80
+ lastIdx = i;
81
+ }
82
+ return {
83
+ entries: out,
84
+ lastUuid: lastIdx === null ? afterUuid : positional(conversationId, lastIdx),
85
+ foundWatermark,
86
+ };
87
+ }
88
+ //# sourceMappingURL=agy.js.map
@@ -29,6 +29,15 @@ export function detectHarness() {
29
29
  return { harness: 'codex' };
30
30
  if (hasEnvPrefix('GROK_'))
31
31
  return { harness: 'grok', harnessSession: process.env.GROK_SESSION_ID };
32
+ // Antigravity (agy) injects ANTIGRAVITY_CONVERSATION_ID into every hook/tool
33
+ // subprocess it spawns (confirmed live). Check this exact var, not an
34
+ // 'ANTIGRAVITY_' prefix scan - the Antigravity IDE's own integrated terminal
35
+ // ambiently sets other ANTIGRAVITY_*-prefixed vars (e.g. ANTIGRAVITY_CLI_ALIAS)
36
+ // in every shell it spawns, which would false-positive for any user with the
37
+ // IDE open, even outside a real agy session/hook.
38
+ if (process.env.ANTIGRAVITY_CONVERSATION_ID) {
39
+ return { harness: 'agy', harnessSession: process.env.ANTIGRAVITY_CONVERSATION_ID };
40
+ }
32
41
  if (process.env.CURSOR_TRACE_ID || hasEnvPrefix('CURSOR_') || (process.env.TERM_PROGRAM ?? '').toLowerCase().includes('cursor')) {
33
42
  return { harness: 'cursor' };
34
43
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `gipity brand` - view or change the app's generated visual identity:
3
+ * favicons, the iOS Home Screen icon, the PWA manifest, and the 1200x630
4
+ * social share card (og-image) that X/iMessage/Slack show when the app's
5
+ * URL is shared.
6
+ *
7
+ * The default identity is deterministic (accent color from the project guid,
8
+ * glyph from the title's first character, small Gipity egg badge). `brand set`
9
+ * overrides pieces of it - most usefully the glyph (an emoji reads far more
10
+ * "this is my app" than a letter) and the accent color - then regenerates
11
+ * every asset server-side in one shot. Deploy afterwards to publish.
12
+ */
13
+ import { Command } from 'commander';
14
+ import { get, post } from '../api.js';
15
+ import { resolveProjectContext } from '../config.js';
16
+ import { bold, muted, success } from '../colors.js';
17
+ import { run } from '../helpers/index.js';
18
+ function printBrand(d) {
19
+ console.log(`${bold('Glyph:')} ${d.resolved.glyph}${d.resolved.isEmoji ? muted(' (emoji)') : ''}`);
20
+ console.log(`${bold('Accent:')} ${d.resolved.accent}${d.branding.primary_color ? '' : muted(' (derived from project)')}`);
21
+ if (d.branding.app_name)
22
+ console.log(`${bold('Name:')} ${d.branding.app_name}`);
23
+ if (d.branding.tagline)
24
+ console.log(`${bold('Tagline:')} ${d.branding.tagline}`);
25
+ }
26
+ const DEPLOY_HINT = 'Deploy to publish the new assets: gipity deploy dev';
27
+ export const brandCommand = new Command('brand')
28
+ .description("The app's generated icons + share card (favicons, iOS icon, og-image)")
29
+ .addHelpText('after', `
30
+ Examples:
31
+ gipity brand Show the current brand
32
+ gipity brand set --emoji 🦍 Emoji icon + share card, regenerate all assets
33
+ gipity brand set --glyph R --color "#3b82f6" Letter glyph on a blue accent
34
+ gipity brand set --tagline "Bananas at 60fps" Subtitle on the share card
35
+ gipity brand apply Re-render assets from the stored brand
36
+ gipity brand apply --fix-head Migrate an older app: assets + splice the
37
+ share/SEO tags into src/index.html
38
+
39
+ Assets are generated server-side into src/images/ (+ src/manifest.webmanifest)
40
+ and are versioned like any other file - rollback restores previous ones.`);
41
+ brandCommand
42
+ .command('show', { isDefault: true })
43
+ .description('Show the stored brand and the resolved render spec')
44
+ .option('--project <guid-or-slug>', 'Target a specific project instead of cwd / Home')
45
+ .option('--json', 'Output raw JSON')
46
+ .action((opts) => run('Brand', async () => {
47
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
48
+ const res = await get(`/projects/${config.projectGuid}/brand`);
49
+ if (opts.json) {
50
+ console.log(JSON.stringify(res.data));
51
+ return;
52
+ }
53
+ printBrand(res.data);
54
+ console.log(muted(`\nAssets: ${(res.data.assets ?? []).join(', ')}`));
55
+ console.log(muted(`Change it: gipity brand set --emoji 🎨 | --glyph A | --color "#RRGGBB" | --tagline "..."`));
56
+ }));
57
+ brandCommand
58
+ .command('set')
59
+ .description('Change brand fields and regenerate all assets')
60
+ .option('--emoji <emoji>', 'Emoji glyph for the icon + share card (e.g. 🦍)')
61
+ .option('--glyph <char>', 'Letter/digit glyph (alias of --emoji, any single character)')
62
+ .option('--color <hex>', 'Accent color, #RRGGBB')
63
+ .option('--name <name>', 'Display name override (og/share title stays the page title)')
64
+ .option('--tagline <text>', 'One-line subtitle on the share card (max 120 chars)')
65
+ .option('--fix-head', 'Also splice the share/SEO head block into src/index.html (for apps installed before templates shipped it)')
66
+ .option('--no-regenerate', 'Store the fields without re-rendering assets')
67
+ .option('--project <guid-or-slug>', 'Target a specific project instead of cwd / Home')
68
+ .option('--json', 'Output raw JSON')
69
+ .action((opts) => run('Brand', async () => {
70
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
71
+ const body = {};
72
+ const glyph = opts.emoji ?? opts.glyph;
73
+ if (glyph)
74
+ body.icon_glyph = glyph;
75
+ if (opts.color)
76
+ body.primary_color = opts.color;
77
+ if (opts.name)
78
+ body.app_name = opts.name;
79
+ if (opts.tagline)
80
+ body.tagline = opts.tagline;
81
+ if (opts.regenerate === false)
82
+ body.regenerate = false;
83
+ if (opts.fixHead)
84
+ body.fix_head = true;
85
+ if (!glyph && !opts.color && !opts.name && !opts.tagline) {
86
+ console.error('Nothing to set - pass --emoji, --glyph, --color, --name, or --tagline. (gipity brand set --help; use gipity brand apply to just re-render)');
87
+ process.exitCode = 1;
88
+ return;
89
+ }
90
+ const res = await post(`/projects/${config.projectGuid}/brand`, body);
91
+ if (opts.json) {
92
+ console.log(JSON.stringify(res.data));
93
+ return;
94
+ }
95
+ printBrand(res.data);
96
+ reportFiles(res.data.files ?? []);
97
+ }));
98
+ brandCommand
99
+ .command('apply')
100
+ .description('Re-render all brand assets from the stored brand (no field changes)')
101
+ .option('--fix-head', 'Also splice the share/SEO head block into src/index.html (for apps installed before templates shipped it)')
102
+ .option('--project <guid-or-slug>', 'Target a specific project instead of cwd / Home')
103
+ .option('--json', 'Output raw JSON')
104
+ .action((opts) => run('Brand', async () => {
105
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
106
+ const body = opts.fixHead ? { fix_head: true } : {};
107
+ const res = await post(`/projects/${config.projectGuid}/brand`, body);
108
+ if (opts.json) {
109
+ console.log(JSON.stringify(res.data));
110
+ return;
111
+ }
112
+ reportFiles(res.data.files ?? []);
113
+ }));
114
+ function reportFiles(files) {
115
+ if (!files.length) {
116
+ console.log(muted('\nStored (assets not regenerated - run gipity brand apply).'));
117
+ return;
118
+ }
119
+ console.log(success(`\n✓ Regenerated ${files.length} asset${files.length === 1 ? '' : 's'}.`));
120
+ if (files.includes('src/index.html')) {
121
+ console.log(success('✓ src/index.html head updated (page title/description preserved).'));
122
+ }
123
+ console.log(muted(` ${DEPLOY_HINT}`));
124
+ }
125
+ //# sourceMappingURL=brand.js.map
@@ -968,7 +968,7 @@ async function pickAgent(lastUsed) {
968
968
  const notes = [];
969
969
  if (a.key === lastUsed)
970
970
  notes.push('last used');
971
- if (a.key === 'codex' && !a.hooksSupportedOnPlatform(process.platform)) {
971
+ if ((a.key === 'codex' || a.key === 'agy') && !a.hooksSupportedOnPlatform(process.platform)) {
972
972
  notes.push('session recording unavailable on Windows');
973
973
  }
974
974
  const note = notes.length ? ` ${muted(`(${notes.join(', ')})`)}` : '';
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { get, post, del, publicPost } from '../api.js';
2
+ import { get, post, del, publicPost, mintAppToken } from '../api.js';
3
3
  import { getAuth } from '../auth.js';
4
4
  import { requireConfig, resolveApiBase } from '../config.js';
5
5
  import { error as clrError, bold, muted, success, warning } from '../colors.js';
@@ -62,13 +62,7 @@ fnCommand
62
62
  * auth-gated function — the server's message is surfaced as-is, with no
63
63
  * "run: gipity login" misdirection. */
64
64
  async function callAnon(projectGuid, name, body) {
65
- let appToken;
66
- try {
67
- const minted = await publicPost('/api/token', { app: projectGuid });
68
- appToken = minted.data.token;
69
- }
70
- catch { /* public functions work without a token; auth-gated ones will 401 with the real reason */ }
71
- return publicPost(`/api/${projectGuid}/fn/${encodeURIComponent(name)}`, body, appToken ? { 'X-App-Token': appToken } : undefined);
65
+ return publicPost(`/api/${projectGuid}/fn/${encodeURIComponent(name)}`, body, await mintAppToken(projectGuid));
72
66
  }
73
67
  fnCommand
74
68
  .command('call <name> [body]')
@@ -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, and installs the Gipity skills + file-sync hooks into the agent CLIs found on this machine (Claude Code, Codex, Grok).')
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, Antigravity).')
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)')
@@ -177,7 +177,7 @@ Working with an existing Gipity project:
177
177
  }
178
178
  console.log(success(`Wrote primer files: ${primerSummary}.`));
179
179
  if (wantsClaude) {
180
- console.log(success('Ready! Run your coding agent here (claude, codex, grok), or `gipity build` to launch one with a picker.'));
180
+ console.log(success('Ready! Run your coding agent here (claude, codex, grok, agy), or `gipity build` to launch one with a picker.'));
181
181
  // Recording happens by default (however Claude Code is launched), so
182
182
  // say so up front - consent should be explicit, not discovered later.
183
183
  if (opts.capture === false) {
@@ -0,0 +1,91 @@
1
+ import { Command } from 'commander';
2
+ import { get, post, del } from '../api.js';
3
+ import { resolveProjectContext } from '../config.js';
4
+ import { bold, muted, success, warning } from '../colors.js';
5
+ import { run, printList } from '../helpers/index.js';
6
+ /**
7
+ * Project API keys - the revocable secret a script, cron, or agent sends as
8
+ * `X-Api-Key` to write to YOUR app without a browser login.
9
+ *
10
+ * These are per-project and app-facing; `gipity token` mints account-level
11
+ * agent tokens (gip_at_*) that drive the CLI itself. Without this command the
12
+ * only mint path was raw curl, so agents asked for "a key I can generate and
13
+ * revoke" built their own key table instead of using the platform's.
14
+ */
15
+ export const keyCommand = new Command('key')
16
+ .description('Manage project API keys for scripts and agents (X-Api-Key)')
17
+ .addHelpText('after', `
18
+ Give a script, cron, or agent write access to your app without a login:
19
+
20
+ gipity key create "laptop importer" --role editor
21
+ # the script sends: X-Api-Key: <the key printed once above>
22
+
23
+ Inside a function the caller shows up as ctx.auth.via === 'api_key' with
24
+ ctx.auth.apiKeyName set to the key's name - stamp that on rows to tell
25
+ script-written entries from hand-entered ones. Never build your own key table.
26
+
27
+ Account-level tokens that run the CLI headlessly are a different thing: see
28
+ gipity token --help.`);
29
+ const fmtDate = (d) => (d ? new Date(d).toLocaleDateString() : 'never');
30
+ const projectOpt = ['--project <guid-or-slug>', 'Target a specific project instead of cwd / Home'];
31
+ async function projectGuid(opts) {
32
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
33
+ return config.projectGuid;
34
+ }
35
+ keyCommand
36
+ .command('create <name>')
37
+ .description('Mint a project API key (shown once). Give it a name you will recognize later')
38
+ .option('--role <role>', 'viewer | editor | owner (default: viewer)', 'viewer')
39
+ .option('--expires-days <n>', 'Days until the key expires (default: never)')
40
+ .option(...projectOpt)
41
+ .option('--json', 'Output as JSON')
42
+ .action((name, opts) => run('Create', async () => {
43
+ const body = { name, role: opts.role };
44
+ if (opts.expiresDays !== undefined) {
45
+ const days = parseInt(opts.expiresDays, 10);
46
+ if (!Number.isFinite(days) || days <= 0)
47
+ throw new Error('--expires-days must be a positive number of days');
48
+ body.expires_in_days = days;
49
+ }
50
+ const res = await post(`/projects/${await projectGuid(opts)}/api-keys`, body);
51
+ const k = res.data;
52
+ if (opts.json) {
53
+ console.log(JSON.stringify(k));
54
+ return;
55
+ }
56
+ const expNote = k.expires_at ? ` (expires ${fmtDate(k.expires_at)})` : ' (never expires)';
57
+ console.log(success(`Created API key ${bold(k.short_guid)} "${k.name}" as ${k.role}${muted(expNote)}.`));
58
+ console.log('');
59
+ console.log(k.key);
60
+ console.log('');
61
+ console.log(muted('Send it from your script on every request:'));
62
+ console.log(muted(` curl -H "X-Api-Key: ${k.key}" ...`));
63
+ console.log(muted(`Revoke it any time: gipity key revoke ${k.short_guid}`));
64
+ console.log('');
65
+ console.log(warning('Copy it now - it will not be shown again.'));
66
+ }));
67
+ keyCommand
68
+ .command('list')
69
+ .alias('ls')
70
+ .description('List this project\'s API keys (values are never shown again)')
71
+ .option(...projectOpt)
72
+ .option('--json', 'Output as JSON')
73
+ .action((opts) => run('List', async () => {
74
+ const res = await get(`/projects/${await projectGuid(opts)}/api-keys`);
75
+ printList(res.data, opts, 'No API keys. Mint one with: gipity key create "my script" --role editor', (k) => `${bold(k.short_guid)} ${k.name} ${muted(`${k.role} ${k.prefix}… last used ${fmtDate(k.last_used_at)} expires ${fmtDate(k.expires_at)}`)}`);
76
+ }));
77
+ keyCommand
78
+ .command('revoke <short_guid>')
79
+ .alias('rm')
80
+ .description('Revoke a project API key (instant, irreversible)')
81
+ .option(...projectOpt)
82
+ .option('--json', 'Output as JSON')
83
+ .action((shortGuid, opts) => run('Revoke', async () => {
84
+ await del(`/projects/${await projectGuid(opts)}/api-keys/${encodeURIComponent(shortGuid)}`);
85
+ if (opts.json) {
86
+ console.log(JSON.stringify({ short_guid: shortGuid, revoked: true }));
87
+ return;
88
+ }
89
+ console.log(success(`Revoked API key ${bold(shortGuid)}.`));
90
+ }));
91
+ //# sourceMappingURL=key.js.map
@@ -257,6 +257,31 @@ export function budgetOverrunHint(reason, usedBudgetMs) {
257
257
  `(up to ${EVAL_SCRIPT_BUDGET_MAX_MS}), e.g. --timeout ${EVAL_SCRIPT_BUDGET_MAX_MS} — ` +
258
258
  `or gate on a ready signal instead: --wait-for '<selector>' --wait-timeout ${MAX_WAIT_MS}.`;
259
259
  }
260
+ /** The server aborts a script whose page navigated out from under it, and tells
261
+ * the caller to "split the check into two evals". For the case that actually
262
+ * produces this (seed state, reload, assert the state came back), that advice
263
+ * sends the caller down the wrong road: two evals are two independent page
264
+ * sessions, so the second one has to re-seed before it can read anything, and
265
+ * the thing being verified (a real reload restoring real persisted state) is
266
+ * never exercised. `--reload` is the primitive for it: one page load, reloaded
267
+ * IN PLACE with storage preserved, second expression against the post-reload
268
+ * DOM. Name it here, at the exact moment the caller hand-rolled it. */
269
+ export function navigationAbortHint(reason) {
270
+ if (!/navigated or reloaded/i.test(reason))
271
+ return null;
272
+ return `If you were verifying that state SURVIVES a reload, that is what --reload is for (one command, one page): ` +
273
+ `gipity page eval "<url>" '<seed/assert expr>' --reload '<assert-restored expr>'. ` +
274
+ `It reloads the page in place (localStorage/sessionStorage/cookies preserved) and reports the second result ` +
275
+ `separately. Splitting into two 'page eval' calls does NOT do this: each call is its own browser session, so ` +
276
+ `the second one starts from empty storage and never exercises the restore path.`;
277
+ }
278
+ /** True when the submitted script already drives the app's clock itself
279
+ * (`core.advance(seconds)` on the 3D templates, the imported `advance(seconds)`
280
+ * on the 2D one). Such a script does not care what the renderer paints at, so
281
+ * the slow-render warning below has nothing to tell it. Exported for tests. */
282
+ export function stepsDeterministically(script) {
283
+ return /\badvance\s*\(/.test(script);
284
+ }
260
285
  /** The headless browser paints slowly, and what that MEANS depends on what the
261
286
  * page is doing — so the one-size warning was actively misleading on a --camera
262
287
  * run. A vision app has no loop to step: its pipeline is driven by camera
@@ -273,6 +298,8 @@ export function budgetOverrunHint(reason, usedBudgetMs) {
273
298
  * deterministic way to wait (--wait-for) instead of inviting a wall-clock
274
299
  * escalation that is guaranteed to be wasted. Exported for tests. */
275
300
  export function slowRenderMessage(fps, o) {
301
+ if (!o.camera && stepsDeterministically(o.script ?? ''))
302
+ return null;
276
303
  if (o.camera) {
277
304
  const frames = Math.max(1, Math.round(fps * (o.waitMs / 1000)));
278
305
  return `${warning('⚠ Slow render:')} page painted at ${fps} fps, so the app's vision pipeline ran on roughly `
@@ -382,7 +409,7 @@ export const pageEvalCommand = new Command('eval')
382
409
  .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
410
  .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
411
  .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
- .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.')
412
+ .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 ("remember it when I come back"): seed/assert state with <expr>, then assert the restored UI here. This is the ONLY way to check that - reloading inside the body aborts the eval (the result is lost with the old page), and two separate `page eval` calls are two fresh browser profiles, so the second starts from empty storage.')
386
413
  .option('--reload-file <path>', 'Read the post-reload expression from a file instead of inline --reload (mutually exclusive)')
387
414
  .option('--camera <path>', `Play a local image or video (.png/.jpg/.webp/.mp4/.webm/.y4m/.mjpeg) as the browser's WEBCAM feed, so a camera app's real pipeline (getUserMedia → MediaPipe/YOLOX → your app logic) runs headlessly on a frame you choose. Implies --fake-media and waits ${CAMERA_DEFAULT_WAIT_MS / 1000}s for the vision model to load. No frame handy? gipity generate image "a hand making a closed fist, palm to camera".`)
388
415
  .option('--fake-media', 'Grant a synthetic microphone + camera and auto-accept the getUserMedia prompt, so a voice/camera app runs headlessly instead of hitting its no-camera path. The feed is a built-in test pattern (and a tone) — nothing a vision model can recognize; to drive a vision app use --camera <path> instead.')
@@ -589,7 +616,10 @@ export const pageEvalCommand = new Command('eval')
589
616
  // raises it — and the flag is what the caller needs next. Name it, with
590
617
  // the value THIS run used, so the retry is an edit rather than a guess.
591
618
  const msg = err?.message ?? '';
592
- const hint = budgetOverrunHint(msg, scriptBudgetMs);
619
+ const hint = budgetOverrunHint(msg, scriptBudgetMs)
620
+ // A script aborted by its own reload is nearly always a persistence
621
+ // check hand-rolled without --reload; point at the flag that does it.
622
+ ?? (reloadExpr === undefined ? navigationAbortHint(msg) : null);
593
623
  if (!hint)
594
624
  throw err;
595
625
  throw new Error(`${msg}\n${hint}`);
@@ -632,8 +662,17 @@ export const pageEvalCommand = new Command('eval')
632
662
  // page's rAF loop is its clock, and engines cap the per-frame delta, so
633
663
  // `setTimeout(2000)` may advance only a fraction of a second of app time.
634
664
  // Without this line the eval returns a plausible-looking false negative.
665
+ // A script that already steps the loop itself is immune to all of that, so
666
+ // slowRenderMessage returns null for it rather than prescribing the fix it
667
+ // is already applying.
635
668
  if (d.slowRender) {
636
- console.log(slowRenderMessage(d.slowRender.fps, { camera: !!camera, waitMs }));
669
+ const slow = slowRenderMessage(d.slowRender.fps, {
670
+ camera: !!camera,
671
+ waitMs,
672
+ script: [expr, ...steps, reloadExpr ?? ''].join('\n'),
673
+ });
674
+ if (slow)
675
+ console.log(slow);
637
676
  }
638
677
  // Identity line on EVERY run: without it an agent can't distinguish
639
678
  // "signed-in eval" from "--auth silently no-op'd against the anonymous