gipity 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/adopt-cwd.js +1 -1
  2. package/dist/banner.js +3 -5
  3. package/dist/catalog.js +1 -3
  4. package/dist/commands/add.js +1 -1
  5. package/dist/commands/build.js +111 -40
  6. package/dist/commands/chat.js +1 -1
  7. package/dist/commands/deploy.js +7 -3
  8. package/dist/commands/file.js +18 -0
  9. package/dist/commands/fn.js +6 -6
  10. package/dist/commands/generate.js +80 -16
  11. package/dist/commands/gmail.js +1 -1
  12. package/dist/commands/job.js +132 -10
  13. package/dist/commands/load.js +2 -2
  14. package/dist/commands/login.js +7 -2
  15. package/dist/commands/page-eval.js +82 -13
  16. package/dist/commands/page-fetch.js +14 -10
  17. package/dist/commands/page-inspect.js +28 -3
  18. package/dist/commands/page-screenshot.js +36 -7
  19. package/dist/commands/page-test.js +1 -1
  20. package/dist/commands/records.js +14 -1
  21. package/dist/commands/remove.js +1 -1
  22. package/dist/commands/sandbox.js +182 -10
  23. package/dist/commands/save.js +1 -1
  24. package/dist/commands/secrets.js +1 -1
  25. package/dist/commands/service.js +7 -2
  26. package/dist/commands/setup.js +4 -6
  27. package/dist/commands/status.js +41 -12
  28. package/dist/commands/storage.js +2 -2
  29. package/dist/commands/sync.js +15 -0
  30. package/dist/commands/test.js +33 -1
  31. package/dist/commands/update.js +4 -0
  32. package/dist/commands/upload.js +69 -121
  33. package/dist/commands/workflow.js +1 -1
  34. package/dist/helpers/body.js +117 -0
  35. package/dist/helpers/duration.js +34 -0
  36. package/dist/helpers/index.js +2 -0
  37. package/dist/index.js +1173 -620
  38. package/dist/knowledge.js +7 -7
  39. package/dist/login-flow.js +44 -2
  40. package/dist/relay/onboarding.js +26 -44
  41. package/dist/sync.js +6 -6
  42. package/dist/updater/bootstrap.js +27 -16
  43. package/dist/updater/check.js +86 -10
  44. package/dist/updater/install.js +82 -0
  45. package/dist/updater/shim.js +90 -27
  46. package/dist/utils.js +60 -3
  47. package/package.json +2 -2
package/dist/adopt-cwd.js CHANGED
@@ -167,7 +167,7 @@ export function formatCwdLabel(cwd) {
167
167
  const tail = norm.slice(home.length + 1).split(sep);
168
168
  if (tail.length <= 2)
169
169
  return '~/' + tail.join('/');
170
- return '~/…/' + tail.slice(-2).join('/');
170
+ return '~/.../' + tail.slice(-2).join('/');
171
171
  }
172
172
  // Outside home: show parent/this for non-root paths.
173
173
  const parts = norm.split(sep).filter(Boolean);
package/dist/banner.js CHANGED
@@ -115,15 +115,13 @@ function padR(s, width) {
115
115
  const gap = width - visLen(s);
116
116
  return gap > 0 ? s + ' '.repeat(gap) : s;
117
117
  }
118
- /** Truncate from the left with a leading ellipsis if longer than maxW (plain text). */
118
+ /** Truncate from the left with a leading "..." if longer than maxW (plain text). */
119
119
  function leadingEllipsify(s, maxW) {
120
120
  if (s.length <= maxW)
121
121
  return s;
122
- if (maxW <= 1)
122
+ if (maxW <= 4)
123
123
  return s.slice(-maxW);
124
- if (maxW <= 3)
125
- return '…'.padStart(maxW);
126
- return '…' + s.slice(-(maxW - 1));
124
+ return '...' + s.slice(-(maxW - 3));
127
125
  }
