gipity 1.1.3 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/catalog.js CHANGED
@@ -36,11 +36,9 @@ export const KITS = [
36
36
  { key: 'chatbot', hint: 'drop-in chatbot - persona, guardrails, streaming responses' },
37
37
  { key: 'audio-align', hint: 'audio + lyrics -> word-level timing JSON (GPU job)' },
38
38
  { key: 'i18n', hint: 'multi-language web apps - language picker, RTL, translations' },
39
- { key: 'records', hint: 'registry-driven data plane - generic CRUD, validation, search, audit spine' },
40
- { key: 'views', hint: 'registry-driven UI over records - table, forms, kanban' },
41
- { key: 'agent-api', hint: 'named API keys for agent/script writes through the records kit' },
42
39
  { key: 'contacts', hint: 'multi-source contact layer - LinkedIn/Gmail import, dedupe with provenance' },
43
40
  { key: 'stripe', hint: 'charge your users - Stripe checkout, subscriptions, brokered webhooks' },
44
41
  { key: 'notify', hint: 'web push notifications - platform-owned keys, works on iOS home screen' },
42
+ { key: 'servicenow', hint: 'ServiceNow tables as a data source - OAuth pull/write-back/real-time sync' },
45
43
  ];
46
44
  //# sourceMappingURL=catalog.js.map
