gipity 1.0.408 → 1.0.410

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 (52) hide show
  1. package/dist/claude-setup.js +3 -2
  2. package/dist/commands/add.js +11 -2
  3. package/dist/commands/approval.js +1 -1
  4. package/dist/commands/bug.js +69 -0
  5. package/dist/commands/credits.js +242 -15
  6. package/dist/commands/email.js +2 -1
  7. package/dist/commands/gmail.js +2 -1
  8. package/dist/commands/init.js +2 -1
  9. package/dist/commands/job.js +5 -4
  10. package/dist/commands/login.js +1 -0
  11. package/dist/commands/notify.js +2 -1
  12. package/dist/commands/page.js +1 -1
  13. package/dist/commands/payments.js +2 -1
  14. package/dist/commands/plan.js +1 -1
  15. package/dist/commands/push.js +7 -3
  16. package/dist/commands/relay.js +26 -2
  17. package/dist/commands/remove.js +10 -4
  18. package/dist/commands/secrets.js +2 -1
  19. package/dist/commands/service.js +2 -1
  20. package/dist/commands/storage.js +64 -0
  21. package/dist/commands/sync.js +2 -1
  22. package/dist/commands/text.js +2 -1
  23. package/dist/commands/token.js +2 -1
  24. package/dist/commands/uninstall.js +2 -2
  25. package/dist/commands/upload.js +1 -2
  26. package/dist/config.js +4 -4
  27. package/dist/helpers/output.js +42 -13
  28. package/dist/index.js +5 -4
  29. package/dist/knowledge.js +22 -0
  30. package/dist/platform.js +9 -5
  31. package/dist/relay/daemon.js +112 -7
  32. package/dist/relay/installers.js +10 -2
  33. package/dist/relay/onboarding.js +5 -2
  34. package/dist/relay/redact.js +26 -3
  35. package/dist/relay/setup.js +4 -4
  36. package/dist/relay/state.js +6 -3
  37. package/dist/setup.js +2 -3
  38. package/dist/sync.js +227 -106
  39. package/dist/updater/bootstrap.js +1 -1
  40. package/dist/updater/shim.js +4 -4
  41. package/dist/upload.js +1 -1
  42. package/package.json +6 -3
  43. package/dist/coding-guidelines.js +0 -52
  44. package/dist/commands/browser.js +0 -84
  45. package/dist/commands/checkpoint.js +0 -68
  46. package/dist/commands/hook-capture.js +0 -215
  47. package/dist/commands/scaffold.js +0 -58
  48. package/dist/commands/skills.js +0 -45
  49. package/dist/commands/start-cc.js +0 -313
  50. package/dist/gip3dw-guide.js +0 -18
  51. package/dist/platform-overview.js +0 -52
  52. package/dist/relay/transcripts.js +0 -119