128
126
  function center(s, width) {
129
127
  const gap = width - visLen(s);
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
@@ -207,7 +207,7 @@ export const addCommand = new Command('add')
207
207
  const doAdd = () => post(`/projects/${config.projectGuid}/add`, body);
208
208
  const res = opts.json
209
209
  ? await doAdd()
210
- : await withSpinner('Installing', doAdd, { done: null });
210
+ : await withSpinner('Installing...', doAdd, { done: null });
211
211
  // Pull the created/installed files down to local.
212
212
  const syncResult = await sync({ interactive: false, progress: opts.json ? undefined : createProgressReporter() });
213
213
  const data = res.data;
@@ -50,7 +50,7 @@ const __clPkg = readOwnPkg(__clDir);
50
50
  * incomplete pull, so we can tell the user plainly that nothing was deleted. */
51
51
  function reportSyncResult(result) {
52
52
  if (result.aborted) {
53
- console.log(` ${warning('Merge cancelled this folder was NOT synced with the project.')}`);
53
+ console.log(` ${warning('Merge cancelled - this folder was NOT synced with the project.')}`);
54
54
  console.log(` ${muted('For a clean copy, quit and open the project in an empty folder. To merge anyway, run `gipity sync`.')}`);
55
55
  return;
56
56
  }
@@ -58,11 +58,11 @@ function reportSyncResult(result) {
58
58
  console.log(` Synced ${result.applied} change${result.applied > 1 ? 's' : ''} with Gipity.`);
59
59
  }
60
60
  if (result.errors.length) {
61
- console.log(` ${warning(`Sync finished with ${result.errors.length} problem${result.errors.length === 1 ? '' : 's'} your local copy may be incomplete:`)}`);
61
+ console.log(` ${warning(`Sync finished with ${result.errors.length} problem${result.errors.length === 1 ? '' : 's'} - your local copy may be incomplete:`)}`);
62
62
  for (const e of result.errors.slice(0, 8))
63
63
  console.log(` - ${e}`);
64
64
  if (result.errors.length > 8)
65
- console.log(` and ${result.errors.length - 8} more.`);
65
+ console.log(` ...and ${result.errors.length - 8} more.`);
66
66
  console.log(` ${muted('Nothing was deleted. Re-run `gipity sync` to finish the pull.')}`);
67
67
  }
68
68
  }
@@ -133,7 +133,7 @@ function localFsFallback(dir) {
133
133
  };
134
134
  walk(dir, 0);
135
135
  const topLevel = topLevelEntries.length
136
- ? topLevelEntries.slice(0, 20).join(', ') + (topLevelEntries.length > 20 ? ', ' : '')
136
+ ? topLevelEntries.slice(0, 20).join(', ') + (topLevelEntries.length > 20 ? ', ...' : '')
137
137
  : '(empty directory)';
138
138
  return { fileCount, folderCount, totalBytes, topLevel };
139
139
  }
@@ -502,6 +502,8 @@ async function runLaunch(cmdName, cmdCfg, opts) {
502
502
  console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
503
503
  }
504
504
  setupProjectTools();