@@ -1171,37 +1171,47 @@ async function pickOrCreateProject(projects, existingSlugs) {
1171
1171
  const showAdopt = canAdoptCwd(cwd);
1172
1172
  // Reserve slot 1 for "create new", slot 2 for "use this dir" when shown.
1173
1173
  const reserved = showAdopt ? 2 : 1;
1174
- // Pickable single-keypress range is 1-9; leave one slot for "Show all"
1175
- // when there are more projects than fit.
1176
- const maxRecent = 9 - reserved - 1; // worst case: keep slot 9 free for "show all"
1177
- const recent = filtered.slice(0, maxRecent);
1178
- const hasMore = filtered.length > recent.length;
1174
+ // Pickable single-keypress range is 1-9. When every project fits, list
1175
+ // them all; otherwise the actions grow a "Search projects" slot right
1176
+ // after the reserved ones and "Show more" takes slot 9 - accounts with
1177
+ // hundreds of projects can't scroll a dump. Action rows render in a
1178
+ // different color than project rows so the two groups read apart.
1179
+ const fitsAll = filtered.length <= 9 - reserved;
1180
+ const hasMore = !fitsAll;
1181
+ const searchSlot = hasMore ? reserved + 1 : 0;
1182
+ const projectBase = reserved + (hasMore ? 1 : 0); // slots projectBase+1.. are projects
1183
+ const recent = fitsAll ? filtered : filtered.slice(0, 9 - reserved - 2);
1184
+ const moreSlot = hasMore ? projectBase + recent.length + 1 : 0; // always 9 when shown
1179
1185
  // Loop so that a refused/declined adopt-cwd re-shows the picker rather
1180
1186
  // than dropping the user into a shell.
1181
1187
  while (true) {
1182
1188
  const newProjectLabel = formatNewProjectLabel(existingSlugs);
1183
1189
  console.log(` ${bold('Choose project to open:')}\n`);
1184
- console.log(` ${bold('1.')} Create new project ${muted(`(${newProjectLabel})`)}`);
1190
+ console.log(` ${bold('1.')} ${info('Create new project')} ${muted(`(${newProjectLabel})`)}`);
1185
1191
  if (showAdopt) {
1186
- console.log(` ${bold('2.')} Use this directory ${muted(`(${formatCwdLabel(cwd)})`)}`);
1192
+ console.log(` ${bold('2.')} ${info('Use this directory')} ${muted(`(${formatCwdLabel(cwd)})`)}`);
1187
1193
  }
1188
- recent.forEach((p, i) => console.log(` ${bold(`${i + reserved + 1}.`)} ${p.name} ${muted(`(${p.slug})`)}`));
1189
1194
  if (hasMore) {
1190
- console.log(` ${bold(`${recent.length + reserved + 1}.`)} Show all projects`);
1195
+ console.log(` ${bold(`${searchSlot}.`)} ${info('Search projects')} ${muted(`(${filtered.length} total)`)}`);
1196
+ }
1197
+ recent.forEach((p, i) => console.log(` ${bold(`${projectBase + i + 1}.`)} ${p.name} ${muted(`(${p.slug})`)}`));
1198
+ if (hasMore) {
1199
+ console.log(` ${bold(`${moreSlot}.`)} ${info('Show more')}`);
1191
1200
  }
1192
1201
  console.log('');
1193
- const maxOption = recent.length + reserved + (hasMore ? 1 : 0);
1202
+ const maxOption = projectBase + recent.length + (hasMore ? 1 : 0);
1194
1203
  const idx = await pickOne('Choose', maxOption, 1);
1195
- // Recent project (slots reserved+1 .. recent.length+reserved).
1196
- if (idx > reserved && idx <= recent.length + reserved) {
1197
- return { kind: 'pick', project: recent[idx - reserved - 1] };
1204
+ // Recent project (slots projectBase+1 .. projectBase+recent.length).
1205
+ if (idx > projectBase && idx <= projectBase + recent.length) {
1206
+ return { kind: 'pick', project: recent[idx - projectBase - 1] };
1198
1207
  }
1199
- // Show all projects (one past the last recent slot).
1200
- if (hasMore && idx === recent.length + reserved + 1) {
1201
- const picked = await pickFromAll(filtered);
1208
+ // Search (right after the reserved slots) / Show more (slot 9).
1209
+ if (hasMore && (idx === searchSlot || idx === moreSlot)) {
1210
+ const picked = await browseProjects(filtered, { search: idx === searchSlot });
1202
1211
  if (picked)
1203
1212
  return { kind: 'pick', project: picked };
1204
- continue; // user bailed; re-show top picker
1213
+ console.log('');
1214
+ continue; // user backed out; re-show top picker
1205
1215
  }
1206
1216
  // Slot 2 = "Use this directory" (only when shown).
1207
1217
  if (showAdopt && idx === 2) {
@@ -1217,19 +1227,79 @@ async function pickOrCreateProject(projects, existingSlugs) {
1217
1227
  return { kind: 'create-new', project };
1218
1228
  }
1219
1229
  }
1220
- /** Render "Show all projects" with numbered list; returns the picked
1221
- * project or null if the user picked "create new" (slot 1) or invalid. */
1222
- async function pickFromAll(filtered) {
1223
- console.log('');
1224
- console.log(` ${bold('All projects:')}\n`);
1225
- console.log(` ${bold('1.')} Create new project`);
1226
- filtered.forEach((p, i) => console.log(` ${bold(`${i + 2}.`)} ${p.name} ${muted(`(${p.slug})`)}`));
1230
+ const PROJECT_PAGE_SIZE = 10;
1231
+ /** Case-insensitive substring match on project name or slug. An empty or
1232
+ * whitespace-only query matches everything. Exported for tests. */
1233
+ export function searchProjects(all, query) {
1234
+ const q = query.trim().toLowerCase();
1235
+ if (!q)
1236
+ return all;
1237
+ return all.filter(p => p.name.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q));
1238
+ }
1239
+ /** Paged, searchable project list - the shared renderer behind both the
1240
+ * "Search projects" and "Show more" picker slots. Shows PROJECT_PAGE_SIZE
1241
+ * rows at a time with numbering that stays stable across pages, then reads
1242
+ * a full line (not single-keypress - numbers can exceed 9):
1243
+ * Enter → next page · <number> → open that project
1244
+ * s → new search (always over the FULL list, never the current subset)
1245
+ * q → back to the main picker (returns null).
1246
+ * `opts.search` starts by prompting for a query instead of listing all.
1247
+ * Exported for tests. */
1248
+ export async function browseProjects(all, opts = {}) {
1249
+ let items = all;
1250
+ let heading = 'All projects';
1251
+ // Prompt for a query and swap the working list to the matches. Returns
1252
+ // false (list unchanged) on empty input or zero matches.
1253
+ const runSearch = async () => {
1254
+ const q = await prompt(` ${bold('Search projects:')} `);
1255
+ if (!q)
1256
+ return false;
1257
+ const matches = searchProjects(all, q);
1258
+ if (matches.length === 0) {
1259
+ console.log(` ${muted(`No projects match "${q}".`)}`);
1260
+ return false;
1261
+ }
1262
+ items = matches;
1263
+ heading = `Projects matching "${q}"`;
1264
+ return true;
1265
+ };
1227
1266
  console.log('');
1228
- const allChoice = await prompt(` Choose (1-${filtered.length + 1}): `);
1229
- const allIdx = parseInt(allChoice, 10);
1230
- if (allIdx >= 2 && allIdx <= filtered.length + 1)
1231
- return filtered[allIdx - 2];
1232
- return null;
1267
+ if (opts.search && !(await runSearch()))
1268
+ return null;
1269
+ let shown = 0;
1270
+ while (true) {
1271
+ if (shown < items.length) {
1272
+ const page = items.slice(shown, shown + PROJECT_PAGE_SIZE);
1273
+ if (shown === 0)
1274
+ console.log(` ${bold(`${heading}`)} ${muted(`(${items.length})`)}\n`);
1275
+ page.forEach((p, i) => console.log(` ${bold(`${String(shown + i + 1).padStart(3)}.`)} ${p.name} ${muted(`(${p.slug})`)}`));
1276
+ shown += page.length;
1277
+ console.log('');
1278
+ }
1279
+ const more = shown < items.length;
1280
+ const hints = [
1281
+ more ? `Enter = next ${Math.min(PROJECT_PAGE_SIZE, items.length - shown)}` : null,
1282
+ 'number = open',
1283
+ 's = search',
1284
+ 'q = back',
1285
+ ].filter(Boolean).join(' · ');
1286
+ const answer = (await prompt(` ${bold('Choose')} ${muted(`(${hints})`)}: `)).toLowerCase();
1287
+ if (answer === '' && more)
1288
+ continue; // Enter → next page
1289
+ if (answer === '' || answer === 'q')
1290
+ return null;
1291
+ if (answer === 's') {
1292
+ if (await runSearch()) {
1293
+ shown = 0;
1294
+ console.log('');
1295
+ }
1296
+ continue;
1297
+ }
1298
+ const n = parseInt(answer, 10);
1299
+ if (Number.isInteger(n) && n >= 1 && n <= shown)
1300
+ return items[n - 1];
1301
+ console.log(` ${muted('Type one of the numbers above, Enter for more, s to search, or q to go back.')}`);
1302
+ }
1233
1303
  }
1234
1304
  /** Show "(~/GipityProjects/project-NNN)" - the exact dir option 1 will
1235
1305
  * create, so the user can see where their new project will land. */
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { post } from '../api.js';
3
- import { requireConfig } from '../config.js';
3
+ import { requireConfig, resolveApiBase } from '../config.js';
4
4
  import { formatSize } from '../utils.js';
5
5
  import { success, error as clrError, warning, muted, bold, brand } from '../colors.js';
6
6
  import { run, syncBeforeAction } from '../helpers/index.js';
@@ -150,7 +150,11 @@ export const deployCommand = new Command('deploy')
150
150
  // for an API-only app the endpoint address IS the deliverable, so name
151
151
  // it instead of leaving it to be inferred from a template comment.
152
152
  if (d.phases?.some(p => p.name === 'functions')) {
153
- console.log(`${muted('Functions:')} POST ${brand(`${config.apiBase}/api/${config.projectGuid}/fn/<name>`)}`);
153
+ // Print the base the CLI actually calls (resolveApiBase honors the
154
+ // --api-base flag and GIPITY_API_BASE env), not the raw stored
155
+ // config.apiBase - otherwise on any override we'd advertise a host we
156
+ // didn't deploy to and send the caller probing the wrong instance.
157
+ console.log(`${muted('Functions:')} POST ${brand(`${resolveApiBase()}/api/${config.projectGuid}/fn/<name>`)}`);
154
158
  console.log(muted(' (same address on dev and prod; names: gipity fn list; verify the public path: gipity fn call <name> --anon)'));
155
159
  }
156
160
  await inspectAfter();
@@ -34,6 +34,24 @@ fileCommand
34
34
  process.stdout.write(res.data.content);
35
35
  }
36
36
  }));
