gipity 1.0.397 → 1.0.399

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/adopt-cwd.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * `.gipity.json`, sync, and install hooks/skills/gitignore.
12
12
  */
13
13
  import { readdirSync, statSync } from 'fs';
14
- import { join, resolve, sep } from 'path';
14
+ import { join, resolve, sep, parse as parsePath } from 'path';
15
15
  import { homedir } from 'os';
16
16
  import { get, post, ApiError } from './api.js';
17
17
  import { isSyncIgnored } from './setup.js';
@@ -103,6 +103,15 @@ export function canAdoptCwd(cwd) {
103
103
  '/tmp', '/var', '/etc', '/usr', '/opt',
104
104
  '/mnt', '/mnt/c', '/mnt/d', '/mnt/wsl',
105
105
  ]);
106
+ // Filesystem root (POSIX `/` already covered by `sep`, but on Windows this
107
+ // catches drive roots like `C:\`). Plus the common Windows system dirs.
108
+ if (norm === parsePath(norm).root)
109
+ return false;
110
+ for (const envVar of ['windir', 'SystemRoot', 'ProgramFiles', 'ProgramFiles(x86)']) {
111
+ const v = process.env[envVar];
112
+ if (v && norm === resolve(v))
113
+ return false;
114
+ }
106
115
  if (blocked.has(norm))
107
116
  return false;
108
117
  // Workspace-parent heuristic: a directory at depth ≤1 below $HOME that
package/dist/api.js CHANGED
@@ -2,6 +2,7 @@ import { Readable } from 'stream';
2
2
  import * as tar from 'tar-stream';
3
3
  import { getAuth, refreshTokenIfNeeded } from './auth.js';
4
4
  import { resolveApiBase, requireConfig, saveConfig } from './config.js';