505
+ if (!nonInteractive)
506
+ console.log(` ${muted('Installed Gipity plugin, skills, and hooks.')}`);
505
507
  if (nonInteractive) {
506
508
  // Headless: the -p message is wrapped later (Step 3). Just record
507
509
  // whether to use the new-project framing for that wrap.
@@ -673,6 +675,8 @@ async function runLaunch(cmdName, cmdCfg, opts) {
673
675
  // Claude directly what they want to build or do.
674
676
  initialPrompt = '';
675
677
  setupProjectTools();
678
+ if (!nonInteractive)
679
+ console.log(` ${muted('Installed Gipity plugin, skills, and hooks.')}`);
676
680
  console.log(` ${success(`Project "${project.name}" ready.`)}\n`);
677
681
  }
678
682
  // ── Step 3: Pick the coding agent + model ─────────────────────────
@@ -812,9 +816,8 @@ async function runLaunch(cmdName, cmdCfg, opts) {
812
816
  }
813
817
  if (!nonInteractive) {
814
818
  console.log(` ${bold('Launching Claude Code, powered by Gipity.')}`);
815
- console.log(` ${muted("Just tell Claude what you'd like to build or do - everything Claude can do,")}`);
816
- console.log(` ${muted('plus hosting, databases, and live deploys on Gipity.')}`);
817
819
  if (convGuidForHooks) {
820
+ console.log('');
818
821
  console.log(` ${muted(`This session is saved to Gipity - watch it live at ${brand('prompt.gipity.ai')}. (--no-capture on init to disable.)`)}`);
819
822
  }
820
823
  console.log('');
@@ -960,12 +963,11 @@ async function pickAgent(lastUsed) {
960
963
  const defaultIdx = AGENT_ADAPTERS.findIndex(a => a.key === defaultKey) + 1;
961
964
  console.log(` ${bold('Which coding agent?')}\n`);
962
965
  AGENT_ADAPTERS.forEach((a, i) => {
963
- const installed = binaryOnPath(a.binary) || (a.key === 'claude' && isClaudeInstalled());
966
+ // Keep the list clean: no install-state notes (picking an uninstalled
967
+ // agent installs it, or fails with the install hint at that point).
964
968
  const notes = [];
965
969
  if (a.key === lastUsed)
966
970
  notes.push('last used');
967
- if (!installed)
968
- notes.push(a.ensureInstalled ? "not installed - we'll install it" : 'not installed');
969
971
  if (a.key === 'codex' && !a.hooksSupportedOnPlatform(process.platform)) {
970
972
  notes.push('session recording unavailable on Windows');
971
973
  }
@@ -1101,9 +1103,8 @@ async function launchNonClaudeAgent(adapter, ctx) {
1101
1103
  else {
1102
1104
  agentArgs = [...adapter.buildInteractiveArgs({ resume, model }), ...extras];
1103
1105
  console.log(` ${bold(`Launching ${adapter.displayName}, powered by Gipity.`)}`);
1104
- console.log(` ${muted(`Just tell ${adapter.displayName} what you'd like to build or do - plus hosting,`)}`);
1105
- console.log(` ${muted('databases, and live deploys on Gipity.')}`);
1106
1106
  if (convGuid) {
1107
+ console.log('');
1107
1108
  console.log(` ${muted(`This session is saved to Gipity - watch it live at ${brand('prompt.gipity.ai')}. (--no-capture on init to disable.)`)}`);
1108
1109
  }
1109
1110
  if (adapter.oneTimeSetupNote) {
@@ -1171,37 +1172,47 @@ async function pickOrCreateProject(projects, existingSlugs) {
1171
1172
  const showAdopt = canAdoptCwd(cwd);
1172
1173
  // Reserve slot 1 for "create new", slot 2 for "use this dir" when shown.
1173
1174
  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;
1175
+ // Pickable single-keypress range is 1-9. When every project fits, list
1176
+ // them all; otherwise the actions grow a "Search projects" slot right
1177
+ // after the reserved ones and "Show more" takes slot 9 - accounts with
1178
+ // hundreds of projects can't scroll a dump. Action rows render in a
1179
+ // different color than project rows so the two groups read apart.
1180
+ const fitsAll = filtered.length <= 9 - reserved;
1181
+ const hasMore = !fitsAll;
1182
+ const searchSlot = hasMore ? reserved + 1 : 0;
1183
+ const projectBase = reserved + (hasMore ? 1 : 0); // slots projectBase+1.. are projects
1184
+ const recent = fitsAll ? filtered : filtered.slice(0, 9 - reserved - 2);
1185
+ const moreSlot = hasMore ? projectBase + recent.length + 1 : 0; // always 9 when shown
1179
1186
  // Loop so that a refused/declined adopt-cwd re-shows the picker rather
1180
1187
  // than dropping the user into a shell.
1181
1188
  while (true) {
1182
1189
  const newProjectLabel = formatNewProjectLabel(existingSlugs);
1183
1190
  console.log(` ${bold('Choose project to open:')}\n`);
1184
- console.log(` ${bold('1.')} Create new project ${muted(`(${newProjectLabel})`)}`);
1191
+ console.log(` ${bold('1.')} ${info('Create new project')} ${muted(`(${newProjectLabel})`)}`);
1185
1192
  if (showAdopt) {
1186
- console.log(` ${bold('2.')} Use this directory ${muted(`(${formatCwdLabel(cwd)})`)}`);
1193
+ console.log(` ${bold('2.')} ${info('Use this directory')} ${muted(`(${formatCwdLabel(cwd)})`)}`);
1187
1194
  }
1188
- recent.forEach((p, i) => console.log(` ${bold(`${i + reserved + 1}.`)} ${p.name} ${muted(`(${p.slug})`)}`));
1189
1195
  if (hasMore) {
1190
- console.log(` ${bold(`${recent.length + reserved + 1}.`)} Show all projects`);
1196
+ console.log(` ${bold(`${searchSlot}.`)} ${info('Search projects')} ${muted(`(${filtered.length} total)`)}`);
1197
+ }
1198
+ recent.forEach((p, i) => console.log(` ${bold(`${projectBase + i + 1}.`)} ${p.name} ${muted(`(${p.slug})`)}`));
1199
+ if (hasMore) {
1200
+ console.log(` ${bold(`${moreSlot}.`)} ${info('Show more')}`);
1191
1201
  }
1192
1202
  console.log('');
1193
- const maxOption = recent.length + reserved + (hasMore ? 1 : 0);
1203
+ const maxOption = projectBase + recent.length + (hasMore ? 1 : 0);
1194
1204
  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] };
1205
+ // Recent project (slots projectBase+1 .. projectBase+recent.length).
1206
+ if (idx > projectBase && idx <= projectBase + recent.length) {
1207
+ return { kind: 'pick', project: recent[idx - projectBase - 1] };
1198
1208
  }
1199
- // Show all projects (one past the last recent slot).
1200
- if (hasMore && idx === recent.length + reserved + 1) {
1201
- const picked = await pickFromAll(filtered);
1209
+ // Search (right after the reserved slots) / Show more (slot 9).
1210
+ if (hasMore && (idx === searchSlot || idx === moreSlot)) {
1211
+ const picked = await browseProjects(filtered, { search: idx === searchSlot });
1202
1212
  if (picked)
1203
1213
  return { kind: 'pick', project: picked };
1204
- continue; // user bailed; re-show top picker
1214
+ console.log('');
1215
+ continue; // user backed out; re-show top picker
1205
1216
  }
1206
1217
  // Slot 2 = "Use this directory" (only when shown).
1207
1218
  if (showAdopt && idx === 2) {
@@ -1217,19 +1228,79 @@ async function pickOrCreateProject(projects, existingSlugs) {
1217
1228
  return { kind: 'create-new', project };
1218
1229
  }
1219
1230
  }
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})`)}`));
1231
+ const PROJECT_PAGE_SIZE = 10;
1232
+ /** Case-insensitive substring match on project name or slug. An empty or
1233
+ * whitespace-only query matches everything. Exported for tests. */
1234
+ export function searchProjects(all, query) {
1235
+ const q = query.trim().toLowerCase();
1236
+ if (!q)
1237
+ return all;
1238
+ return all.filter(p => p.name.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q));
1239
+ }
1240
+ /** Paged, searchable project list - the shared renderer behind both the
1241
+ * "Search projects" and "Show more" picker slots. Shows PROJECT_PAGE_SIZE
1242
+ * rows at a time with numbering that stays stable across pages, then reads
1243
+ * a full line (not single-keypress - numbers can exceed 9):
1244
+ * Enter → next page · <number> → open that project
1245
+ * s → new search (always over the FULL list, never the current subset)
1246
+ * q → back to the main picker (returns null).
1247
+ * `opts.search` starts by prompting for a query instead of listing all.
1248
+ * Exported for tests. */
1249
+ export async function browseProjects(all, opts = {}) {
1250
+ let items = all;
1251
+ let heading = 'All projects';
1252
+ // Prompt for a query and swap the working list to the matches. Returns
1253
+ // false (list unchanged) on empty input or zero matches.
1254
+ const runSearch = async () => {
1255
+ const q = await prompt(` ${bold('Search projects:')} `);
1256
+ if (!q)
1257
+ return false;
1258
+ const matches = searchProjects(all, q);
1259
+ if (matches.length === 0) {
1260
+ console.log(` ${muted(`No projects match "${q}".`)}`);
1261
+ return false;
1262
+ }
1263
+ items = matches;
1264
+ heading = `Projects matching "${q}"`;
1265
+ return true;
1266
+ };
1227
1267
  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;
1268
+ if (opts.search && !(await runSearch()))
1269
+ return null;
1270
+ let shown = 0;
1271
+ while (true) {
1272
+ if (shown < items.length) {
1273
+ const page = items.slice(shown, shown + PROJECT_PAGE_SIZE);
1274
+ if (shown === 0)
1275
+ console.log(` ${bold(`${heading}`)} ${muted(`(${items.length})`)}\n`);
1276
+ page.forEach((p, i) => console.log(` ${bold(`${String(shown + i + 1).padStart(3)}.`)} ${p.name} ${muted(`(${p.slug})`)}`));
1277
+ shown += page.length;
1278
+ console.log('');
1279
+ }
1280
+ const more = shown < items.length;
1281
+ const hints = [
1282
+ more ? `Enter = next ${Math.min(PROJECT_PAGE_SIZE, items.length - shown)}` : null,
1283
+ 'number = open',
1284
+ 's = search',
1285
+ 'q = back',
1286
+ ].filter(Boolean).join(' · ');
1287
+ const answer = (await prompt(` ${bold('Choose')} ${muted(`(${hints})`)}: `)).toLowerCase();
1288
+ if (answer === '' && more)
1289
+ continue; // Enter → next page
1290
+ if (answer === '' || answer === 'q')
1291
+ return null;
1292
+ if (answer === 's') {
1293
+ if (await runSearch()) {
1294
+ shown = 0;
1295
+ console.log('');
1296
+ }
1297
+ continue;
1298
+ }
1299
+ const n = parseInt(answer, 10);
1300
+ if (Number.isInteger(n) && n >= 1 && n <= shown)
1301
+ return items[n - 1];
1302
+ console.log(` ${muted('Type one of the numbers above, Enter for more, s to search, or q to go back.')}`);
1303
+ }
1233
1304
  }
1234
1305
  /** Show "(~/GipityProjects/project-NNN)" - the exact dir option 1 will
1235
1306
  * create, so the user can see where their new project will land. */
@@ -25,7 +25,7 @@ export const chatCommand = new Command('chat')
25
25
  const doChat = () => post(endpoint, body);
26
26
  const res = opts.json
27
27
  ? await doChat()
28
- : await withSpinner('Thinking', doChat, { done: null });
28
+ : await withSpinner('Thinking...', doChat, { done: null });
29
29
  // Save conversation guid for continuity. Skipped in one-off mode: the
30
30
  // config was resolved from the server's Home project and there is no
31
31
  // local `.gipity.json` to update - persisting here would create one in
@@ -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';
@@ -57,7 +57,7 @@ export const deployCommand = new Command('deploy')
57
57
  });
58
58
  const res = opts.json
59
59
  ? await doDeploy()
60
- : await withSpinner(`Deploying to ${target}…`, doDeploy, { done: null });
60
+ : await withSpinner(`Deploying to ${target}...`, doDeploy, { done: null });
61
61
  const d = res.data;
62
62
  // Deploy + verify in one command: after a successful deploy, run the
63
63
  // same inspect pipeline `page inspect` uses against the live URL. An
@@ -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
  }
@@ -221,9 +285,9 @@ Examples:
221
285
  // Veo runs 30-120s; the bouncing bar + timer keeps the wait honest.
222
286
  const result = opts.json
223
287
  ? await doGenerate()
224
- : await withSpinner('Generating video', doGenerate, { done: null });
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
  }
@@ -285,9 +349,9 @@ Examples:
285
349
  });