37
+ fileCommand
38
+ .command('url <path>')
39
+ .description('Durable public URL for a project file - no deploy needed. Reachable by browsers, GPU jobs, and external services the moment the file syncs; ideal for throwaway test fixtures (delete the file to revoke the link).')
40
+ .option('--download', 'URL forces a download (Content-Disposition: attachment)')
41
+ .option('--json', 'Output as JSON')
42
+ .action((path, opts) => run('Url', async () => {
43
+ const { config } = await resolveProjectContext();
44
+ const qs = new URLSearchParams({ path });
45
+ if (opts.download)
46
+ qs.set('download', '1');
47
+ const res = await get(`/projects/${config.projectGuid}/files/url?${qs.toString()}`);
48
+ if (opts.json) {
49
+ console.log(JSON.stringify(res.data));
50
+ }
51
+ else {
52
+ console.log(res.data.url);
53
+ }
54
+ }));
37
55
  fileCommand
38
56
  .command('tree [path]')
39
57
  .description('Show the file tree')
@@ -1,9 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import { get, post, del, publicPost } from '../api.js';
3
3
  import { getAuth } from '../auth.js';
4
- import { requireConfig } from '../config.js';
4
+ import { requireConfig, resolveApiBase } from '../config.js';
5
5
  import { error as clrError, bold, muted, success, warning } from '../colors.js';
6
- import { run, printList, emitField } from '../helpers/index.js';
6
+ import { run, printList, emitField, resolveBody } from '../helpers/index.js';
7
7
  import { confirm } from '../utils.js';
8
8
  export const fnCommand = new Command('fn')
9
9
  .description('Manage functions');
@@ -21,7 +21,7 @@ fnCommand
21
21
  // The callable address is the deliverable for API-only apps - name it here
22
22
  // rather than leaving it to be inferred from a template comment.
23
23
  if (!opts.json && res.data.length > 0) {
24
- console.log(muted(`Endpoint: POST ${config.apiBase}/api/${config.projectGuid}/fn/<name> (same on dev and prod; public path: gipity fn call <name> --anon)`));
24
+ console.log(muted(`Endpoint: POST ${resolveApiBase()}/api/${config.projectGuid}/fn/<name> (same on dev and prod; public path: gipity fn call <name> --anon)`));
25
25
  }