5
+ import { clientHeaders } from './client-context.js';
5
6
  export class ApiError extends Error {
6
7
  statusCode;
7
8
  code;
@@ -32,6 +33,7 @@ async function bearerToken() {
32
33
  }
33
34
  async function getHeaders() {
34
35
  return {
36
+ ...clientHeaders(),
35
37
  'Authorization': `Bearer ${await bearerToken()}`,
36
38
  'Content-Type': 'application/json',
37
39
  };
@@ -137,7 +139,7 @@ export async function sendMessage(message) {
137
139
  export async function download(path) {
138
140
  const url = `${baseUrl()}${path}`;
139
141
  const res = await fetch(url, {
140
- headers: { 'Authorization': `Bearer ${await bearerToken()}` },
142
+ headers: { ...clientHeaders(), 'Authorization': `Bearer ${await bearerToken()}` },
141
143
  });
142
144
  if (!res.ok) {
143
145
  throw new ApiError(res.status, 'DOWNLOAD_ERROR', `Download failed: ${res.statusText}`);
@@ -149,7 +151,7 @@ export async function downloadStream(path) {
149
151
  const { Readable } = await import('stream');
150
152
  const url = `${baseUrl()}${path}`;
151
153
  const res = await fetch(url, {
152
- headers: { 'Authorization': `Bearer ${await bearerToken()}` },
154
+ headers: { ...clientHeaders(), 'Authorization': `Bearer ${await bearerToken()}` },
153
155
  });
154
156
  if (!res.ok) {
155
157
  throw new ApiError(res.status, 'DOWNLOAD_ERROR', `Download failed: ${res.statusText}`);
@@ -199,7 +201,7 @@ export async function publicPost(path, body) {
199
201
  const url = `${baseUrl()}${path}`;
200
202
  const res = await fetch(url, {
201
203
  method: 'POST',
202
- headers: { 'Content-Type': 'application/json' },
204
+ headers: { ...clientHeaders(), 'Content-Type': 'application/json' },
203
205
  body: JSON.stringify(body),
204
206
  });
205
207
  if (!res.ok) {
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from 'fs';
2
+ import { resolve, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ function cliVersion() {
5
+ try {
6
+ const dir = dirname(fileURLToPath(import.meta.url));
7
+ const pkg = JSON.parse(readFileSync(resolve(dir, '../package.json'), 'utf-8'));
8
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
9
+ }
10
+ catch {
11
+ return 'unknown';
12
+ }
13
+ }
14
+ function hasEnvPrefix(prefix) {
15
+ return Object.keys(process.env).some((k) => k.startsWith(prefix));
16
+ }
17
+ function isCi() {
18
+ const ci = (process.env.CI ?? '').toLowerCase();
19
+ return ci === '1' || ci === 'true' || !!process.env.GITHUB_ACTIONS;
20
+ }
21
+ /** Identify the coding harness from its env footprint. Order = specificity. Exported for tests. */
22
+ export function detectHarness() {
23
+ // Claude Code is the most reliable: CLAUDECODE=1 plus a versioned exec path.
24
+ if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_ENTRYPOINT) {
25
+ const version = process.env.CLAUDE_CODE_EXECPATH?.match(/versions\/([^/\\]+)/)?.[1];
26
+ return { harness: 'claude-code', harnessVersion: version, harnessSession: process.env.CLAUDE_CODE_SESSION_ID };
27
+ }
28
+ if (hasEnvPrefix('CODEX_'))
29
+ return { harness: 'codex' };
30
+ if (process.env.CURSOR_TRACE_ID || hasEnvPrefix('CURSOR_') || (process.env.TERM_PROGRAM ?? '').toLowerCase().includes('cursor')) {
31
+ return { harness: 'cursor' };
32
+ }
33
+ if (hasEnvPrefix('AIDER_'))
34
+ return { harness: 'aider' };
35
+ if (process.env.GEMINI_CLI)
36
+ return { harness: 'gemini' };
37
+ if (isCi())
38
+ return { harness: 'ci' };
39
+ return { harness: 'manual' };
40
+ }
41
+ let cached;
42
+ export function clientContext() {
43
+ if (cached)
44
+ return cached;
45
+ cached = {
46
+ cli: cliVersion(),
47
+ ...detectHarness(),
48
+ node: process.versions.node,
49
+ os: process.platform,
50
+ arch: process.arch,
51
+ ci: isCi() || undefined,
52
+ };
53
+ return cached;
54
+ }
55
+ /** Headers to merge into every API request so the server can attribute the call. */
56
+ export function clientHeaders() {
57
+ const ctx = clientContext();
58
+ return {
59
+ 'User-Agent': `gipity-cli/${ctx.cli} (${ctx.os}; ${ctx.arch}; harness=${ctx.harness})`,
60
+ 'X-Gipity-Client': JSON.stringify(ctx),
61
+ };
62
+ }
63
+ //# sourceMappingURL=client-context.js.map
@@ -7,6 +7,7 @@ import { requireConfig } from '../config.js';
7
7
  import { sync } from '../sync.js';
8
8
  import { success, muted, bold } from '../colors.js';
9
9
  import { run } from '../helpers/index.js';
10
+ import { createProgressReporter, withSpinner } from '../progress.js';
10
11
  const STARTERS = [
11
12
  { key: 'web-vision-cam', hint: 'fullscreen camera app with on-device vision (MediaPipe)' },
12
13
  { key: 'object-spotter', hint: 'camera app that boxes, labels, and counts objects (YOLOX on-device)' },
@@ -198,9 +199,14 @@ export const addCommand = new Command('add')
198
199
  force: opts.force,
199
200
  };
200
201
  }
201
- const res = await post(`/projects/${config.projectGuid}/add`, body);
202
+ // The server runs the whole install pipeline before responding; animate the
203
+ // wait, then clear the spinner so the installed-files list is the result.
204
+ const doAdd = () => post(`/projects/${config.projectGuid}/add`, body);
205
+ const res = opts.json
206
+ ? await doAdd()
207
+ : await withSpinner('Installing…', doAdd, { done: null });
202
208
  // Pull the created/installed files down to local.
203
- const syncResult = await sync({ interactive: false });
209
+ const syncResult = await sync({ interactive: false, progress: opts.json ? undefined : createProgressReporter() });
204
210
  const data = res.data;
205
211
  if (opts.json) {
206
212
  console.log(JSON.stringify({ ...data, synced: syncResult.applied }));
@@ -4,6 +4,7 @@ import { resolveProjectContext, saveConfig } from '../config.js';
4
4
  import { sync } from '../sync.js';
5
5
  import { error as clrError, muted, success } from '../colors.js';
6
6
  import { run, printList, printResult } from '../helpers/index.js';
7
+ import { createProgressReporter, withSpinner } from '../progress.js';
7
8
  export const chatCommand = new Command('chat')
8
9
  .description('Send a message to your agent')
9
10
  .argument('<message>', 'Message to send')
@@ -19,7 +20,12 @@ export const chatCommand = new Command('chat')
19
20
  const body = useExisting
20
21
  ? { content: message, projectGuid: config.projectGuid }
21
22
  : { agentGuid: config.agentGuid, content: message, projectGuid: config.projectGuid };
22
- const res = await post(endpoint, body);
23
+ // The agent can think for many seconds; animate the wait, then clear the
24
+ // spinner (done:null) so the reply itself is the result. JSON mode skips it.
25
+ const doChat = () => post(endpoint, body);
26
+ const res = opts.json
27
+ ? await doChat()
28
+ : await withSpinner('Thinking…', doChat, { done: null });
23
29
  // Save conversation guid for continuity. Skipped in one-off mode: the
24
30
  // config was resolved from the server's Home project and there is no
25
31
  // local `.gipity.json` to update - persisting here would create one in
@@ -31,7 +37,10 @@ export const chatCommand = new Command('chat')
31
37
  let syncSummary = '';
32
38
  let syncChanges = [];
33
39
  if (res.data.filesChanged) {
34
- const syncResult = await sync({ interactive: false });
40
+ const syncResult = await sync({
41
+ interactive: false,
42
+ progress: opts.json ? undefined : createProgressReporter(),
43
+ });
35
44
  if (syncResult.applied > 0) {
36
45
  syncSummary = `\nSynced ${syncResult.applied} change${syncResult.applied > 1 ? 's' : ''}:\n${syncResult.summary}`;
37
46
  }
@@ -4,20 +4,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdir
4
4
  import { execSync, spawn } from 'child_process';
5
5
  import { homedir } from 'os';
6
6
  import { fileURLToPath } from 'url';
7
- /** On Windows, spawn without shell:true needs an explicit extension (.exe or .cmd) */
8
- function resolveCommand(cmd) {
9
- if (process.platform !== 'win32')
10
- return cmd;
11
- try {
12
- const lines = execSync(`where ${cmd}`, { encoding: 'utf-8' }).split(/\r?\n/).map(l => l.trim()).filter(Boolean);
13
- // Prefer .exe (native) over .cmd (npm shim)
14
- return lines.find(l => l.endsWith('.exe')) || lines.find(l => l.endsWith('.cmd')) || cmd;
15
- }
16
- catch {
17
- return `${cmd}.cmd`;
18
- }
19
- }
20
- import { getAuth, saveAuth } from '../auth.js';
7
+ import { resolveCommand } from '../platform.js';
8
+ import { getAuth, saveAuth, sessionExpired } from '../auth.js';
21
9
  import { get, post, publicPost, ApiError, getAccountSlug } from '../api.js';
22
10
  import { getConfig, saveConfigAt, clearConfigCache, getApiBaseOverride, DEFAULT_API_BASE, getConfigPath } from '../config.js';
23
11
  import { sync } from '../sync.js';
@@ -26,12 +14,35 @@ import { buildProjectContextBlock as buildProjectContextBlockText, buildNewProje
26
14
  import * as relayState from '../relay/state.js';
27
15
  import { maybeOfferRelayOn, ensureDaemonRunning } from '../relay/onboarding.js';
28
16
  import { prompt, pickOne, decodeJwtExp, confirm } from '../utils.js';
29
- import { brand, bold, info, success, error as clrError, muted } from '../colors.js';
17
+ import { brand, bold, info, success, warning, error as clrError, muted } from '../colors.js';
30
18
  import { createProgressReporter } from '../progress.js';
31
19
  import { printBanner } from '../banner.js';
32
20
  import { scanForAdoption, isLikelyEmpty, canAdoptCwd, formatCwdLabel, formatBytes, adoptCurrentDir, ADOPT_THRESHOLDS, } from '../adopt-cwd.js';
33
21
  const __clDir = dirname(fileURLToPath(import.meta.url));
34
22
  const __clPkg = JSON.parse(readFileSync(resolve(__clDir, '../../package.json'), 'utf-8'));
23
+ /** Report a sync run to the user. Beyond the applied-changes line, this SURFACES
24
+ * sync.errors - a download that came back incomplete (truncated bulk tar, a file
25
+ * that couldn't be refetched) lands here, so we never print "ready" over a
26
+ * half-synced project as if nothing went wrong. Deletions are disarmed on an
27
+ * incomplete pull, so we can tell the user plainly that nothing was deleted. */
28
+ function reportSyncResult(result) {
29
+ if (result.aborted) {
30
+ console.log(` ${warning('Merge cancelled — this folder was NOT synced with the project.')}`);
31
+ console.log(` ${muted('For a clean copy, quit and open the project in an empty folder. To merge anyway, run `gipity sync`.')}`);
32
+ return;
33
+ }
34
+ if (result.applied > 0) {
35
+ console.log(` Synced ${result.applied} change${result.applied > 1 ? 's' : ''} with Gipity.`);
36
+ }
37
+ if (result.errors.length) {
38
+ console.log(` ${warning(`Sync finished with ${result.errors.length} problem${result.errors.length === 1 ? '' : 's'} — your local copy may be incomplete:`)}`);
39
+ for (const e of result.errors.slice(0, 8))
40
+ console.log(` - ${e}`);
41
+ if (result.errors.length > 8)
42
+ console.log(` …and ${result.errors.length - 8} more.`);
43
+ console.log(` ${muted('Nothing was deleted. Re-run `gipity sync` to finish the pull.')}`);
44
+ }
45
+ }
35
46
  import { getProjectsRoot } from '../relay/paths.js';
36
47
  /** Ask the server for recursive VFS counts. Server owns the file metadata
37
48
  * (counts, bytes, paths), so a single aggregate query beats walking the
@@ -280,7 +291,15 @@ export const claudeCommand = new Command('claude')
280
291
  if (!nonInteractive) {
281
292
  printBanner({ version: __clPkg.version, email: auth?.email, cwd: process.cwd() });
282
293
  }
283
- if (auth) {
294
+ if (auth && sessionExpired()) {
295
+ // The cached auth.json exists but its refresh token has lapsed, so the
296
+ // first API call below would 401 anyway. Re-login up front instead of
297
+ // printing "Logged in" and immediately contradicting it with "session
298
+ // expired" once the projects fetch fails.
299
+ console.log(` ${muted('Your session expired. Let\'s sign you back in.')}\n`);
300
+ auth = await interactiveLogin();
301
+ }
302
+ else if (auth) {
284
303
  console.log(` Logged in (${auth.email}).`);
285
304
  }
286
305
  else {
@@ -395,13 +414,11 @@ export const claudeCommand = new Command('claude')
395
414
  });
396
415
  console.log(`\n Using ${projectDir}`);
397
416
  try {
398
- const result = await sync({ interactive: !nonInteractive, progress: syncProgress });
399
- if (result.applied > 0) {
400
- console.log(` Synced ${result.applied} change${result.applied > 1 ? 's' : ''} with Gipity.`);
401
- }
417
+ const result = await sync({ interactive: !nonInteractive, confirmMerge: !nonInteractive, progress: syncProgress });
418
+ reportSyncResult(result);
402
419
  }
403
- catch {
404
- console.log(' Could not sync files (will retry on next prompt).');
420
+ catch (err) {
421
+ console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
405
422
  }
406
423
  setupClaudeHooks();
407
424
  setupClaudeMd();
@@ -445,13 +462,11 @@ export const claudeCommand = new Command('claude')
445
462
  }
446
463
  if (doSync) {
447
464
  try {
448
- const result = await sync({ interactive: !nonInteractive, progress: syncProgress });
449
- if (result.applied > 0) {
450
- console.log(` Synced ${result.applied} change${result.applied > 1 ? 's' : ''} with Gipity.`);
451
- }
465
+ const result = await sync({ interactive: !nonInteractive, confirmMerge: !nonInteractive, progress: syncProgress });
466
+ reportSyncResult(result);
452
467
  }
453
- catch {
454
- console.log(' Could not sync files (will retry on next prompt).');
468
+ catch (err) {
469
+ console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
455
470
  }
456
471
  }
457
472
  // Interactive: no seeded prompt - CLAUDE.md carries the context now.
@@ -570,13 +585,11 @@ export const claudeCommand = new Command('claude')
570
585
  console.log(`\n Using ${projectDir}`);
571
586
  // Unified sync - push and pull resolved via three-way merge (non-fatal)
572
587
  try {
573
- const result = await sync({ interactive: !nonInteractive, progress: syncProgress });
574
- if (result.applied > 0) {
575
- console.log(` Synced ${result.applied} change${result.applied > 1 ? 's' : ''} with Gipity.`);
576
- }
588
+ const result = await sync({ interactive: !nonInteractive, confirmMerge: !nonInteractive, progress: syncProgress });
589
+ reportSyncResult(result);
577
590
  }
578
- catch {
579
- console.log(' Could not sync files (will retry on next prompt).');
591
+ catch (err) {
592
+ console.log(` ${warning('Could not sync files (will retry on next prompt):')} ${err.message}`);
580
593
  }
581
594
  }
582
595
  // Interactive launch: no seeded first message (new or existing). The
@@ -1,15 +1,18 @@
1
1
  import { Command } from 'commander';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { get } from '../api.js';
4
- import { brand, muted } from '../colors.js';
4
+ import { brand, muted, success, error as clrError } from '../colors.js';
5
5
  import { run, printList } from '../helpers/index.js';
6
6
  const PRICING_URL = 'https://prompt.gipity.ai/pricing';
7
7
  function openInBrowser(url) {
8
- const cmd = process.platform === 'darwin' ? 'open' :
9
- process.platform === 'win32' ? 'start' :
10
- 'xdg-open';
8
+ // Windows `start` is a cmd.exe builtin, not an executable - spawn can't
9
+ // launch it directly, so go through `cmd /c start`. The empty "" is the
10
+ // window-title arg; without it a quoted URL would be swallowed as the title.
11
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] :
12
+ process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] :
13
+ ['xdg-open', [url]];
11
14
  try {
12
- spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref();
15
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
13
16
  }
14
17
  catch {
15
18
  // Spawn failed (no xdg-open on minimal Linux, etc.) - caller prints the URL.
@@ -19,17 +22,23 @@ export const creditsCommand = new Command('credits')
19
22
  .description('View credits')
20
23
  .option('--json', 'Output as JSON')
21
24
  .action((opts) => run('Credits', async () => {
22
- const res = await get('/credits/balance');
23
25
  if (opts.json) {
26
+ const res = await get('/credits/balance');
24
27
  console.log(JSON.stringify(res.data));
28
+ return;
25
29
  }
26
- else {
27
- console.log(`Credits: ${brand(res.data.available.toLocaleString())}`);
28
- if (res.data.balances.length > 0) {
29
- for (const b of res.data.balances) {
30
- const exp = new Date(b.expiresAt).toLocaleDateString();
31
- console.log(`${b.source}: ${b.creditsRemaining.toLocaleString()} ${muted(`expires ${exp}`)}`);
32
- }
30
+ const [balRes, subRes] = await Promise.all([
31
+ get('/credits/balance'),
32
+ get('/credits/subscription'),
33
+ ]);
34
+ const sub = subRes.data;
35
+ const tierLabel = sub.tier === 'pro' ? 'Gipity Pro' : 'Free';
36
+ console.log(`Plan: ${brand(tierLabel)} ${muted(`(${sub.status})`)}`);
37
+ console.log(`Credits: ${brand(balRes.data.available.toLocaleString())}`);
38
+ if (balRes.data.balances.length > 0) {
39
+ for (const b of balRes.data.balances) {
40
+ const exp = new Date(b.expiresAt).toLocaleDateString();
41
+ console.log(`${b.source}: ${b.creditsRemaining.toLocaleString()} ${muted(`expires ${exp}`)}`);
33
42
  }
34
43
  }
35
44
  }));
@@ -41,12 +50,42 @@ creditsCommand
41
50
  openInBrowser(PRICING_URL);
42
51
  console.log(muted("If your browser didn't open automatically, copy the URL above."));
43
52
  }));
53
+ creditsCommand
54
+ .command('status')
55
+ .description('Show billing/Stripe configuration health (admin only)')
56
+ .option('--json', 'Output as JSON')
57
+ // optsWithGlobals(): the parent `credits` command also declares --json, which
58
+ // commander binds to the parent - so read merged opts to see the flag here.
59
+ .action((_opts, cmd) => run('Billing status', async () => {
60
+ const opts = cmd.optsWithGlobals();
61
+ const res = await get('/admin/billing-status');
62
+ const s = res.data;
63
+ if (opts.json) {
64
+ console.log(JSON.stringify(s));
65
+ return;
66
+ }
67
+ const mark = (ok) => (ok ? success('✓') : clrError('✗'));
68
+ console.log(`Stripe mode: ${brand(s.mode)}`);
69
+ console.log(`Secret key: ${mark(s.secretKeyConfigured)}`);
70
+ console.log(`Webhook secret: ${mark(s.webhookSecretConfigured)} ${muted(`endpoint ${s.webhookPath}`)}`);
71
+ console.log(`Credit packs: ${mark(s.packsConfigured)}${s.packsError ? ` ${clrError(s.packsError)}` : ''}`);
72
+ for (const p of s.packs) {
73
+ console.log(` ${p.name} ${muted(p.priceId)}${p.hidden ? muted(' [hidden]') : ''}`);
74
+ }
75
+ console.log(`Test pack ($1): ${mark(s.testPackConfigured)} ${muted('set STRIPE_PACK_TEST_PRICE_ID to enable a cheap purchase smoke test')}`);
76
+ console.log('Subscription plans:');
77
+ for (const pl of s.subscriptionPlans) {
78
+ console.log(` ${pl.name} (${pl.tier}) ${mark(pl.priceIdConfigured)}`);
79
+ }
80
+ }));
44
81
  creditsCommand
45
82
  .command('usage')
46
83
  .description('Show recent usage')
47
84
  .option('--limit <n>', 'Number of entries', '20')
48
85
  .option('--json', 'Output as JSON')
49
- .action((opts) => run('Usage', async () => {
86
+ // optsWithGlobals(): parent `credits` also declares --json (see status above).
87
+ .action((_opts, cmd) => run('Usage', async () => {
88
+ const opts = cmd.optsWithGlobals();
50
89
  const limit = parseInt(opts.limit, 10) || 20;
51
90
  const res = await get(`/credits/usage?limit=${limit}`);
52
91
  printList(res.data, opts, 'No usage history.', u => {
@@ -4,6 +4,7 @@ import { requireConfig } 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';
7
+ import { withSpinner } from '../progress.js';
7
8
  // ── Status icons ───────────────────────────────────────────────────────
8
9
  function statusIcon(status) {
9
10
  if (status === 'ok')
@@ -32,14 +33,16 @@ export const deployCommand = new Command('deploy')
32
33
  }
33
34
  const config = requireConfig();
34
35
  await syncBeforeAction(opts);
35
- // Call server - pipeline runs entirely server-side
36
- const res = await post(`/projects/${config.projectGuid}/deploy`, {
36
+ const doDeploy = () => post(`/projects/${config.projectGuid}/deploy`, {
37
37
  target,
38
38
  sourceDir: opts.sourceDir,
39
39
  optimize: opts.optimize,
40
40
  force: opts.force,
41
41
  only: opts.only?.split(',').map((s) => s.trim()),
42
42
  });
43
+ const res = opts.json
44
+ ? await doDeploy()
45
+ : await withSpinner(`Deploying to ${target}…`, doDeploy, { done: null });
43
46
  const d = res.data;
44
47
  if (opts.json) {
45
48
  console.log(JSON.stringify(d));
@@ -4,7 +4,9 @@ import { resolveProjectContext, getConfigPath } from '../config.js';
4
4
  import { pushFile } from '../sync.js';
5
5
  import { writeFileSync } from 'fs';
6
6
  import { resolve as resolvePath, dirname, relative, isAbsolute } from 'path';
7
- import { error as clrError, success, muted, info } from '../colors.js';
7
+ import { error as clrError, success, muted } from '../colors.js';
8
+ import { printCommandError } from '../helpers/command.js';
9
+ import { withSpinner } from '../progress.js';
8
10
  import { IMAGE_MODELS_DOC, IMAGE_GEMINI_ASPECT_RATIOS, IMAGE_GEMINI_SIZES, VIDEO_MODELS_DOC, TTS_PROVIDER_DESCRIPTIONS } from '../provider-docs.js';
9
11
  /** Download a URL and save to a local file, then push it up to the project so
10
12
  * the cloud (and anything that mirrors it) immediately matches local disk.
@@ -72,7 +74,7 @@ Examples:
72
74
  .action(async (prompt, opts) => {
73
75
  try {
74
76
  const { config } = await resolveProjectContext();
75
- const result = await post(`/projects/${config.projectGuid}/generate/image`, {
77
+ const doGenerate = () => post(`/projects/${config.projectGuid}/generate/image`, {
76
78
  prompt,
77
79
  provider: opts.provider,
78
80
  model: opts.model,
@@ -82,6 +84,9 @@ Examples:
82
84
  image_size: opts.imageSize,
83
85
  seed: Number.isFinite(opts.seed) ? opts.seed : undefined,
84
86
  });
87
+ const result = opts.json
88
+ ? await doGenerate()
89
+ : await withSpinner('Generating image…', doGenerate, { done: null });
85
90
  const ext = result.content_type.includes('png') ? 'png' : 'jpg';
86
91
  const filename = opts.output || `generated.${ext}`;
87
92
  const savedPath = await downloadFile(result.url, filename);
@@ -98,7 +103,7 @@ Examples:
98
103
  }
99
104
  }
100
105
  catch (err) {
101
- console.error(clrError(`Image generation failed: ${err.message}`));
106
+ printCommandError('Image generation', err);
102
107
  process.exit(1);
103
108
  }
104
109
  });
@@ -130,14 +135,16 @@ Examples:
130
135
  .action(async (prompt, opts) => {
131
136
  try {
132
137
  const { config } = await resolveProjectContext();
133
- if (!opts.json)
134
- console.log(info('Generating video (this may take 30-120 seconds)...')); // keep --json stdout pure JSON
135
- const result = await post(`/projects/${config.projectGuid}/generate/video`, {
138
+ const doGenerate = () => post(`/projects/${config.projectGuid}/generate/video`, {
136
139
  prompt,
137
140
  model: opts.model,
138
141
  aspect_ratio: opts.aspect,
139
142
  resolution: opts.resolution,
140
143
  });
144
+ // Veo runs 30-120s; the bouncing bar + timer keeps the wait honest.
145
+ const result = opts.json
146
+ ? await doGenerate()
147
+ : await withSpinner('Generating video…', doGenerate, { done: null });
141
148
  const filename = opts.output || 'generated.mp4';
142
149
  const savedPath = await downloadFile(result.url, filename);
143
150
  if (opts.json) {
@@ -150,7 +157,7 @@ Examples:
150
157
  }
151
158
  }
152
159
  catch (err) {
153
- console.error(clrError(`Video generation failed: ${err.message}`));
160
+ printCommandError('Video generation', err);
154
161
  process.exit(1);
155
162
  }
156
163
  });
@@ -190,13 +197,16 @@ Examples:
190
197
  process.exit(1);
191
198
  }
192
199
  }
193
- const result = await post(`/projects/${config.projectGuid}/generate/speech`, {
200
+ const doGenerate = () => post(`/projects/${config.projectGuid}/generate/speech`, {
194
201
  text,
195
202
  provider: opts.provider,
196
203
  voice: opts.voice,
197
204
  language: opts.language,
198
205
  speakers,
199
206
  });
207
+ const result = opts.json
208
+ ? await doGenerate()
209
+ : await withSpinner('Generating speech…', doGenerate, { done: null });
200
210
  const filename = opts.output || 'speech.mp3';
201
211
  const savedPath = await downloadFile(result.url, filename);
202
212
  if (opts.json) {
@@ -209,7 +219,7 @@ Examples:
209
219
  }
210
220
  }
211
221
  catch (err) {
212
- console.error(clrError(`Speech generation failed: ${err.message}`));
222
+ printCommandError('Speech generation', err);
213
223
  process.exit(1);
214
224
  }
215
225
  });
@@ -239,14 +249,15 @@ Examples:
239
249
  .action(async (prompt, opts) => {
240
250
  try {
241
251
  const { config } = await resolveProjectContext();
242
- if (!opts.json)
243
- console.log(info('Generating music...')); // keep --json stdout pure JSON
244
- const result = await post(`/projects/${config.projectGuid}/generate/music`, {
252
+ const doGenerate = () => post(`/projects/${config.projectGuid}/generate/music`, {
245
253
  prompt,
246
254
  duration_seconds: opts.duration,
247
255
  model: opts.model,
248
256
  instrumental: !opts.vocals,
249
257
  });
258
+ const result = opts.json
259
+ ? await doGenerate()
260
+ : await withSpinner('Generating music…', doGenerate, { done: null });
250
261
  const filename = opts.output || 'music.mp3';
251
262
  const savedPath = await downloadFile(result.url, filename);
252
263
  if (opts.json) {
@@ -259,7 +270,7 @@ Examples:
259
270
  }
260
271
  }
261
272
  catch (err) {
262
- console.error(clrError(`Music generation failed: ${err.message}`));
273
+ printCommandError('Music generation', err);
263
274
  process.exit(1);
264
275
  }
265
276
  });
@@ -6,6 +6,7 @@ import { getProjectRoot } from '../config.js';
6
6
  import { brand, bold, muted, success } from '../colors.js';
7
7
  import { formatSize } from '../utils.js';
8
8
  import { run } from '../helpers/index.js';
9
+ import { withSpinner } from '../progress.js';
9
10
  const DEVICE_PRESETS = {
10
11
  default: { width: 1280, height: 720 },
11
12
  desktop: { width: 1920, height: 1080 },
@@ -143,7 +144,13 @@ export const pageScreenshotCommand = new Command('screenshot')
143
144
  ...(opts.fakeMedia ? { fakeMedia: true } : {}),
144
145
  ...(opts.action ? { action: opts.action } : {}),
145
146
  };
146
- const entries = await postForTarEntries('/tools/browser/screenshot', body);
147
+ // Load + render across viewports runs server-side and can take many
148
+ // seconds; animate the wait, then clear so the saved-files summary is the
149
+ // result. JSON mode skips the spinner (shares stdout).
150
+ const doShoot = () => postForTarEntries('/tools/browser/screenshot', body);
151
+ const entries = opts.json
152
+ ? await doShoot()
153
+ : await withSpinner('Capturing…', doShoot, { done: null });
147
154
  const metaEntry = entries.find((e) => e.name === 'meta.json');
148
155
  if (!metaEntry)
149
156
  throw new Error('Server response missing meta.json');
@@ -5,6 +5,7 @@ import { sync } from '../sync.js';
5
5
  import { success, muted } from '../colors.js';
6
6
  import { run } from '../helpers/index.js';
7
7
  import { confirm } from '../utils.js';
8
+ import { createProgressReporter, withSpinner } from '../progress.js';
8
9
  export const removeCommand = new Command('remove')
9
10
  .description('Remove an installed kit from the project (inverse of `gipity add <kit>`).')
10
11
  .argument('<kit>', 'Kit key/directory under src/packages/ to remove')
@@ -18,10 +19,13 @@ export const removeCommand = new Command('remove')
18
19
  return;
19
20
  }
20
21
  }
21
- const res = await post(`/projects/${config.projectGuid}/remove`, { name: kit });
22
+ const doRemove = () => post(`/projects/${config.projectGuid}/remove`, { name: kit });
23
+ const res = opts.json
24
+ ? await doRemove()
25
+ : await withSpinner('Removing…', doRemove, { done: null });
22
26
  // Force the pull so the kit's deletions land locally without tripping the
23
27
  // bulk-deletion guard - the removal is an explicit, user-invoked action.
24
- const syncResult = await sync({ interactive: false, force: true });
28
+ const syncResult = await sync({ interactive: false, force: true, progress: opts.json ? undefined : createProgressReporter() });
25
29
  const data = res.data;
26
30
  if (opts.json) {
27
31
  console.log(JSON.stringify({ ...data, synced: syncResult.applied }));