@gopherhole/cli 0.4.1 → 0.6.0

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 (3) hide show
  1. package/dist/index.js +855 -15
  2. package/package.json +1 -1
  3. package/src/index.ts +677 -15
package/src/index.ts CHANGED
@@ -20,7 +20,7 @@ const brand = {
20
20
  };
21
21
 
22
22
  // Version
23
- const VERSION = '0.4.1';
23
+ const VERSION = '0.6.0';
24
24
 
25
25
  // ========== API KEY RESOLUTION ==========
26
26
  // Precedence: --api-key flag > GOPHERHOLE_API_KEY env var > .env file in cwd
@@ -968,6 +968,9 @@ ${chalk.bold('Examples:')}
968
968
  $ gopherhole agents config agent-abc123 --no-auto-approve
969
969
  $ gopherhole agents config agent-abc123 --price 0.01 --price-unit request
970
970
  $ gopherhole agents config agent-abc123 --visibility public
971
+ $ gopherhole agents config agent-abc123 --alias support
972
+ $ gopherhole agents config agent-abc123 --email-enabled
973
+ $ gopherhole agents config agent-abc123 --no-email-enabled
971
974
  `)
972
975
  .option('--auto-approve', 'Enable auto-approve (instant access for marketplace)')
973
976
  .option('--no-auto-approve', 'Disable auto-approve (require manual approval)')
@@ -978,6 +981,9 @@ ${chalk.bold('Examples:')}
978
981
  .option('--category <category>', 'Set category')
979
982
  .option('--tags <tags>', 'Set tags (comma-separated)')
980
983
  .option('--description <text>', 'Update description')
984
+ .option('--alias <alias>', 'Set email alias (<alias>[.<tenant-slug>]@gopherhole.io). Triggers 30-day grace redirect from the previous alias if email is enabled')
985
+ .option('--email-enabled', 'Enable inbound + outbound email for this agent (requires an alias)')
986
+ .option('--no-email-enabled', 'Disable email for this agent (inbound rejects with 550, Postie refuses to send)')
981
987
  .action(async (agentId, options) => {
982
988
  const sessionId = config.get('sessionId') as string;
983
989
  if (!sessionId) {
@@ -1021,29 +1027,71 @@ ${chalk.bold('Examples:')}
1021
1027
  body.description = options.description;
1022
1028
  }
1023
1029
 
1024
- if (Object.keys(body).length === 0) {
1030
+ const aliasChange: string | undefined = options.alias;
1031
+ const emailEnabledChange: boolean | undefined = options.emailEnabled;
1032
+
1033
+ if (Object.keys(body).length === 0 && aliasChange === undefined && emailEnabledChange === undefined) {
1025
1034
  console.log(chalk.yellow('No changes specified.'));
1026
1035
  console.log(chalk.gray('Use --help to see available options.'));
1027
1036
  return;
1028
1037
  }
1029
1038
 
1030
1039
  const spinner = ora('Updating agent config...').start();
1031
- log('PATCH /agents/' + agentId, body);
1032
1040
 
1033
1041
  try {
1034
- const res = await fetch(`${API_URL}/agents/${agentId}`, {
1035
- method: 'PATCH',
1036
- headers: {
1037
- 'Content-Type': 'application/json',
1038
- 'X-Session-ID': sessionId,
1039
- },
1040
- body: JSON.stringify(body),
1041
- });
1042
+ if (Object.keys(body).length > 0) {
1043
+ log('PATCH /agents/' + agentId, body);
1044
+ const res = await fetch(`${API_URL}/agents/${agentId}`, {
1045
+ method: 'PATCH',
1046
+ headers: {
1047
+ 'Content-Type': 'application/json',
1048
+ 'X-Session-ID': sessionId,
1049
+ },
1050
+ body: JSON.stringify(body),
1051
+ });
1042
1052
 
1043
- if (!res.ok) {
1044
- const err = await res.json();
1045
- logError('config', err);
1046
- throw new Error(err.error || 'Failed to update agent');
1053
+ if (!res.ok) {
1054
+ const err = await res.json();
1055
+ logError('config', err);
1056
+ throw new Error(err.error || 'Failed to update agent');
1057
+ }
1058
+ }
1059
+
1060
+ // Alias rename has its own endpoint because it also writes the
1061
+ // 30-day grace redirect into agent_alias_history.
1062
+ if (aliasChange !== undefined) {
1063
+ log('PATCH /agents/' + agentId + '/alias', { alias: aliasChange });
1064
+ const res = await fetch(`${API_URL}/agents/${agentId}/alias`, {
1065
+ method: 'PATCH',
1066
+ headers: {
1067
+ 'Content-Type': 'application/json',
1068
+ 'X-Session-ID': sessionId,
1069
+ },
1070
+ body: JSON.stringify({ alias: aliasChange }),
1071
+ });
1072
+ if (!res.ok) {
1073
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
1074
+ logError('alias', err);
1075
+ throw new Error((err as { error?: string }).error || 'Failed to set alias');
1076
+ }
1077
+ }
1078
+
1079
+ // Email toggle is a dedicated endpoint that enforces alias-first.
1080
+ if (emailEnabledChange !== undefined) {
1081
+ log('PATCH /agents/' + agentId + '/email-enabled', { enabled: emailEnabledChange });
1082
+ const res = await fetch(`${API_URL}/agents/${agentId}/email-enabled`, {
1083
+ method: 'PATCH',
1084
+ headers: {
1085
+ 'Content-Type': 'application/json',
1086
+ 'X-Session-ID': sessionId,
1087
+ },
1088
+ body: JSON.stringify({ enabled: emailEnabledChange }),
1089
+ });
1090
+ if (!res.ok) {
1091
+ const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
1092
+ logError('email-enabled', err);
1093
+ throw new Error((err as { error?: string }).error || 'Failed to toggle email');
1094
+ }
1047
1095
  }
1048
1096
 
1049
1097
  spinner.succeed('Agent config updated');
@@ -1073,6 +1121,12 @@ ${chalk.bold('Examples:')}
1073
1121
  const desc = body.description as string;
1074
1122
  changes.push(` Description: ${chalk.gray(desc.slice(0, 50))}${desc.length > 50 ? '...' : ''}`);
1075
1123
  }
1124
+ if (aliasChange !== undefined) {
1125
+ changes.push(` Alias: ${brand.green(aliasChange)}`);
1126
+ }
1127
+ if (emailEnabledChange !== undefined) {
1128
+ changes.push(` Email: ${emailEnabledChange ? brand.green('enabled 📬') : chalk.gray('disabled')}`);
1129
+ }
1076
1130
 
1077
1131
  if (changes.length > 0) {
1078
1132
  console.log('');
@@ -3105,5 +3159,613 @@ wsMembers
3105
3159
  }
3106
3160
  });
3107
3161
 
3162
+ // ========== KEYS COMMAND ==========
3163
+
3164
+ const keys = program
3165
+ .command('keys')
3166
+ .description(`Manage API keys
3167
+
3168
+ ${chalk.bold('Examples:')}
3169
+ $ gopherhole keys list
3170
+ $ gopherhole keys create --name "prod" --agent agent-abc123
3171
+ $ gopherhole keys delete key-abc123
3172
+ `);
3173
+
3174
+ keys
3175
+ .command('list')
3176
+ .description('List all API keys on the tenant')
3177
+ .option('--json', 'Output as JSON')
3178
+ .action(async (options) => {
3179
+ const sessionId = config.get('sessionId') as string;
3180
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3181
+
3182
+ const spinner = ora('Fetching API keys...').start();
3183
+ try {
3184
+ const res = await fetch(`${API_URL}/api-keys`, { headers: { 'X-Session-ID': sessionId } });
3185
+ if (!res.ok) throw new Error('Failed to fetch API keys');
3186
+ const data = await res.json() as { keys: any[] };
3187
+ const keysList = Array.isArray(data) ? data : data.keys || [];
3188
+ spinner.stop();
3189
+
3190
+ if (options.json) { console.log(JSON.stringify(keysList, null, 2)); return; }
3191
+ if (keysList.length === 0) { console.log(chalk.yellow('\nNo API keys found.\n')); return; }
3192
+
3193
+ console.log(chalk.bold('\nAPI Keys:\n'));
3194
+ for (const k of keysList) {
3195
+ console.log(` ${chalk.cyan(k.id)} — ${k.name || 'unnamed'}`);
3196
+ console.log(` Agent: ${k.agentId || k.agent_id || 'none'} | Prefix: ${chalk.gray(k.prefix || '???')} | Scopes: ${k.scopes?.join(', ') || 'default'}`);
3197
+ console.log('');
3198
+ }
3199
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3200
+ });
3201
+
3202
+ keys
3203
+ .command('create')
3204
+ .description('Create a new API key')
3205
+ .requiredOption('--name <name>', 'Human-readable label for this key')
3206
+ .requiredOption('--agent <agentId>', 'The agent this key authenticates as')
3207
+ .option('--scopes <scopes>', 'Comma-separated scopes (e.g., "messages:send,memory:read")')
3208
+ .action(async (options) => {
3209
+ const sessionId = config.get('sessionId') as string;
3210
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3211
+
3212
+ const spinner = ora('Creating API key...').start();
3213
+ try {
3214
+ const body: any = { name: options.name, agentId: options.agent };
3215
+ if (options.scopes) body.scopes = options.scopes.split(',').map((s: string) => s.trim());
3216
+ const res = await fetch(`${API_URL}/api-keys`, {
3217
+ method: 'POST',
3218
+ headers: { 'Content-Type': 'application/json', 'X-Session-ID': sessionId },
3219
+ body: JSON.stringify(body),
3220
+ });
3221
+ if (!res.ok) { const err = await res.json(); throw new Error((err as any).error || 'Failed to create key'); }
3222
+ const data = await res.json() as any;
3223
+ spinner.succeed('API key created!');
3224
+ console.log('');
3225
+ console.log(` ID: ${chalk.cyan(data.id)}`);
3226
+ console.log(` Key: ${brand.green(data.key || data.secret)}`);
3227
+ console.log('');
3228
+ console.log(chalk.yellow(' Store this key securely — it will not be shown again.'));
3229
+ console.log('');
3230
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3231
+ });
3232
+
3233
+ keys
3234
+ .command('delete <keyId>')
3235
+ .description('Revoke and delete an API key')
3236
+ .option('-y, --yes', 'Skip confirmation')
3237
+ .action(async (keyId, options) => {
3238
+ const sessionId = config.get('sessionId') as string;
3239
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3240
+
3241
+ if (!options.yes) {
3242
+ const { confirm } = await inquirer.prompt([{
3243
+ type: 'confirm', name: 'confirm', message: `Permanently revoke key ${keyId}?`, default: false,
3244
+ }]);
3245
+ if (!confirm) { console.log(chalk.gray('Cancelled.')); return; }
3246
+ }
3247
+
3248
+ const spinner = ora('Revoking key...').start();
3249
+ try {
3250
+ const res = await fetch(`${API_URL}/api-keys/${keyId}`, {
3251
+ method: 'DELETE',
3252
+ headers: { 'X-Session-ID': sessionId },
3253
+ });
3254
+ if (!res.ok) { const err = await res.json(); throw new Error((err as any).error || 'Failed to delete key'); }
3255
+ spinner.succeed(`Key ${chalk.cyan(keyId)} revoked and deleted.`);
3256
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3257
+ });
3258
+
3259
+ // ========== TEAM COMMAND ==========
3260
+
3261
+ const team = program
3262
+ .command('team')
3263
+ .description(`Manage team members
3264
+
3265
+ ${chalk.bold('Examples:')}
3266
+ $ gopherhole team list
3267
+ $ gopherhole team invite user@example.com --role admin
3268
+ $ gopherhole team remove member-abc123
3269
+ `);
3270
+
3271
+ team
3272
+ .command('list')
3273
+ .description('List team members')
3274
+ .option('--json', 'Output as JSON')
3275
+ .action(async (options) => {
3276
+ const sessionId = config.get('sessionId') as string;
3277
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3278
+
3279
+ const spinner = ora('Fetching team...').start();
3280
+ try {
3281
+ const res = await fetch(`${API_URL}/team/members`, { headers: { 'X-Session-ID': sessionId } });
3282
+ if (!res.ok) throw new Error('Failed to fetch team');
3283
+ const data = await res.json() as any;
3284
+ const members = Array.isArray(data) ? data : data.members || [];
3285
+ spinner.stop();
3286
+
3287
+ if (options.json) { console.log(JSON.stringify(members, null, 2)); return; }
3288
+ if (members.length === 0) { console.log(chalk.yellow('\nNo team members.\n')); return; }
3289
+
3290
+ console.log(chalk.bold('\nTeam Members:\n'));
3291
+ for (const m of members) {
3292
+ const role = m.role === 'admin' ? chalk.red(m.role) : chalk.gray(m.role);
3293
+ console.log(` ${chalk.bold(m.name || m.email)} (${chalk.cyan(m.id)})`);
3294
+ console.log(` Role: ${role}${m.joinedAt ? ` | Joined: ${m.joinedAt}` : ''}`);
3295
+ console.log('');
3296
+ }
3297
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3298
+ });
3299
+
3300
+ team
3301
+ .command('invite <email>')
3302
+ .description('Invite someone to your tenant')
3303
+ .option('--role <role>', 'Role: admin, member, viewer (default: member)')
3304
+ .action(async (email, options) => {
3305
+ const sessionId = config.get('sessionId') as string;
3306
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3307
+
3308
+ const spinner = ora(`Sending invite to ${email}...`).start();
3309
+ try {
3310
+ const body: any = { email };
3311
+ if (options.role) body.role = options.role;
3312
+ const res = await fetch(`${API_URL}/team/invites`, {
3313
+ method: 'POST',
3314
+ headers: { 'Content-Type': 'application/json', 'X-Session-ID': sessionId },
3315
+ body: JSON.stringify(body),
3316
+ });
3317
+ if (!res.ok) { const err = await res.json(); throw new Error((err as any).error || 'Failed to send invite'); }
3318
+ spinner.succeed(`Invitation sent to ${chalk.cyan(email)} with role: ${options.role || 'member'}`);
3319
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3320
+ });
3321
+
3322
+ team
3323
+ .command('remove <memberId>')
3324
+ .description('Remove a team member')
3325
+ .option('-y, --yes', 'Skip confirmation')
3326
+ .action(async (memberId, options) => {
3327
+ const sessionId = config.get('sessionId') as string;
3328
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3329
+
3330
+ if (!options.yes) {
3331
+ const { confirm } = await inquirer.prompt([{
3332
+ type: 'confirm', name: 'confirm', message: `Remove team member ${memberId}?`, default: false,
3333
+ }]);
3334
+ if (!confirm) { console.log(chalk.gray('Cancelled.')); return; }
3335
+ }
3336
+
3337
+ const spinner = ora('Removing member...').start();
3338
+ try {
3339
+ const res = await fetch(`${API_URL}/team/members/${memberId}`, {
3340
+ method: 'DELETE',
3341
+ headers: { 'X-Session-ID': sessionId },
3342
+ });
3343
+ if (!res.ok) { const err = await res.json(); throw new Error((err as any).error || 'Failed to remove member'); }
3344
+ spinner.succeed(`Team member ${chalk.cyan(memberId)} removed.`);
3345
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3346
+ });
3347
+
3348
+ // ========== USAGE COMMAND ==========
3349
+
3350
+ const usage = program
3351
+ .command('usage')
3352
+ .description(`View usage statistics
3353
+
3354
+ ${chalk.bold('Examples:')}
3355
+ $ gopherhole usage summary
3356
+ $ gopherhole usage summary --period week
3357
+ $ gopherhole usage agents
3358
+ `);
3359
+
3360
+ usage
3361
+ .command('summary')
3362
+ .description('Get high-level usage overview')
3363
+ .option('--period <period>', 'Time window: day, week, month (default: month)')
3364
+ .option('--json', 'Output as JSON')
3365
+ .action(async (options) => {
3366
+ const sessionId = config.get('sessionId') as string;
3367
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3368
+
3369
+ const spinner = ora('Fetching usage...').start();
3370
+ try {
3371
+ const params = new URLSearchParams();
3372
+ if (options.period) params.set('period', options.period);
3373
+ const qs = params.toString() ? `?${params}` : '';
3374
+ const res = await fetch(`${API_URL}/usage/summary${qs}`, { headers: { 'X-Session-ID': sessionId } });
3375
+ if (!res.ok) throw new Error('Failed to fetch usage');
3376
+ const data = await res.json();
3377
+ spinner.stop();
3378
+
3379
+ if (options.json) { console.log(JSON.stringify(data, null, 2)); return; }
3380
+
3381
+ console.log(chalk.bold(`\nUsage Summary (${options.period || 'month'}):\n`));
3382
+ const d = data as any;
3383
+ if (d.messagesSent !== undefined) console.log(` Messages Sent: ${brand.green(d.messagesSent)}`);
3384
+ if (d.messagesReceived !== undefined) console.log(` Messages Received: ${brand.green(d.messagesReceived)}`);
3385
+ if (d.tasksCreated !== undefined) console.log(` Tasks Created: ${brand.green(d.tasksCreated)}`);
3386
+ if (d.connections !== undefined) console.log(` Connections: ${brand.green(d.connections)}`);
3387
+ if (d.creditsUsed !== undefined) console.log(` Credits Used: ${brand.green(d.creditsUsed)}`);
3388
+ console.log('');
3389
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3390
+ });
3391
+
3392
+ usage
3393
+ .command('agents')
3394
+ .description('Per-agent usage breakdown')
3395
+ .option('--period <period>', 'Time window: day, week, month (default: month)')
3396
+ .option('--limit <n>', 'Max agents to show (default: 20)')
3397
+ .option('--json', 'Output as JSON')
3398
+ .action(async (options) => {
3399
+ const sessionId = config.get('sessionId') as string;
3400
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3401
+
3402
+ const spinner = ora('Fetching per-agent usage...').start();
3403
+ try {
3404
+ const params = new URLSearchParams();
3405
+ if (options.period) params.set('period', options.period);
3406
+ if (options.limit) params.set('limit', options.limit);
3407
+ const qs = params.toString() ? `?${params}` : '';
3408
+ const res = await fetch(`${API_URL}/usage/agents${qs}`, { headers: { 'X-Session-ID': sessionId } });
3409
+ if (!res.ok) throw new Error('Failed to fetch agent usage');
3410
+ const data = await res.json() as any;
3411
+ spinner.stop();
3412
+
3413
+ if (options.json) { console.log(JSON.stringify(data, null, 2)); return; }
3414
+
3415
+ const agents = Array.isArray(data) ? data : data.agents || [];
3416
+ if (agents.length === 0) { console.log(chalk.yellow('\nNo usage data.\n')); return; }
3417
+
3418
+ console.log(chalk.bold(`\nPer-Agent Usage (${options.period || 'month'}):\n`));
3419
+ for (const a of agents) {
3420
+ console.log(` ${chalk.bold(a.name || a.agentId)} (${chalk.cyan(a.agentId || a.id)})`);
3421
+ if (a.messages !== undefined) console.log(` Messages: ${a.messages}`);
3422
+ if (a.tasks !== undefined) console.log(` Tasks: ${a.tasks}`);
3423
+ if (a.credits !== undefined) console.log(` Credits: ${a.credits}`);
3424
+ console.log('');
3425
+ }
3426
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3427
+ });
3428
+
3429
+ // ========== WEBHOOKS COMMAND ==========
3430
+
3431
+ const webhooks = program
3432
+ .command('webhooks')
3433
+ .description(`Manage webhooks
3434
+
3435
+ ${chalk.bold('Examples:')}
3436
+ $ gopherhole webhooks list
3437
+ $ gopherhole webhooks create --url https://example.com/hook --events message.received,task.completed
3438
+ $ gopherhole webhooks delete wh-abc123
3439
+ $ gopherhole webhooks test wh-abc123
3440
+ `);
3441
+
3442
+ webhooks
3443
+ .command('list')
3444
+ .description('List configured webhooks')
3445
+ .option('--json', 'Output as JSON')
3446
+ .action(async (options) => {
3447
+ const sessionId = config.get('sessionId') as string;
3448
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3449
+
3450
+ const spinner = ora('Fetching webhooks...').start();
3451
+ try {
3452
+ const res = await fetch(`${API_URL}/webhooks`, { headers: { 'X-Session-ID': sessionId } });
3453
+ if (!res.ok) throw new Error('Failed to fetch webhooks');
3454
+ const data = await res.json() as any;
3455
+ const hooks = Array.isArray(data) ? data : data.webhooks || [];
3456
+ spinner.stop();
3457
+
3458
+ if (options.json) { console.log(JSON.stringify(hooks, null, 2)); return; }
3459
+ if (hooks.length === 0) { console.log(chalk.yellow('\nNo webhooks configured.\n')); return; }
3460
+
3461
+ console.log(chalk.bold('\nWebhooks:\n'));
3462
+ for (const h of hooks) {
3463
+ console.log(` ${chalk.cyan(h.id)} — ${h.url}`);
3464
+ console.log(` Events: ${h.events?.join(', ') || 'all'}${h.description ? ` | ${chalk.gray(h.description)}` : ''}`);
3465
+ console.log('');
3466
+ }
3467
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3468
+ });
3469
+
3470
+ webhooks
3471
+ .command('create')
3472
+ .description('Create a new webhook')
3473
+ .requiredOption('--url <url>', 'HTTPS URL to deliver events to')
3474
+ .requiredOption('--events <events>', 'Comma-separated event types (e.g., "message.received,task.completed")')
3475
+ .option('--description <text>', 'Label for this webhook')
3476
+ .option('--secret <secret>', 'Signing secret for HMAC-SHA256 verification')
3477
+ .action(async (options) => {
3478
+ const sessionId = config.get('sessionId') as string;
3479
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3480
+
3481
+ const spinner = ora('Creating webhook...').start();
3482
+ try {
3483
+ const body: any = {
3484
+ url: options.url,
3485
+ events: options.events.split(',').map((e: string) => e.trim()),
3486
+ };
3487
+ if (options.description) body.description = options.description;
3488
+ if (options.secret) body.secret = options.secret;
3489
+ const res = await fetch(`${API_URL}/webhooks`, {
3490
+ method: 'POST',
3491
+ headers: { 'Content-Type': 'application/json', 'X-Session-ID': sessionId },
3492
+ body: JSON.stringify(body),
3493
+ });
3494
+ if (!res.ok) { const err = await res.json(); throw new Error((err as any).error || 'Failed to create webhook'); }
3495
+ const data = await res.json() as any;
3496
+ spinner.succeed('Webhook created!');
3497
+ console.log(`\n ID: ${chalk.cyan(data.id)}`);
3498
+ console.log(` URL: ${data.url || options.url}`);
3499
+ console.log(` Events: ${body.events.join(', ')}\n`);
3500
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3501
+ });
3502
+
3503
+ webhooks
3504
+ .command('delete <webhookId>')
3505
+ .description('Delete a webhook')
3506
+ .option('-y, --yes', 'Skip confirmation')
3507
+ .action(async (webhookId, options) => {
3508
+ const sessionId = config.get('sessionId') as string;
3509
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3510
+
3511
+ if (!options.yes) {
3512
+ const { confirm } = await inquirer.prompt([{
3513
+ type: 'confirm', name: 'confirm', message: `Delete webhook ${webhookId}?`, default: false,
3514
+ }]);
3515
+ if (!confirm) { console.log(chalk.gray('Cancelled.')); return; }
3516
+ }
3517
+
3518
+ const spinner = ora('Deleting webhook...').start();
3519
+ try {
3520
+ const res = await fetch(`${API_URL}/webhooks/${webhookId}`, {
3521
+ method: 'DELETE',
3522
+ headers: { 'X-Session-ID': sessionId },
3523
+ });
3524
+ if (!res.ok) { const err = await res.json(); throw new Error((err as any).error || 'Failed to delete webhook'); }
3525
+ spinner.succeed(`Webhook ${chalk.cyan(webhookId)} deleted.`);
3526
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3527
+ });
3528
+
3529
+ webhooks
3530
+ .command('test <webhookId>')
3531
+ .description('Send a test event to a webhook')
3532
+ .action(async (webhookId) => {
3533
+ const sessionId = config.get('sessionId') as string;
3534
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3535
+
3536
+ const spinner = ora('Sending test event...').start();
3537
+ try {
3538
+ const res = await fetch(`${API_URL}/webhooks/${webhookId}/test`, {
3539
+ method: 'POST',
3540
+ headers: { 'X-Session-ID': sessionId },
3541
+ });
3542
+ if (!res.ok) { const err = await res.json(); throw new Error((err as any).error || 'Failed to test webhook'); }
3543
+ const data = await res.json() as any;
3544
+ spinner.succeed(`Test event sent. Response: ${data.status || data.statusCode || 'delivered'}`);
3545
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3546
+ });
3547
+
3548
+ // ========== TENANT COMMAND ==========
3549
+
3550
+ const tenant = program
3551
+ .command('tenant')
3552
+ .description(`View and manage tenant settings
3553
+
3554
+ ${chalk.bold('Examples:')}
3555
+ $ gopherhole tenant settings
3556
+ $ gopherhole tenant update --name "Acme Corp" --slug acme
3557
+ `);
3558
+
3559
+ tenant
3560
+ .command('settings')
3561
+ .description('View current tenant settings')
3562
+ .option('--json', 'Output as JSON')
3563
+ .action(async (options) => {
3564
+ const sessionId = config.get('sessionId') as string;
3565
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3566
+
3567
+ const spinner = ora('Fetching tenant settings...').start();
3568
+ try {
3569
+ const res = await fetch(`${API_URL}/auth/tenant/settings`, { headers: { 'X-Session-ID': sessionId } });
3570
+ if (!res.ok) throw new Error('Failed to fetch tenant settings');
3571
+ const data = await res.json() as any;
3572
+ spinner.stop();
3573
+
3574
+ if (options.json) { console.log(JSON.stringify(data, null, 2)); return; }
3575
+
3576
+ console.log(chalk.bold('\nTenant Settings:\n'));
3577
+ if (data.name) console.log(` Name: ${brand.green(data.name)}`);
3578
+ if (data.slug) console.log(` Slug: ${chalk.cyan(data.slug)}`);
3579
+ if (data.plan) console.log(` Plan: ${data.plan}`);
3580
+ if (data.email) console.log(` Email: ${data.email}`);
3581
+ if (data.id) console.log(` ID: ${chalk.gray(data.id)}`);
3582
+ console.log('');
3583
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3584
+ });
3585
+
3586
+ tenant
3587
+ .command('update')
3588
+ .description('Update tenant name or slug')
3589
+ .option('--name <name>', 'New display name')
3590
+ .option('--slug <slug>', 'New URL-safe slug')
3591
+ .action(async (options) => {
3592
+ const sessionId = config.get('sessionId') as string;
3593
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3594
+
3595
+ const body: any = {};
3596
+ if (options.name) body.name = options.name;
3597
+ if (options.slug) body.slug = options.slug;
3598
+ if (Object.keys(body).length === 0) {
3599
+ console.log(chalk.yellow('No changes specified. Use --name or --slug.'));
3600
+ return;
3601
+ }
3602
+
3603
+ const spinner = ora('Updating tenant...').start();
3604
+ try {
3605
+ const res = await fetch(`${API_URL}/auth/tenant`, {
3606
+ method: 'PATCH',
3607
+ headers: { 'Content-Type': 'application/json', 'X-Session-ID': sessionId },
3608
+ body: JSON.stringify(body),
3609
+ });
3610
+ if (!res.ok) { const err = await res.json(); throw new Error((err as any).error || 'Failed to update tenant'); }
3611
+ spinner.succeed('Tenant updated.');
3612
+ if (body.name) console.log(` Name: ${brand.green(body.name)}`);
3613
+ if (body.slug) console.log(` Slug: ${chalk.cyan(body.slug)}`);
3614
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3615
+ });
3616
+
3617
+ // ========== PROFILE COMMAND ==========
3618
+
3619
+ program
3620
+ .command('profile')
3621
+ .description(`Update your user profile
3622
+
3623
+ ${chalk.bold('Examples:')}
3624
+ $ gopherhole profile --name "Jane Smith"
3625
+ $ gopherhole profile --avatar https://example.com/photo.jpg
3626
+ `)
3627
+ .option('--name <name>', 'Display name')
3628
+ .option('--avatar <url>', 'Avatar image URL')
3629
+ .action(async (options) => {
3630
+ const sessionId = config.get('sessionId') as string;
3631
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3632
+
3633
+ const body: any = {};
3634
+ if (options.name) body.name = options.name;
3635
+ if (options.avatar) body.avatarUrl = options.avatar;
3636
+ if (Object.keys(body).length === 0) {
3637
+ console.log(chalk.yellow('No changes specified. Use --name or --avatar.'));
3638
+ return;
3639
+ }
3640
+
3641
+ const spinner = ora('Updating profile...').start();
3642
+ try {
3643
+ const res = await fetch(`${API_URL}/auth/me`, {
3644
+ method: 'PATCH',
3645
+ headers: { 'Content-Type': 'application/json', 'X-Session-ID': sessionId },
3646
+ body: JSON.stringify(body),
3647
+ });
3648
+ if (!res.ok) { const err = await res.json(); throw new Error((err as any).error || 'Failed to update profile'); }
3649
+ spinner.succeed('Profile updated.');
3650
+ if (body.name) console.log(` Name: ${brand.green(body.name)}`);
3651
+ if (body.avatarUrl) console.log(` Avatar: updated`);
3652
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3653
+ });
3654
+
3655
+ // ========== SECRETS COMMAND (workspace secrets) ==========
3656
+
3657
+ const secrets = program
3658
+ .command('secrets')
3659
+ .description(`Manage workspace secrets
3660
+
3661
+ ${chalk.bold('Examples:')}
3662
+ $ gopherhole secrets list ws-abc123
3663
+ $ gopherhole secrets set ws-abc123 OPENAI_API_KEY sk-abc...
3664
+ $ gopherhole secrets delete ws-abc123 OPENAI_API_KEY
3665
+ `);
3666
+
3667
+ secrets
3668
+ .command('list <workspaceId>')
3669
+ .description('List secret keys in a workspace (values are never shown)')
3670
+ .option('--json', 'Output as JSON')
3671
+ .action(async (workspaceId, options) => {
3672
+ const sessionId = config.get('sessionId') as string;
3673
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3674
+
3675
+ const spinner = ora('Fetching secrets...').start();
3676
+ try {
3677
+ const res = await fetch(`${API_URL}/workspaces/${workspaceId}/secrets`, { headers: { 'X-Session-ID': sessionId } });
3678
+ if (!res.ok) throw new Error('Failed to fetch secrets');
3679
+ const data = await res.json() as any;
3680
+ const secretsList = Array.isArray(data) ? data : data.secrets || [];
3681
+ spinner.stop();
3682
+
3683
+ if (options.json) { console.log(JSON.stringify(secretsList, null, 2)); return; }
3684
+ if (secretsList.length === 0) { console.log(chalk.yellow('\nNo secrets in this workspace.\n')); return; }
3685
+
3686
+ console.log(chalk.bold('\nWorkspace Secrets:\n'));
3687
+ for (const s of secretsList) {
3688
+ console.log(` ${chalk.cyan(s.key || s.name)}`);
3689
+ }
3690
+ console.log('');
3691
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3692
+ });
3693
+
3694
+ secrets
3695
+ .command('set <workspaceId> <key> <value>')
3696
+ .description('Create or update a workspace secret')
3697
+ .action(async (workspaceId, key, value) => {
3698
+ const sessionId = config.get('sessionId') as string;
3699
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3700
+
3701
+ const spinner = ora(`Setting secret ${key}...`).start();
3702
+ try {
3703
+ const res = await fetch(`${API_URL}/workspaces/${workspaceId}/secrets`, {
3704
+ method: 'POST',
3705
+ headers: { 'Content-Type': 'application/json', 'X-Session-ID': sessionId },
3706
+ body: JSON.stringify({ key, value }),
3707
+ });
3708
+ if (!res.ok) { const err = await res.json(); throw new Error((err as any).error || 'Failed to set secret'); }
3709
+ spinner.succeed(`Secret ${chalk.cyan(key)} stored.`);
3710
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3711
+ });
3712
+
3713
+ secrets
3714
+ .command('delete <workspaceId> <key>')
3715
+ .description('Delete a workspace secret')
3716
+ .option('-y, --yes', 'Skip confirmation')
3717
+ .action(async (workspaceId, key, options) => {
3718
+ const sessionId = config.get('sessionId') as string;
3719
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3720
+
3721
+ if (!options.yes) {
3722
+ const { confirm } = await inquirer.prompt([{
3723
+ type: 'confirm', name: 'confirm', message: `Delete secret ${key}?`, default: false,
3724
+ }]);
3725
+ if (!confirm) { console.log(chalk.gray('Cancelled.')); return; }
3726
+ }
3727
+
3728
+ const spinner = ora(`Deleting secret ${key}...`).start();
3729
+ try {
3730
+ const res = await fetch(`${API_URL}/workspaces/${workspaceId}/secrets/${key}`, {
3731
+ method: 'DELETE',
3732
+ headers: { 'X-Session-ID': sessionId },
3733
+ });
3734
+ if (!res.ok) { const err = await res.json(); throw new Error((err as any).error || 'Failed to delete secret'); }
3735
+ spinner.succeed(`Secret ${chalk.cyan(key)} deleted.`);
3736
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3737
+ });
3738
+
3739
+ // ========== CREDITS COMMAND ==========
3740
+
3741
+ program
3742
+ .command('credits')
3743
+ .description(`View credit balance
3744
+
3745
+ ${chalk.bold('Examples:')}
3746
+ $ gopherhole credits
3747
+ `)
3748
+ .option('--json', 'Output as JSON')
3749
+ .action(async (options) => {
3750
+ const sessionId = config.get('sessionId') as string;
3751
+ if (!sessionId) { console.log(chalk.yellow('Not logged in. Run: gopherhole login')); process.exit(1); }
3752
+
3753
+ const spinner = ora('Fetching balance...').start();
3754
+ try {
3755
+ const res = await fetch(`${API_URL}/credits/balance`, { headers: { 'X-Session-ID': sessionId } });
3756
+ if (!res.ok) throw new Error('Failed to fetch credits');
3757
+ const data = await res.json() as any;
3758
+ spinner.stop();
3759
+
3760
+ if (options.json) { console.log(JSON.stringify(data, null, 2)); return; }
3761
+
3762
+ console.log(chalk.bold('\nCredit Balance:\n'));
3763
+ if (data.remaining !== undefined) console.log(` Remaining: ${brand.green(data.remaining)}`);
3764
+ if (data.total !== undefined) console.log(` Total: ${data.total}`);
3765
+ if (data.used !== undefined) console.log(` Used: ${data.used}`);
3766
+ console.log('');
3767
+ } catch (err) { spinner.fail(chalk.red((err as Error).message)); process.exit(1); }
3768
+ });
3769
+
3108
3770
  // Parse and run
3109
3771
  program.parse();