26
26
  }));
27
27
  fnCommand
@@ -73,14 +73,14 @@ async function callAnon(projectGuid, name, body) {
73
73
  fnCommand
74
74
  .command('call <name> [body]')
75
75
  .description('Call a function')
76
- .option('--data <json>', 'JSON request body')
76
+ .option('-d, --data <json>', 'JSON request body: inline JSON, @file to read a file, or @- / - for stdin')
77
+ .option('--file <field=@path>', 'Attach a file as { data, media_type } under <field> (the vision/media service shape), repeatable, e.g. --file image=@receipt.png', (v, acc) => (acc || []).concat(v))
77
78
  .option('--anon', 'Call as an anonymous visitor (the public path a signed-out user hits) instead of as your signed-in account')
78
79
  .option('--field <path>', 'Print only this field of the result (dot path, e.g. items.0.short_guid)')
79
80
  .option('--json', 'Output as JSON')
80
81
  .action((name, bodyArg, opts) => run('Call', async () => {
81
82
  const config = requireConfig();
82
- const raw = bodyArg || opts.data || '{}';
83
- const body = JSON.parse(raw);
83
+ const body = resolveBody(bodyArg || opts.data, opts.file);
84
84
  const path = `/api/${config.projectGuid}/fn/${encodeURIComponent(name)}`;
85
85
  // Name the calling identity (stderr, so stdout stays parseable). Without
86
86
  // this, a "test the anonymous path" call silently runs authenticated as
@@ -2,11 +2,12 @@ import { Command } from 'commander';
2
2
  import { post } from '../api.js';
3
3
  import { resolveProjectContext, getConfigPath } from '../config.js';
4
4
  import { pushFile } from '../sync.js';
5
- import { mkdirSync, writeFileSync } from 'fs';
6
- import { resolve as resolvePath, dirname, relative, isAbsolute } from 'path';
5
+ import { mkdirSync, writeFileSync, readFileSync } from 'fs';
6
+ import { resolve as resolvePath, dirname, relative, isAbsolute, basename } from 'path';
7
7
  import { error as clrError, success, muted } from '../colors.js';
8
8
  import { printCommandError } from '../helpers/command.js';
9
9
  import { withSpinner } from '../progress.js';
10
+ import { guessMime } from '../upload.js';
10
11
  import { IMAGE_MODELS_DOC, IMAGE_GEMINI_ASPECT_RATIOS, IMAGE_GEMINI_SIZES, VIDEO_MODELS_DOC, TTS_PROVIDER_DESCRIPTIONS } from '../provider-docs.js';
11
12
  /** Download a URL and save to a local file, then push it up to the project so
12
13
  * the cloud (and anything that mirrors it) immediately matches local disk.
@@ -20,12 +21,25 @@ import { IMAGE_MODELS_DOC, IMAGE_GEMINI_ASPECT_RATIOS, IMAGE_GEMINI_SIZES, VIDEO
20
21
  * that gap so the "sandbox auto-mirrors the project" contract holds right after
21
22
  * generation. Best-effort: the local save already succeeded, so a push failure
22
23
  * only warns and points at `gipity sync`. */
23
- async function downloadFile(url, filename) {
24
+ async function downloadFile(url, filename, explicit) {
24
25
  const res = await fetch(url);
25
26
  if (!res.ok)
26
27
  throw new Error(`Download failed: ${res.status}`);
27
28
  const buffer = Buffer.from(await res.arrayBuffer());
28
- const outPath = correctExtension(filename, buffer);
29
+ // An explicit `-o` path is a name the caller has committed to and will chain
30
+ // the NEXT command against (`--input src.png`, `page eval --camera src.png`),
31
+ // so the saved path MUST equal what was asked - a predictable path is worth
32
+ // more than a tidy extension. The model's output format is non-deterministic
33
+ // (BFL's "png" request comes back as JPEG), so renaming to match the bytes
34
+ // moves the file out from under the caller's already-written second command:
35
+ // that is the "saved as .jpg, retry with the right path" wasted turn. Honour
36
+ // the request; every downstream consumer (browsers, image/video tools, the
37
+ // edit + camera services) sniffs the bytes, not the extension, so a jpg-in-a-
38
+ // .png plays and edits fine. For the auto-generated default there is no
39
+ // committed path, so there we DO name the file after its real bytes.
40
+ const outPath = explicit ? filename : correctExtension(filename, buffer);
41
+ if (explicit)
42
+ noteFormatMismatch(filename, buffer);
29
43
  ensureOutputDir(outPath);
30
44
  writeFileSync(outPath, buffer);
31
45
  const savedPath = resolvePath(outPath);
@@ -85,6 +99,23 @@ function correctExtension(filename, buf) {
85
99
  + `saving as ${corrected} so the extension matches the actual bytes.`));
86
100
  return corrected;
87
101
  }
102
+ /** When the caller committed to an explicit `-o` path we save to it verbatim
103
+ * (see downloadFile), but the model's format may not match the extension. Say
104
+ * so, and say plainly that the file is at the requested path — so an agent that
105
+ * reads this note does NOT go change the path its already-queued next command
106
+ * points at (that "retry with .jpg" turn is the whole bug we're closing). */
107
+ function noteFormatMismatch(filename, buf) {
108
+ const actual = sniffExt(buf);
109
+ if (!actual)
110
+ return;
111
+ const dot = filename.lastIndexOf('.');
112
+ const asked = dot > 0 ? filename.slice(dot + 1).toLowerCase() : '';
113
+ if (!asked || canonicalExt(asked) === actual)
114
+ return;
115
+ console.error(muted(`Note: ${basename(filename)} holds ${formatName(actual)} bytes though its name ends .${asked} — `
116
+ + `that's fine (browsers and image/video/edit tools sniff the bytes, not the name). `
117
+ + `Saved at the exact path you requested; reference it as-is.`));
118
+ }
88
119
  /** Create the parent directory of an -o path, so `-o src/audio/ding.mp3` works
89
120
  * in a tree that has no `src/audio/` yet instead of dying on a raw ENOENT.
90
121
  *
@@ -116,22 +147,52 @@ async function pushGenerated(savedPath) {
116
147
  console.error(muted(`Note: couldn't sync to the cloud automatically (${err.message}). Run \`gipity sync\` before referencing this file in \`gipity sandbox run\`.`));
117
148
  }
118
149
  }
150
+ /** Read the `--input` image files an edit request is applied to, as base64 +
151
+ * mime type. The CLI reads the bytes itself so a photo never has to travel
152
+ * through the shell argv (base64 images blow past ARG_MAX — "Argument list too
153
+ * long" — the moment you try to inline them), which is exactly the trap an
154
+ * agent falls into hand-rolling an edit call with a python one-liner. */
155
+ function readInputImages(paths) {
156
+ return paths.map((p) => {
157
+ let buf;
158
+ try {
159
+ buf = readFileSync(p);
160
+ }
161
+ catch (e) {
162
+ throw new Error(`can't read --input image ${p} (${e.code || e.message}).`);
163
+ }
164
+ const mime = guessMime(p);
165
+ if (!mime.startsWith('image/')) {
166
+ throw new Error(`--input ${p} is not an image (${mime}). Edit inputs must be image files.`);
167
+ }
168
+ return { data: buf.toString('base64'), mime_type: mime };
169
+ });
170
+ }
119
171
  // ── IMAGE ──────────────────────────────────────────────────────────────
120
172
  const imageCommand = new Command('image')
121
- .description(`Generate an image from a text prompt using AI.
173
+ .description(`Generate an image from a text prompt, or EDIT existing images with --input.
122
174
 
123
175
  Models: ${IMAGE_MODELS_DOC}
124
176
 
177
+ Editing (--input): pass one or more source images and the prompt becomes an edit
178
+ instruction applied to them - "make it night time", "add a hat", "remove the car
179
+ in the background", or compose several inputs together. Editing routes to Gemini
180
+ automatically. This is the first-party way to exercise the image-edit flow from
181
+ the CLI; the CLI reads the file bytes itself, so there is no base64/argv fiddling.
182
+
125
183
  Gemini-specific options:
126
184
  --aspect-ratio Control output shape: ${IMAGE_GEMINI_ASPECT_RATIOS}
127
185
  --image-size Control resolution: ${IMAGE_GEMINI_SIZES} (default: 1K)
128
186
 
129
187
  Examples:
130
188
  gipity generate image "a cat wearing a top hat"
189
+ gipity generate image "make it night time" --input photo.jpg -o edited.png
190
+ gipity generate image "put the person in the first photo into the second scene" --input person.png --input scene.png
131
191
  gipity generate image "landscape sunset" --provider gemini --aspect-ratio 16:9 --image-size 2K
132
192
  gipity generate image "product photo" --provider openai --model gpt-image-2 --size 1536x1024 --quality high
133
193
  gipity generate image "abstract art" --provider bfl --model flux-2-pro -o art.png`)
134
- .argument('<prompt>', 'Text description of the image to generate')
194
+ .argument('<prompt>', 'Text description of the image, or (with --input) the edit instruction to apply')
195
+ .option('--input <file>', 'Source image to edit/compose (repeatable). With --input the prompt is an edit instruction; routes to Gemini.', (v, acc) => (acc || []).concat(v))
135
196
  .option('--provider <provider>', 'Image provider: openai, bfl, or gemini (default: bfl)')
136
197
  .option('--model <model>', 'Model ID (see provider list above)')
137
198
  .option('--size <size>', 'Dimensions as WxH, e.g. "1024x1024" (OpenAI/BFL)')
@@ -146,6 +207,7 @@ Examples:
146
207
  const { config } = await resolveProjectContext();
147
208
  if (opts.output)
148
209
  ensureOutputDir(opts.output);
210
+ const inputImages = opts.input ? readInputImages(opts.input) : undefined;
149
211
  const doGenerate = () => post(`/projects/${config.projectGuid}/generate/image`, {
150
212
  prompt,
151
213
  provider: opts.provider,
@@ -155,13 +217,15 @@ Examples:
155
217
  aspect_ratio: opts.aspectRatio,
156
218
  image_size: opts.imageSize,
157
219
  seed: Number.isFinite(opts.seed) ? opts.seed : undefined,
220
+ input_images: inputImages,
158
221
  });
222
+ const verb = inputImages ? 'Editing image…' : 'Generating image…';
159
223
  const result = opts.json
160
224
  ? await doGenerate()
161
- : await withSpinner('Generating image…', doGenerate, { done: null });
225
+ : await withSpinner(verb, doGenerate, { done: null });
162
226
  const ext = result.content_type.includes('png') ? 'png' : 'jpg';
163
227
  const filename = opts.output || `generated.${ext}`;
164
- const savedPath = await downloadFile(result.url, filename);
228
+ const savedPath = await downloadFile(result.url, filename, !!opts.output);
165
229
  if (opts.json) {
166
230
  console.log(JSON.stringify({ ...result, saved: savedPath }));
167
231
  }
@@ -223,7 +287,7 @@ Examples:
223
287
  ? await doGenerate()
224
288
  : await withSpinner('Generating video…', doGenerate, { done: null });
225
289
  const filename = opts.output || 'generated.mp4';
226
- const savedPath = await downloadFile(result.url, filename);
290
+ const savedPath = await downloadFile(result.url, filename, !!opts.output);
227
291
  if (opts.json) {
228
292
  console.log(JSON.stringify({ ...result, saved: savedPath }));
229
293
  }
@@ -287,7 +351,7 @@ Examples:
287
351
  ? await doGenerate()
288
352
  : await withSpinner('Generating speech…', doGenerate, { done: null });
289
353
  const filename = opts.output || 'speech.mp3';
290
- const savedPath = await downloadFile(result.url, filename);
354
+ const savedPath = await downloadFile(result.url, filename, !!opts.output);
291
355
  if (opts.json) {
292
356
  console.log(JSON.stringify({ ...result, saved: savedPath }));
293
357
  }
@@ -340,7 +404,7 @@ Examples:
340
404
  ? await doGenerate()
341
405
  : await withSpinner('Generating music…', doGenerate, { done: null });
342
406
  const filename = opts.output || 'music.mp3';
343
- const savedPath = await downloadFile(result.url, filename);
407
+ const savedPath = await downloadFile(result.url, filename, !!opts.output);
344
408
  if (opts.json) {
345
409
  console.log(JSON.stringify({ ...result, saved: savedPath }));
346
410
  }
@@ -391,7 +455,7 @@ Examples:
391
455
  ? await doGenerate()
392
456
  : await withSpinner('Generating sound effect…', doGenerate, { done: null });
393
457
  const filename = opts.output || 'sound.mp3';
394
- const savedPath = await downloadFile(result.url, filename);
458
+ const savedPath = await downloadFile(result.url, filename, !!opts.output);
395
459
  if (opts.json) {
396
460
  console.log(JSON.stringify({ ...result, saved: savedPath }));
397
461
  }
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { get, post, del, getBaseUrl, getAuthHeader } from '../api.js';
3
3
  import { requireConfig } from '../config.js';
4
4
  import { error as clrError, bold, muted, success, warning as warn } from '../colors.js';
5
- import { run, printList } from '../helpers/index.js';
5
+ import { run, printList, resolveBody, parseDuration } from '../helpers/index.js';
6
6
  export const jobCommand = new Command('job')
7
7
  .description('Run long jobs (CPU/GPU)')
8
8
  .addHelpText('after', '\nCPU sandbox or GPU (gpu-small=L4, gpu-medium=A10G, gpu-large=A100, gpu-huge=H100). Declared in the gipity.yaml jobs: phase; submit and poll via the subcommands below.');
@@ -23,14 +23,14 @@ jobCommand
23
23
  }));
24
24
  jobCommand
25
25
  .command('submit <name> [body]')
26
- .description('Submit a job (returns a run guid; poll with `job status <guid>`)')
27
- .option('--data <json>', 'JSON input body')
26
+ .description('Submit a job (returns a run guid; block on it with `job wait <guid>`)')
27
+ .option('-d, --data <json>', 'JSON input body: inline JSON, @file to read a file, or @- / - for stdin')
28
+ .option('--file <field=@path>', 'Attach a file as { data, media_type } under <field> (the vision/media service shape), repeatable, e.g. --file image=@scan.png', (v, acc) => (acc || []).concat(v))
28
29
  .option('--idempotency-key <key>', 'Idempotency key (replays return the existing run)')
29
30
  .option('--json', 'Output as JSON')
30
31
  .action((name, bodyArg, opts) => run('Submit', async () => {
31
32
  const config = requireConfig();
32
- const raw = bodyArg || opts.data || '{}';
33
- const input = JSON.parse(raw);
33
+ const input = resolveBody(bodyArg || opts.data, opts.file);
34
34
  const body = { input };
35
35
  if (opts.idempotencyKey)
36
36
  body.idempotency_key = opts.idempotencyKey;
@@ -41,6 +41,12 @@ jobCommand
41
41
  else {
42
42
  const tag = res.data.replayed ? warn(' (replayed)') : '';
43
43
  console.log(`${bold(res.data.run_guid)} ${muted(res.data.status)}${tag}`);
44
+ // Awaiting the run is the natural next step after every submit; point the
45
+ // agent at the blocking command so it doesn't hand-roll a status poll loop.
46
+ const TERMINAL = new Set(['success', 'failed', 'cancelled', 'canceled', 'error']);
47
+ if (!TERMINAL.has(res.data.status)) {
48
+ console.error(muted(`Block until it finishes: gipity job wait ${res.data.run_guid}`));
49
+ }
44
50
  }
45
51
  }));
46
52
  jobCommand
@@ -66,6 +72,71 @@ jobCommand
66
72
  if (r.output)
67
73
  console.log(`output: ${JSON.stringify(r.output)}`);
68
74
  }));
75
+ jobCommand
76
+ .command('wait <runGuid>')
77
+ .description('Block until a job run finishes, or exit at --timeout so the shell never hangs (poll again to keep waiting)')
78
+ .option('--timeout <seconds>', 'Max seconds to wait before giving up and reporting current progress. Bare number = seconds; an explicit unit (90s) means the same here and on `sandbox run --timeout`.', '90')
79
+ .option('--interval <seconds>', 'Seconds between polls', '3')
80
+ .option('--json', 'Output as JSON')
81
+ .action((runGuid, opts) => run('Wait', async () => {
82
+ const config = requireConfig();
83
+ // Terminal states end the wait; anything else means "still working, keep polling".
84
+ const TERMINAL = new Set(['success', 'failed', 'cancelled', 'canceled', 'error']);
85
+ const durOpt = parseDuration(opts.timeout, 's');
86
+ const timeoutSec = durOpt ? Math.max(1, Math.round(durOpt.value)) : parseInt(opts.timeout, 10);
87
+ const timeout = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 90;
88
+ const interval = Math.max(1, parseInt(opts.interval, 10) || 3);
89
+ const deadline = Date.now() + timeout * 1000;
90
+ const fetchRun = async () => (await get(`/projects/${config.projectGuid}/jobs/runs/${encodeURIComponent(runGuid)}`)).data;
91
+ let r = await fetchRun();
92
+ let lastProgress = '';
93
+ while (!TERMINAL.has(r.status) && Date.now() < deadline) {
94
+ // Surface progress changes to stderr so an agent watching sees liveness
95
+ // without the poll noise polluting the machine-readable stdout result.
96
+ if (!opts.json) {
97
+ const prog = r.progress_pct != null
98
+ ? `${Math.round(r.progress_pct * 100)}%${r.progress_message ? ` (${r.progress_message})` : ''}`
99
+ : r.status;
100
+ if (prog !== lastProgress) {
101
+ console.error(muted(`… ${prog}`));
102
+ lastProgress = prog;
103
+ }
104
+ }
105
+ const remaining = deadline - Date.now();
106
+ if (remaining <= 0)
107
+ break;
108
+ await new Promise(res => setTimeout(res, Math.min(interval * 1000, remaining)));
109
+ r = await fetchRun();
110
+ }
111
+ const done = TERMINAL.has(r.status);
112
+ if (opts.json) {
113
+ console.log(JSON.stringify(done ? r : { ...r, waiting: true, timed_out: true }));
114
+ }
115
+ else if (!done) {
116
+ // Not an error - the job is simply still going. Say so plainly and tell
117
+ // the agent exactly how to resume, then exit non-zero so a chained `&&`
118
+ // doesn't proceed as if the run had finished.
119
+ const prog = r.progress_pct != null ? ` ${Math.round(r.progress_pct * 100)}%` : '';
120
+ console.log(`${warn('still running')}${prog}${r.progress_message ? ` (${r.progress_message})` : ''} ${muted(runGuid)}`);
121
+ console.log(muted(`Not finished after ${timeout}s. Run \`gipity job wait ${runGuid}\` again to keep waiting.`));
122
+ }
123
+ else {
124
+ const statusColor = r.status === 'success' ? success : clrError;
125
+ console.log(`${statusColor(r.status)} ${muted(r.guid)}`);
126
+ if (r.duration_ms != null)
127
+ console.log(`duration: ${r.duration_ms}ms`);
128
+ if (r.error)
129
+ console.log(`${clrError('error:')} ${r.error}`);
130
+ if (r.output)
131
+ console.log(`output: ${JSON.stringify(r.output)}`);
132
+ }
133
+ // Exit codes: success → 0, failed/cancelled → 1, still-running-at-timeout → 2
134
+ // (distinct from a real failure so agents can tell "not yet" from "broke").
135
+ if (!done)
136
+ process.exit(2);
137
+ if (r.status !== 'success')
138
+ process.exit(1);
139
+ }));
69
140
  jobCommand