@@ -24,7 +24,7 @@ export function claudeInstallPlan(platformOverride) {
24
24
  /** Whether the `claude` binary resolves on PATH. */
25
25
  export function isClaudeInstalled(platformOverride) {
26
26
  try {
27
- execSync(claudeInstallPlan(platformOverride).checkCmd, { stdio: 'ignore' });
27
+ execSync(claudeInstallPlan(platformOverride).checkCmd, { stdio: 'ignore', windowsHide: true });
28
28
  return true;
29
29
  }
30
30
  catch {
@@ -59,6 +59,7 @@ export function probeClaudeAuthenticated() {
59
59
  timeout: 60_000,
60
60
  stdio: ['ignore', 'pipe', 'ignore'],
61
61
  encoding: 'utf-8',
62
+ windowsHide: true,
62
63
  });
63
64
  return typeof out === 'string' && out.trim().length > 0;
64
65
  }
@@ -77,7 +78,7 @@ export function ensureClaudeInstalled(opts = {}) {
77
78
  return { installed: true, alreadyPresent: true };
78
79
  const { installArgv } = claudeInstallPlan();
79
80
  try {
80
- execSync(installArgv.join(' '), { stdio: opts.quiet ? 'ignore' : 'inherit' });
81
+ execSync(installArgv.join(' '), { stdio: opts.quiet ? 'ignore' : 'inherit', windowsHide: true });
81
82
  }
82
83
  catch {
83
84
  // Fall through to a definitive PATH re-check rather than trusting the throw.
@@ -5,7 +5,7 @@ import { Command } from 'commander';
5
5
  import { post } from '../api.js';
6
6
  import { requireConfig } from '../config.js';
7
7
  import { sync } from '../sync.js';
8
- import { success, muted, bold } from '../colors.js';
8
+ import { success, muted, bold, warning } from '../colors.js';
9
9
  import { run } from '../helpers/index.js';
10
10
  import { createProgressReporter, withSpinner } from '../progress.js';
11
11
  const STARTERS = [
@@ -144,7 +144,7 @@ function buildLocalPayload(rootDir) {
144
144
  return { name: path.basename(rootDir), files };
145
145
  }
146
146
  export const addCommand = new Command('add')
147
- .description('Add a template (scaffold an app) or a kit (reusable building block) to the project. Pass ./path/to/dir to install a local template directly.')
147
+ .description('Add a template or kit')
148
148
  .argument('[name]', 'Template/kit key, OR a local directory path (./, ~/, or /abs). Omit for help; use --list for just the catalog.')
149
149
  .option('--title <title>', 'App title - templates only (defaults to project name)')
150
150
  .option('--description <desc>', 'App description for meta tags - templates only')
@@ -225,6 +225,15 @@ export const addCommand = new Command('add')
225
225
  for (const n of data.notes)
226
226
  console.log(n);
227
227
  }
228
+ // Partial install (server HTTP 207): some files were dropped. Surface them so
229
+ // the user knows the install is incomplete rather than reading a clean success.
230
+ if (data.failed?.length) {
231
+ console.log('');
232
+ console.warn(warning(`${data.failed.length} file(s) failed to install:`));
233
+ for (const f of data.failed)
234
+ console.warn(warning(` ! ${f.path}: ${f.error}`));
235
+ console.warn(warning(`Re-run \`gipity add ${data.kit ?? name}${opts.force ? ' --force' : ''}\` to retry the dropped files.`));
236
+ }
228
237
  // After scaffolding an app, point at kits as the next step - they add
229
238
  // features (multiplayer, etc.) into the app you just created.
230
239
  if (data.kind !== 'kit' && KITS.length > 0) {
@@ -21,7 +21,7 @@ function keyToIndex(key) {
21
21
  return code >= 0 && code < 26 ? code : -1;
22
22
  }
23
23
  export const approvalCommand = new Command('approval')
24
- .description('List, create, answer, or cancel approvals');
24
+ .description('Manage approvals');
25
25
  approvalCommand
26
26
  .command('list')
27
27
  .description('List pending approvals')
@@ -0,0 +1,69 @@
1
+ import { Command } from 'commander';
2
+ import { get, post } from '../api.js';
3
+ import { resolveProjectContext } from '../config.js';
4
+ import { bold, muted, success, warning } from '../colors.js';
5
+ import { run } from '../helpers/index.js';
6
+ // Kept in sync with @easyclaw/shared BUG_REPORT_CATEGORIES / _SEVERITIES (the CLI
7
+ // package can't import the server's shared package). The server validates
8
+ // authoritatively; these are for --help and a friendly client-side check.
9
+ const CATEGORIES = ['cli', 'deploy', 'template', 'kit', 'db', 'docs', 'skill', 'service', 'sandbox', 'other'];
10
+ const SEVERITIES = ['S1', 'S2', 'S3', 'S4'];
11
+ const SEVERITY_HINT = 'S1=blocker, S2=major, S3=minor, S4=friction';
12
+ export const bugCommand = new Command('bug')
13
+ .description('Report Gipity platform bugs / friction in real time')
14
+ .addHelpText('after', `\nCapture platform friction the moment you hit it — even one you worked around —` +
15
+ `\nso the team can triage it into a fix.\n` +
16
+ `\nCategories: ${CATEGORIES.join(', ')}` +
17
+ `\nSeverity: ${SEVERITY_HINT}` +
18
+ `\nNever include PII or user data — describe the platform problem in the abstract.`);
19
+ bugCommand
20
+ .command('report')
21
+ .description('File a bug / friction report about the Gipity platform')
22
+ .requiredOption('--category <cat>', `One of: ${CATEGORIES.join(', ')}`)
23
+ .requiredOption('--severity <sev>', SEVERITY_HINT)
24
+ .requiredOption('--summary <text>', 'One line, 7 words max. No PII/user data.')
25
+ .option('--detail <text>', 'Succinct: what you did, what failed, the workaround. No PII/user data.')
26
+ .option('--project <guid-or-slug>', 'Attribute to a specific project instead of cwd / Home')
27
+ .option('--json', 'Output raw JSON')
28
+ .action((opts) => run('Bug report', async () => {
29
+ const category = String(opts.category).toLowerCase();
30
+ const severity = /^[1-4]$/.test(opts.severity) ? `S${opts.severity}` : String(opts.severity).toUpperCase();
31
+ if (!CATEGORIES.includes(category))
32
+ throw new Error(`--category must be one of: ${CATEGORIES.join(', ')}`);
33
+ if (!SEVERITIES.includes(severity))
34
+ throw new Error(`--severity must be one of: ${SEVERITY_HINT}`);
35
+ if (String(opts.summary).trim().split(/\s+/).filter(Boolean).length > 7) {
36
+ throw new Error('--summary must be 7 words or fewer');
37
+ }
38
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
39
+ const res = await post(`/api/${config.projectGuid}/services/bug-report/submit`, { category, severity, summary: opts.summary, detail: opts.detail });
40
+ if (opts.json) {
41
+ console.log(JSON.stringify(res.data));
42
+ return;
43
+ }
44
+ console.log(success(`✓ Bug report filed (${res.data.report_guid}) — queued for triage.`));
45
+ }));
46
+ bugCommand
47
+ .command('list')
48
+ .description('List the bug / friction reports you have filed')
49
+ .option('--project <guid-or-slug>', 'Resolve auth against a specific project instead of cwd / Home')
50
+ .option('--json', 'Output raw JSON')
51
+ .action((opts) => run('Bug report', async () => {
52
+ const { config } = await resolveProjectContext({ projectOverride: opts.project });
53
+ const res = await get(`/api/${config.projectGuid}/services/bug-report/list`);
54
+ if (opts.json) {
55
+ console.log(JSON.stringify(res.data));
56
+ return;
57
+ }
58
+ const rows = res.data;
59
+ if (!rows.length) {
60
+ console.log(warning('No bug reports filed yet.'));
61
+ return;
62
+ }
63
+ console.log(bold(`${rows.length} report${rows.length === 1 ? '' : 's'}`));
64
+ for (const r of rows) {
65
+ console.log(` ${r.severity} ${muted(r.category.padEnd(8))} ${r.summary} ${muted(`[${r.status}]`)}`);
66
+ console.log(muted(` ${r.report_guid} ${r.created_at}`));
67
+ }
68
+ }));
69
+ //# sourceMappingURL=bug.js.map
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
- import { spawn } from 'node:child_process';
3
- import { get } from '../api.js';
4
- import { brand, muted } from '../colors.js';
2
+ import { spawnCommand } from '../platform.js';
3
+ import { get, post, ApiError } from '../api.js';
4
+ import { brand, dim, bold, muted, success } 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) {
@@ -12,7 +12,7 @@ function openInBrowser(url) {
12
12
  process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] :
13
13
  ['xdg-open', [url]];
14
14
  try {
15
- const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
15
+ const child = spawnCommand(cmd, args, { stdio: 'ignore', detached: true });
16
16
  // A missing/non-executable launcher (ENOENT, or EACCES on WSL/minimal Linux
17
17
  // where xdg-open isn't runnable) is reported ASYNCHRONOUSLY via an 'error'
18
18
  // event - not a throw - so the try/catch alone can't stop it. With no
@@ -25,18 +25,92 @@ function openInBrowser(url) {
25
25
  // Synchronous spawn failure - caller prints the URL.
26
26
  }
27
27
  }
28
+ // ---- Plan limits formatting (folded in from the old `plan` command) ----
29
+ // Every key the plan definition enforces is rendered, so `credits` /
30
+ // `credits list` are a full-transparency view of what your plan actually caps.
31
+ const LIMIT_LABELS = {
32
+ maxProjects: 'Projects',
33
+ maxDatabases: 'Databases',
34
+ storageQuotaBytes: 'Storage',
35
+ maxWorkflows: 'Workflows',
36
+ minCronIntervalHours: 'Cron frequency',
37
+ maxConcurrentChats: 'Concurrent chats',
38
+ deployRatePerMinute: 'Deploys/min',
39
+ testFileConcurrency: 'Parallel tests',
40
+ };
41
+ // Nested serviceLimits (media generation entitlements). Convention:
42
+ // -1 = unlimited, 0 = Pro-only (blocked on free), N = N free uses/month.
43
+ const SERVICE_LABELS = {
44
+ video: 'Video generation',
45
+ music: 'Music generation',
46
+ image: 'Image generation',
47
+ audio: 'Speech & sound FX',
48
+ };
49
+ function formatLimit(key, value) {
50
+ if (typeof value !== 'number')
51
+ return String(value);
52
+ if (key === 'storageQuotaBytes') {
53
+ const gb = value / (1024 ** 3);
54
+ return `${gb.toFixed(gb >= 1 ? 1 : 2)} GB`;
55
+ }
56
+ if (key === 'minCronIntervalHours') {
57
+ return value === 0 ? 'no minimum' : `${value}h minimum`;
58
+ }
59
+ return value.toLocaleString();
60
+ }
61
+ function formatServiceLimit(value) {
62
+ if (value === -1)
63
+ return 'unlimited';
64
+ if (value === 0)
65
+ return 'Pro only';
66
+ if (typeof value === 'number')
67
+ return `${value.toLocaleString()}/mo free`;
68
+ return String(value);
69
+ }
70
+ function renderLimits(limits, indent = '') {
71
+ for (const key of Object.keys(LIMIT_LABELS)) {
72
+ const value = limits[key];
73
+ if (value !== undefined) {
74
+ console.log(`${indent}${LIMIT_LABELS[key].padEnd(18)} ${formatLimit(key, value)}`);
75
+ }
76
+ }
77
+ const services = limits.serviceLimits;
78
+ if (services && typeof services === 'object') {
79
+ console.log(`${indent}Media generation`);
80
+ for (const key of Object.keys(SERVICE_LABELS)) {
81
+ const value = services[key];
82
+ if (value !== undefined) {
83
+ console.log(`${indent} ${SERVICE_LABELS[key].padEnd(16)} ${formatServiceLimit(value)}`);
84
+ }
85
+ }
86
+ }
87
+ }
88
+ // `credits` is the single account/billing hub: tier, live balance, the full set
89
+ // of plan limits, and an upgrade nudge. `plan` aliases to it (so old muscle
90
+ // memory resolves to one implementation); compare plans with `plan list`.
28
91
  export const creditsCommand = new Command('credits')
29
- .description('View credits')
92
+ .alias('plan')
93
+ .description('Show your plan, credit balance, and limits')
30
94
  .option('--json', 'Output as JSON')
31
95
  .action((opts) => run('Credits', async () => {
96
+ // Version retention rides alongside the Storage limit; a failure fetching it
97
+ // must not sink the whole view, so degrade to null and omit the line.
98
+ const fetchRetention = () => get('/users/me/retention').then(r => r.data).catch(() => null);
32
99
  if (opts.json) {
33
- const res = await get('/credits/balance');
34
- console.log(JSON.stringify(res.data));
100
+ const [balRes, limitsRes, subRes, retention] = await Promise.all([
101
+ get('/credits/balance'),
102
+ get('/users/me/limits'),
103
+ get('/credits/subscription'),
104
+ fetchRetention(),
105
+ ]);
106
+ console.log(JSON.stringify({ subscription: subRes.data, balance: balRes.data, limits: limitsRes.data, retention }));
35
107
  return;
36
108
  }
37
- const [balRes, subRes] = await Promise.all([
109
+ const [balRes, subRes, limitsRes, retention] = await Promise.all([
38
110
  get('/credits/balance'),
39
111
  get('/credits/subscription'),
112
+ get('/users/me/limits'),
113
+ fetchRetention(),
40
114
  ]);
41
115
  const sub = subRes.data;
42
116
  const tierLabel = sub.tier === 'pro' ? 'Gipity Pro' : 'Free';
@@ -45,17 +119,170 @@ export const creditsCommand = new Command('credits')
45
119
  if (balRes.data.balances.length > 0) {
46
120
  for (const b of balRes.data.balances) {
47
121
  const exp = new Date(b.expiresAt).toLocaleDateString();
48
- console.log(`${b.source}: ${b.creditsRemaining.toLocaleString()} ${muted(`expires ${exp}`)}`);
122
+ console.log(` ${b.source}: ${b.creditsRemaining.toLocaleString()} ${muted(`expires ${exp}`)}`);
49
123
  }
50
124
  }
125
+ console.log('\nLimits:');
126
+ renderLimits(limitsRes.data.limits, ' ');
127
+ if (retention) {
128
+ console.log(` ${'Version retention'.padEnd(18)} ${retention.days} days / ${retention.count} copies`);
129
+ }
130
+ console.log('');
131
+ if (sub.tier !== 'pro') {
132
+ console.log(dim('Upgrade to Gipity Pro for higher limits and 20,000 credits/mo — run `gipity credits buy`.'));
133
+ }
134
+ else {
135
+ console.log(dim('Need more credits? Run `gipity credits list` to see credit packs, then `gipity credits buy <pack>`.'));
136
+ }
51
137
  }));
138
+ // `credits list` compares every plan (with full limits) and the available
139
+ // credit packs — the "what can I upgrade to" view.
52
140
  creditsCommand
53
- .command('buy')
54
- .description('Open the credits purchase page in your browser')
55
- .action(() => run('Buy credits', async () => {
56
- console.log(`Opening ${brand(PRICING_URL)} in your browser...`);
57
- openInBrowser(PRICING_URL);
58
- console.log(muted("If your browser didn't open automatically, copy the URL above."));
141
+ .command('list')
142
+ .description('Compare all plans and credit packs')
143
+ .option('--json', 'Output as JSON')
144
+ // optsWithGlobals(): the parent `credits` command also declares --json, which
145
+ // commander binds to the parent - read merged opts so --json is seen here.
146
+ .action((_opts, cmd) => run('Plans', async () => {
147
+ const opts = cmd.optsWithGlobals();
148
+ const [plansRes, productsRes, subRes] = await Promise.all([
149
+ get('/plans'),
150
+ get('/credits/products'),
151
+ get('/credits/subscription'),
152
+ ]);
153
+ if (opts.json) {
154
+ console.log(JSON.stringify({ currentTier: subRes.data.tier, plans: plansRes.data, products: productsRes.data }));
155
+ return;
156
+ }
157
+ const currentTier = subRes.data.tier;
158
+ const plans = plansRes.data;
159
+ if (plans.length === 0) {
160
+ console.log('No active plans.');
161
+ return;
162
+ }
163
+ console.log(bold('Plans'));
164
+ for (const plan of plans) {
165
+ const marker = plan.tier === currentTier ? brand('* ') : ' ';
166
+ const price = plan.monthlyPriceUsd > 0 ? ` - $${plan.monthlyPriceUsd}/mo` : ' - Free';
167
+ const credits = plan.monthlyCredits > 0
168
+ ? ` - ${plan.monthlyCredits.toLocaleString()} credits/mo (${plan.creditExpiryDays}-day expiry)`
169
+ : '';
170
+ console.log(`${marker}${plan.displayName} (${plan.tier})${price}${credits}`);
171
+ renderLimits(plan.limits, ' ');
172
+ console.log('');
173
+ }
174
+ const packs = productsRes.data.filter(p => p.type === 'one_time');
175
+ if (packs.length > 0) {
176
+ console.log(bold('Credit packs') + muted(' (require an active Pro subscription)'));
177
+ for (const pack of packs) {
178
+ const lock = pack.available ? '' : muted(' [Pro only]');
179
+ console.log(` ${pack.name} - $${pack.amountUsd} - ${pack.credits.toLocaleString()} credits${lock}`);
180
+ }
181
+ console.log('');
182
+ }
183
+ console.log(dim('* = your current plan. Upgrade or top up with `gipity credits buy [pro|<pack-credits>]`.'));
184
+ }));
185
+ // `credits buy` is the ONE purchase command. It resolves the target to a
186
+ // Stripe price and prints a checkout link (agent-friendly: the link is always
187
+ // printed so it can be relayed to the user; --open also launches a browser).
188
+ creditsCommand
189
+ .command('buy [target]')
190
+ .alias('upgrade')
191
+ .description('Upgrade your plan or buy a credit pack (target: a tier like "pro", or a pack credit amount)')
192
+ .option('--open', 'Open the checkout page in your browser')
193
+ .option('--json', 'Output the checkout URL as JSON')
194
+ // optsWithGlobals(): the parent `credits` command also declares --json, which
195
+ // commander binds to the parent - read merged opts so --json/--open are seen here.
196
+ .action((target, _opts, cmd) => run('Buy', async () => {
197
+ const opts = cmd.optsWithGlobals();
198
+ const [productsRes, subRes] = await Promise.all([
199
+ get('/credits/products'),
200
+ get('/credits/subscription'),
201
+ ]);
202
+ const products = productsRes.data;
203
+ const currentTier = subRes.data.tier;
204
+ // Resolve the target product. With no arg, default to the first available
205
+ // paid subscription (the upgrade path). With an arg, match a plan/pack by
206
+ // name or credit amount, mirroring the cloud agent's product keys.
207
+ // Non-happy exits must stay parseable under --json (agents rely on it), so
208
+ // emit a JSON { error } instead of prose when the flag is set.
209
+ const bail = (message, humanLines) => {
210
+ if (opts.json)
211
+ console.log(JSON.stringify({ error: message }));
212
+ else
213
+ for (const line of humanLines)
214
+ console.log(line);
215
+ };
216
+ let product;
217
+ if (!target) {
218
+ product = products.find(p => p.type === 'subscription' && p.available);
219
+ if (!product) {
220
+ bail(currentTier === 'pro'
221
+ ? "You're already on Gipity Pro. To top up credits, run `gipity credits list` and buy a pack, e.g. `gipity credits buy 20000`."
222
+ : 'No upgrade plan is available right now.', [
223
+ currentTier === 'pro'
224
+ ? `You're already on ${brand('Gipity Pro')}. To top up credits, run \`gipity credits list\` and buy a pack, e.g. \`gipity credits buy 20000\`.`
225
+ : 'No upgrade plan is available right now.',
226
+ dim(`Browse everything at ${PRICING_URL}`),
227
+ ]);
228
+ return;
229
+ }
230
+ }
231
+ else {
232
+ const key = target.toLowerCase();
233
+ product = products.find(p => p.type === 'subscription' && p.name.toLowerCase() === key)
234
+ // pack keyed by credit amount, e.g. "20000" - MUST be a pack, not a
235
+ // subscription (Pro's monthly_credits can equal a pack's credit count).
236
+ ?? products.find(p => p.type === 'one_time' && String(p.credits) === key)
237
+ // accept "pro" against the single paid plan when only one exists
238
+ ?? (key === 'pro' ? products.find(p => p.type === 'subscription') : undefined);
239
+ if (!product) {
240
+ bail(`Unknown option "${target}". Run \`gipity credits list\` to see plans and packs.`, [`Unknown option "${target}". Run \`gipity credits list\` to see plans and packs.`]);
241
+ return;
242
+ }
243
+ if (!product.available) {
244
+ bail(product.type === 'subscription'
245
+ ? `You're already on ${product.name}.`
246
+ : 'Credit packs require an active Pro subscription. Run `gipity credits buy pro` first.', [product.type === 'subscription'
247
+ ? `You're already on ${brand(product.name)}.`
248
+ : 'Credit packs require an active Pro subscription. Run `gipity credits buy pro` first.']);
249
+ return;
250
+ }
251
+ }
252
+ try {
253
+ const res = await post('/credits/purchase', { priceId: product.priceId });
254
+ const url = res.data.checkoutUrl;
255
+ if (opts.json) {
256
+ console.log(JSON.stringify({ product: product.name, amountUsd: product.amountUsd, checkoutUrl: url }));
257
+ return;
258
+ }
259
+ const verb = product.type === 'subscription' ? 'Upgrading to' : 'Buying';
260
+ const per = product.type === 'subscription' ? '/mo' : '';
261
+ console.log(`${verb} ${brand(product.name)} ${muted(`($${product.amountUsd}${per}, ${product.credits.toLocaleString()} credits)`)}`);
262
+ console.log('');
263
+ console.log(` ${bold('Checkout:')} ${success(url)}`);
264
+ console.log('');
265
+ console.log(dim('Open the link to complete your purchase — 2 minutes, cancel anytime. Your plan unlocks the moment payment clears.'));
266
+ if (opts.open)
267
+ openInBrowser(url);
268
+ }
269
+ catch (err) {
270
+ // Payments not configured / Stripe hiccup: fall back to the pricing page
271
+ // so the user is never dead-ended.
272
+ if (err instanceof ApiError) {
273
+ if (opts.json) {
274
+ console.log(JSON.stringify({ error: err.message, checkoutUrl: null, fallbackUrl: PRICING_URL }));
275
+ }
276
+ else {
277
+ console.log(muted(`Couldn't create a direct checkout link (${err.message}).`));
278
+ console.log(`Complete your purchase at ${brand(PRICING_URL)}`);
279
+ }
280
+ if (opts.open)
281
+ openInBrowser(PRICING_URL);
282
+ return;
283
+ }
284
+ throw err;
285
+ }
59
286
  }));
60
287
  creditsCommand
61
288
  .command('usage')
@@ -7,7 +7,8 @@ function collect(value, prev) {
7
7
  return [...prev, value];
8
8
  }
9
9
  export const emailCommand = new Command('email')
10
- .description('Send email from the Gipity platform (gipity@gipity.ai). Omit --to to self-send.')
10
+ .description('Send email')
11
+ .addHelpText('after', '\nSends from gipity@gipity.ai. Omit --to to self-send.')
11
12
  .requiredOption('--subject <subject>', 'Email subject')
12
13
  .requiredOption('--body <body>', 'Email body (plain text)')
13
14
  .option('--to <email>', 'Recipient (repeatable; omit for self-send)', collect, [])
@@ -3,7 +3,8 @@ import { get, post } from '../api.js';
3
3
  import { run, printList, printResult } from '../helpers/index.js';
4
4
  import { muted } from '../colors.js';
5
5
  export const gmailCommand = new Command('gmail')
6
- .description('Send, reply, search, or read via your Gmail (user channel - separate from `gipity email`)');
6
+ .description('Use your Gmail')
7
+ .addHelpText('after', '\nSend, reply, search, or read via your own Gmail (separate from `gipity email`).');
7
8
  gmailCommand
8
9
  .command('search <query...>')
9
10
  .description('Search your Gmail (standard Gmail search syntax)')
@@ -22,7 +22,8 @@ function resolveTools(forFlag) {
22
22
  return SUPPORTED_TOOLS.filter(t => requested.includes(t.key) || (!t.optIn && requested.includes('all')));
23
23
  }
24
24
  export const initCommand = new Command('init')
25
- .description('Link this directory to a Gipity project (writes primer files so your AI coding tool understands Gipity)')
25
+ .description('Link this directory to a project')
26
+ .addHelpText('after', '\nWrites CLAUDE.md/AGENTS.md primer files so your AI coding tool understands Gipity.')
26
27
  .argument('[name]', 'Project name/slug (defaults to current directory name)')
27
28
  .option('--agent <guid>', 'Agent GUID to use')
28
29
  .option('--for <tools>', `Which AI tool primer files to write (comma-separated). Default: all except aider (opt-in - it also writes .aider.conf.yml). Choices: ${TOOL_KEYS.join(', ')}, all`)
@@ -4,7 +4,8 @@ import { requireConfig } from '../config.js';
4
4
  import { error as clrError, bold, muted, success, warning as warn } from '../colors.js';
5
5
  import { run, printList } from '../helpers/index.js';
6
6
  export const jobCommand = new Command('job')
7
- .description('Long-running jobs - CPU sandbox or GPU via Modal (gpu-small=L4, gpu-medium=A10G, gpu-large=A100, gpu-huge=H100). Declared in gipity.yaml jobs: phase; submit + poll via subcommands below.');
7
+ .description('Run long jobs (CPU/GPU)')
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.');
8
9
  jobCommand
9
10
  .command('list')
10
11
  .description('List jobs')
@@ -192,7 +193,7 @@ jobCommand
192
193
  .action(async (name, opts) => {
193
194
  const { existsSync, statSync } = await import('node:fs');
194
195
  const { resolve, join } = await import('node:path');
195
- const { spawn, spawnSync } = await import('node:child_process');
196
+ const { spawnCommand, spawnSyncCommand } = await import('../platform.js');
196
197
  const jobDir = resolve(process.cwd(), 'jobs', name);
197
198
  if (!existsSync(jobDir) || !statSync(jobDir).isDirectory()) {
198
199
  console.error(clrError(`jobs/${name}/ not found in current directory`));
@@ -212,7 +213,7 @@ jobCommand
212
213
  process.exit(1);
213
214
  }
214
215
  // Confirm docker is reachable before bothering to build args.
215
- const probe = spawnSync('docker', ['info'], { stdio: 'ignore' });
216
+ const probe = spawnSyncCommand('docker', ['info'], { stdio: 'ignore' });
216
217
  if (probe.status !== 0) {
217
218
  console.error(clrError('docker daemon not reachable. Start Docker Desktop / dockerd and retry.'));
218
219
  process.exit(1);
@@ -239,7 +240,7 @@ jobCommand
239
240
  shellCmd = `echo '[run-local] running ${handlerCmd} (${picked.runtime})' >&2 && exec ${handlerCmd}`;
240
241
  }
241
242
  const runArgs = ['run', ...sharedArgs, '--entrypoint', 'sh', opts.image, '-c', shellCmd];
242
- const child = spawn('docker', runArgs, { stdio: 'inherit' });
243
+ const child = spawnCommand('docker', runArgs, { stdio: 'inherit' });
243
244
  // A missing `docker` binary is reported asynchronously via 'error' (not a
244
245
  // throw), so a try/catch can't stop it - without this handler Node crashes
245
246
  // with a raw stack trace instead of a clear message.
@@ -34,6 +34,7 @@ export const loginCommand = new Command('login')
34
34
  process.exit(1);
35
35
  }
36
36
  await publicPost('/auth/login', { email });
37
+ console.log('');
37
38
  console.log('Check your email for a 6-digit code.');
38
39
  code = await prompt('Code: ');
39
40
  await verify(email, code);
@@ -4,7 +4,8 @@ import { resolveProjectContext } from '../config.js';
4
4
  import { bold, muted, success, warning } from '../colors.js';
5
5
  import { run } from '../helpers/index.js';
6
6
  export const notifyCommand = new Command('notify')
7
- .description('Gipity Notify (web push): send a test notification or inspect subscriptions');
7
+ .description('Web push notifications')
8
+ .addHelpText('after', '\nGipity Notify: send a test notification or inspect subscriptions.');
8
9
  notifyCommand
9
10
  .command('test')
10
11
  .description('Send a test push notification to yourself, a user, or everyone')
@@ -10,7 +10,7 @@ import { pageFetchCommand } from './page-fetch.js';
10
10
  // top-level surface lean and makes the siblings discoverable via `page --help`.
11
11
  // `inspect` is the rendered DOM (browser); `fetch` is the raw asset (plain HTTP).
12
12
  export const pageCommand = new Command('page')
13
- .description('Inspect, evaluate, screenshot, multi-client test, and verify raw files of web pages (page inspect | eval | screenshot | test | fetch)')
13
+ .description('Inspect web pages')
14
14
  .addCommand(pageInspectCommand)
15
15
  .addCommand(pageEvalCommand)
16
16
  .addCommand(pageScreenshotCommand)
@@ -19,7 +19,8 @@ function renderStatus(status) {
19
19
  }
20
20
  }
21
21
  export const paymentsCommand = new Command('payments')
22
- .description('Connect Stripe so your app can charge its users (one-time + subscriptions)');
22
+ .description('Charge your users via Stripe')
23
+ .addHelpText('after', '\nAccept one-time purchases and subscriptions. Set up with `gipity payments connect`.');
23
24
  paymentsCommand
24
25
  .command('connect')
25
26
  .description('Start (or resume) Stripe onboarding for this app — prints a link to finish in your browser')
@@ -31,7 +31,7 @@ function renderLimits(limits, indent = '') {
31
31
  }
32
32
  }
33
33
  export const planCommand = new Command('plan')
34
- .description('Show your current subscription plan and limits')
34
+ .description('Show your plan')
35
35
  .option('--json', 'Output as JSON')
36
36
  .action((opts) => run('Plan', async () => {
37
37
  const [limitsRes, plansRes] = await Promise.all([
@@ -11,9 +11,13 @@ export const pushCommand = new Command('push')
11
11
  try {
12
12
  const fullPath = resolve(file);
13
13
  if (opts.background) {
14
- // Fork to background - import child_process dynamically
15
- const { fork } = await import('child_process');
16
- const child = fork(process.argv[1], ['push', fullPath, '--quiet'], {
14
+ // Detach a background `gipity push` and exit immediately. Goes through
15
+ // spawnCommand (Node binary running our own entry script - no IPC
16
+ // channel is needed, so spawn beats fork), which defaults
17
+ // `windowsHide: true` so the detached child never flashes a console
18
+ // window on Windows.
19
+ const { spawnCommand } = await import('../platform.js');
20
+ const child = spawnCommand(process.execPath, [process.argv[1], 'push', fullPath, '--quiet'], {
17
21
  detached: true,
18
22
  stdio: 'ignore',
19
23
  });
@@ -14,7 +14,7 @@ import { bold, brand, dim, success, error as clrError, muted, } from '../colors.
14
14
  import * as state from '../relay/state.js';
15
15
  import * as daemon from '../relay/daemon.js';
16
16
  import { UnsupportedPlatformError } from '../relay/installers.js';
17
- import { pairDevice, startDaemon, installAutostart } from '../relay/setup.js';
17
+ import { pairDevice, startDaemon, installAutostart, removeAutostart } from '../relay/setup.js';
18
18
  import { registerInstallCommands } from './relay-install.js';
19
19
  export const relayCommand = new Command('relay')
20
20
  .description('Pair with the web CLI');
@@ -269,8 +269,32 @@ relayCommand
269
269
  console.error(muted('Local token cleared anyway. Visit the web CLI to confirm the server-side revoke.'));
270
270
  }
271
271
  state.clearDevice();
272
+ // Remove the OS autostart unit too. Otherwise the login service relaunches
273
+ // `relay run`, which - finding no device but a valid login - silently
274
+ // re-registers a NEW device, undoing this revoke (on macOS the old
275
+ // KeepAlive=true even relaunched on the clean exit). Best-effort: an
276
+ // unsupported OS or a missing unit is fine.
277
+ let autostartRemoved = false;
278
+ try {
279
+ autostartRemoved = removeAutostart().ok;
280
+ }
281
+ catch { /* unsupported platform / no unit - ignore */ }
282
+ // Stop the currently-running daemon now rather than waiting for it to
283
+ // notice via a 401 on its next poll (~up to a hold cycle).
284
+ const pidPath = state.getDaemonPidPath();
285
+ if (existsSync(pidPath)) {
286
+ const pid = parseInt(readFileSync(pidPath, 'utf-8').trim(), 10);
287
+ if (pid && !isNaN(pid)) {
288
+ try {
289
+ process.kill(pid, 'SIGTERM');
290
+ }
291
+ catch { /* already gone */ }
292
+ }
293
+ }
272
294
  console.log(success('Device revoked + local state cleared.'));
273
- console.log(muted('Any running background service will notice and exit within ~30s.'));
295
+ console.log(muted(autostartRemoved
296
+ ? 'Auto-start removed and the background service was signalled to stop.'
297
+ : 'The background service was signalled to stop.'));
274
298
  });
275
299
  // ─── gipity relay log ──────────────────────────────────────────────────
276
300
  relayCommand