286
350
  const result = opts.json
287
351
  ? await doGenerate()
288
- : await withSpinner('Generating speech', doGenerate, { done: null });
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
  }
@@ -338,9 +402,9 @@ Examples:
338
402
  });
339
403
  const result = opts.json
340
404
  ? await doGenerate()
341
- : await withSpinner('Generating music', doGenerate, { done: null });
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
  }
@@ -389,9 +453,9 @@ Examples:
389
453
  });
390
454
  const result = opts.json
391
455
  ? await doGenerate()
392
- : await withSpinner('Generating sound effect', doGenerate, { done: null });
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
  }
@@ -61,7 +61,7 @@ gmailCommand
61
61
  .option('--cc <email>', 'Cc recipient (repeatable)', collect, [])
62
62
  .option('--bcc <email>', 'Bcc recipient (repeatable)', collect, [])
63
63
  .option('--reply-to <email>', 'Reply-To header address')
64
- .requiredOption('--subject <s>', 'Subject (usually "Re: " of the original)')
64
+ .requiredOption('--subject <s>', 'Subject (usually "Re: ..." of the original)')
65
65
  .requiredOption('--body <text>', 'Reply body (you compose the quote header)')
66
66
  .option('--json', 'Output as JSON')
67
67
  .action((opts) => run('Reply', async () => {