70
141
  .command('runs <name>')
71
142
  .description('List recent runs for a job')
@@ -87,6 +158,7 @@ jobCommand
87
158
  .description('Stream live logs for a job run (--no-follow for a one-shot snapshot)')
88
159
  .option('--follow', 'Stream via SSE (default)', true)
89
160
  .option('--no-follow', 'One-shot snapshot of run state instead of streaming')
161
+ .option('--timeout <seconds>', 'Stop streaming after N seconds and exit cleanly, so a bounded peek at the live log never hangs the shell (no shell `timeout` wrapper needed). Bare number = seconds; unit (20s) also accepted.')
90
162
  .option('--json', 'Output as JSON')
91
163
  .action((runGuid, opts) => run('Logs', async () => {
92
164
  const config = requireConfig();
@@ -95,6 +167,16 @@ jobCommand
95
167
  console.log(opts.json ? JSON.stringify(res.data) : JSON.stringify(res.data, null, 2));
96
168
  return;
97
169
  }
170
+ // Optional bounded peek: close the stream after --timeout seconds instead of
171
+ // holding open until the run finishes. Lets an agent glance at early stdout
172
+ // (e.g. catch an import error during cold start) without wrapping the command
173
+ // in a shell `timeout`, then poll again to keep watching.
174
+ let peekSeconds = 0;
175
+ if (opts.timeout != null) {
176
+ const dur = parseDuration(String(opts.timeout), 's');
177
+ const secs = dur ? dur.value : parseFloat(String(opts.timeout));
178
+ peekSeconds = Number.isFinite(secs) && secs > 0 ? secs : 0;
179
+ }
98
180
  // SSE stream. Manual fetch + line buffering - Node's EventSource isn't built-in,
99
181
  // and the SSE format is simple enough to parse inline (event: <name>\ndata: <json>\n\n).
100
182
  const url = `${getBaseUrl()}/projects/${config.projectGuid}/jobs/runs/${encodeURIComponent(runGuid)}/logs/stream`;
@@ -102,8 +184,28 @@ jobCommand
102
184
  const headers = { 'Accept': 'text/event-stream' };
103
185
  if (authHeader)
104
186
  headers.Authorization = authHeader;
105
- const res = await fetch(url, { headers });
187
+ // Abort the underlying request when the peek window elapses so the socket is
188
+ // torn down cleanly rather than left dangling.
189
+ const controller = new AbortController();
190
+ let peekTimer;
191
+ let peekedOut = false;
192
+ if (peekSeconds > 0) {
193
+ peekTimer = setTimeout(() => { peekedOut = true; controller.abort(); }, peekSeconds * 1000);
194
+ }
195
+ let res;
196
+ try {
197
+ res = await fetch(url, { headers, signal: controller.signal });
198
+ }
199
+ catch (e) {
200
+ if (peekedOut) {
201
+ console.error(muted(`… still streaming after ${peekSeconds}s. Run \`gipity job logs ${runGuid} --timeout ${Math.round(peekSeconds)}\` again to keep watching, or \`gipity job wait ${runGuid}\` to block until it finishes.`));
202
+ return;
203
+ }
204
+ throw e;
205
+ }
106
206
  if (!res.ok || !res.body) {
207
+ if (peekTimer)
208
+ clearTimeout(peekTimer);
107
209
  console.error(clrError(`Failed to open stream: HTTP ${res.status}`));
108
210
  process.exit(1);
109
211
  }
@@ -112,9 +214,24 @@ jobCommand
112
214
  let buffer = '';
113
215
  let currentEvent = '';
114
216
  while (true) {
115
- const { done, value } = await reader.read();
116
- if (done)
217
+ let chunk;
218
+ try {
219
+ chunk = await reader.read();
220
+ }
221
+ catch (e) {
222
+ // Peek window elapsed mid-stream: report cleanly and exit 0 (not an error).
223
+ if (peekedOut) {
224
+ console.error(muted(`… still streaming after ${peekSeconds}s. Run \`gipity job logs ${runGuid} --timeout ${Math.round(peekSeconds)}\` again to keep watching, or \`gipity job wait ${runGuid}\` to block until it finishes.`));
225
+ return;
226
+ }
227
+ throw e;
228
+ }
229
+ const { done, value } = chunk;
230
+ if (done) {
231
+ if (peekTimer)
232
+ clearTimeout(peekTimer);
117
233
  break;
234
+ }
118
235
  buffer += decoder.decode(value, { stream: true });
119
236
  let nl;
120
237
  while ((nl = buffer.indexOf('\n')) >= 0) {