@pellux/goodvibes-tui 1.24.2 → 1.26.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 (77) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/bin/goodvibes-daemon +24 -0
  4. package/docs/foundation-artifacts/operator-contract.json +18234 -12253
  5. package/package.json +8 -7
  6. package/src/cli/help.ts +10 -0
  7. package/src/cli/redaction.ts +68 -3
  8. package/src/config/goodvibes-home.ts +149 -0
  9. package/src/config/secret-config.ts +64 -8
  10. package/src/core/turn-event-wiring.ts +7 -16
  11. package/src/core/workstream-notification.ts +69 -0
  12. package/src/daemon/cli.ts +62 -7
  13. package/src/daemon/handlers/credentials.ts +15 -5
  14. package/src/daemon/handlers/index.ts +7 -6
  15. package/src/daemon/send/channels.ts +200 -0
  16. package/src/daemon/send/command.ts +332 -0
  17. package/src/daemon/send/composition.ts +99 -0
  18. package/src/daemon/send/failure-text.ts +93 -0
  19. package/src/daemon/send/inert-text.ts +225 -0
  20. package/src/daemon/send/stdin.ts +24 -0
  21. package/src/input/commands/calendar-runtime.ts +245 -0
  22. package/src/input/commands/config.ts +68 -2
  23. package/src/input/commands/connection-status.ts +258 -0
  24. package/src/input/commands/local-provider-runtime.ts +9 -1
  25. package/src/input/commands/mail-runtime.ts +267 -0
  26. package/src/input/commands/operator-runtime.ts +6 -1
  27. package/src/input/commands/payment-card-intake.ts +200 -0
  28. package/src/input/commands/profile-render.ts +250 -0
  29. package/src/input/commands/profile-runtime.ts +362 -0
  30. package/src/input/commands/profile-types.ts +381 -0
  31. package/src/input/commands/review-runtime.ts +13 -1
  32. package/src/input/commands.ts +11 -0
  33. package/src/input/composer-editor.ts +4 -0
  34. package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
  35. package/src/input/payments-config.ts +167 -0
  36. package/src/input/provider-key-intake.ts +10 -2
  37. package/src/input/settings-modal-commit.ts +146 -0
  38. package/src/input/settings-modal-connections.ts +102 -0
  39. package/src/input/settings-modal-data.ts +30 -0
  40. package/src/input/settings-modal-reset.ts +6 -2
  41. package/src/input/settings-modal-secrets.ts +9 -2
  42. package/src/input/settings-modal-types.ts +19 -3
  43. package/src/input/settings-modal.ts +54 -107
  44. package/src/main.ts +7 -2
  45. package/src/panels/modals/marketplace-modal.ts +5 -0
  46. package/src/panels/modals/skills-modal.ts +5 -0
  47. package/src/renderer/settings-modal-connections.ts +63 -0
  48. package/src/renderer/settings-modal-helpers.ts +6 -0
  49. package/src/renderer/settings-modal.ts +21 -1
  50. package/src/runtime/bootstrap-core.ts +1 -1
  51. package/src/runtime/bootstrap.ts +2 -2
  52. package/src/runtime/context.ts +9 -0
  53. package/src/runtime/credential-composition.ts +54 -0
  54. package/src/runtime/daemon-handler-composition.ts +0 -4
  55. package/src/runtime/install-self-check-startup.ts +23 -0
  56. package/src/runtime/mail-composition.ts +65 -0
  57. package/src/runtime/onboarding/types.ts +6 -1
  58. package/src/runtime/path-shadow-startup.ts +243 -0
  59. package/src/runtime/path-shadow.ts +418 -0
  60. package/src/runtime/reachability-notice.ts +100 -0
  61. package/src/runtime/runtime-services-types.ts +243 -0
  62. package/src/runtime/services.ts +30 -224
  63. package/src/shell/ui-openers.ts +7 -0
  64. package/src/verification/verification-ledger.ts +158 -0
  65. package/src/version.ts +1 -1
  66. package/src/daemon/handlers/calendar/caldav-client.ts +0 -662
  67. package/src/daemon/handlers/calendar/ics.ts +0 -556
  68. package/src/daemon/handlers/calendar/index.ts +0 -316
  69. package/src/daemon/handlers/email/config.ts +0 -164
  70. package/src/daemon/handlers/email/imap-connector.ts +0 -441
  71. package/src/daemon/handlers/email/imap-parsing.ts +0 -499
  72. package/src/daemon/handlers/email/index.ts +0 -43
  73. package/src/daemon/handlers/email/read-handlers.ts +0 -80
  74. package/src/daemon/handlers/email/runtime.ts +0 -140
  75. package/src/daemon/handlers/email/smtp-connector.ts +0 -557
  76. package/src/daemon/handlers/email/validation.ts +0 -147
  77. package/src/daemon/handlers/email/write-handlers.ts +0 -133
@@ -0,0 +1,267 @@
1
+ /**
2
+ * mail-runtime.ts — `/mail`, the terminal surface over the daemon's mail
3
+ * capability.
4
+ *
5
+ * This command implements no mail. Every verb here is one call into the
6
+ * gateway method catalog, where `src/daemon/handlers/email/` owns IMAP, SMTP,
7
+ * settings resolution, the credential read, and the confirmation gate. What
8
+ * lives in this file is argument parsing, rendering, and an honest refusal when
9
+ * the capability is not set up — nothing else.
10
+ *
11
+ * ## What is exposed, and what is not
12
+ *
13
+ * Reading (`list`, `read`) and drafting are exposed because checking mail
14
+ * without leaving the terminal is squarely what this product is for, and
15
+ * because a draft changes nothing outside the account's own Drafts folder.
16
+ *
17
+ * Sending is exposed too — a shipped route with no way to reach it is a route
18
+ * that is not wired up — but never in one step. `/mail send` first renders
19
+ * exactly what would leave the machine and stops; only a re-run carrying
20
+ * `--confirm` sets both flags the daemon's gate demands. That mirrors the
21
+ * preview-then-confirm shape `/review` uses for hunk reverts, and it means the
22
+ * dangerous action cannot happen from a typo or a recalled line of history.
23
+ *
24
+ * Message bodies are attacker-controlled text. They are printed as transcript
25
+ * output, which is display-only and never enters the model's message history,
26
+ * so a body cannot smuggle instructions into a turn by being read here.
27
+ */
28
+
29
+ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
30
+ import {
31
+ EXPLICIT_WRITE_INVOCATION_CONTEXT,
32
+ READ_INVOCATION_CONTEXT,
33
+ errorText,
34
+ probeConnection,
35
+ renderConnectionStatus,
36
+ } from './connection-status.ts';
37
+
38
+ /** Inbox summary, exactly the daemon's `email.inbox.list` item shape. */
39
+ interface InboxMessage {
40
+ readonly uid: number;
41
+ readonly from: string;
42
+ readonly subject: string;
43
+ readonly date: string;
44
+ readonly unread: boolean;
45
+ readonly bodyPreview: string;
46
+ }
47
+
48
+ /** Full message, exactly the daemon's `email.inbox.read` output shape. */
49
+ interface FullMessage {
50
+ readonly uid: number;
51
+ readonly from: string;
52
+ readonly subject: string;
53
+ readonly date: string;
54
+ readonly bodyText?: string;
55
+ readonly attachments?: ReadonlyArray<{ readonly filename: string; readonly sizeBytes: number }>;
56
+ }
57
+
58
+ const DEFAULT_LIST_LIMIT = 15;
59
+ const MAX_LIST_LIMIT = 100;
60
+
61
+ function truncate(value: string, width: number): string {
62
+ const flat = value.replace(/\s+/g, ' ').trim();
63
+ return flat.length <= width ? flat : `${flat.slice(0, width - 1)}…`;
64
+ }
65
+
66
+ export function renderInboxList(messages: readonly InboxMessage[]): string {
67
+ if (messages.length === 0) return 'Inbox is empty (nothing matched).';
68
+ const lines = [`Inbox — ${messages.length} message${messages.length === 1 ? '' : 's'}:`];
69
+ for (const message of messages) {
70
+ const mark = message.unread ? '●' : ' ';
71
+ lines.push(` ${mark} ${String(message.uid).padStart(6)} ${truncate(message.from, 28).padEnd(28)} ${truncate(message.subject, 48)}`);
72
+ }
73
+ lines.push('', ' /mail read <uid> — open one message');
74
+ return lines.join('\n');
75
+ }
76
+
77
+ export function renderMessage(message: FullMessage): string {
78
+ const lines = [
79
+ `From: ${message.from}`,
80
+ `Subject: ${message.subject}`,
81
+ `Date: ${message.date}`,
82
+ ];
83
+ const attachments = message.attachments ?? [];
84
+ if (attachments.length > 0) {
85
+ lines.push(`Files: ${attachments.map((a) => `${a.filename} (${a.sizeBytes} bytes)`).join(', ')}`);
86
+ }
87
+ lines.push('', message.bodyText && message.bodyText.trim().length > 0
88
+ ? message.bodyText.trimEnd()
89
+ : '(no plain-text body)');
90
+ return lines.join('\n');
91
+ }
92
+
93
+ /**
94
+ * Split `to | subject | body` on unescaped pipes.
95
+ *
96
+ * A pipe rather than positional words because a subject and a body both
97
+ * contain spaces, and quoting rules invented per-command are their own bug
98
+ * source. Exactly three fields are required, so a missing one is a clear
99
+ * usage error rather than a message sent with an empty subject.
100
+ */
101
+ export function parseComposeArgs(raw: string): { to: string; subject: string; body: string } | null {
102
+ const parts = raw.split('|').map((part) => part.trim());
103
+ if (parts.length !== 3) return null;
104
+ const [to, subject, body] = parts;
105
+ if (!to || !subject || !body) return null;
106
+ return { to, subject, body };
107
+ }
108
+
109
+ function parseLimit(raw: string | undefined): number {
110
+ const parsed = Number(raw);
111
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIST_LIMIT;
112
+ return Math.min(MAX_LIST_LIMIT, Math.floor(parsed));
113
+ }
114
+
115
+ const USAGE = [
116
+ 'Usage:',
117
+ ' /mail connection status and the latest messages',
118
+ ' /mail status connection status only',
119
+ ' /mail list [n] list the n most recent messages (default 15)',
120
+ ' /mail read <uid> open one message',
121
+ ' /mail draft <to | subject | body> save a draft to the account',
122
+ ' /mail send <to | subject | body> [--confirm] preview, then send',
123
+ ].join('\n');
124
+
125
+ export function registerMailRuntimeCommands(registry: CommandRegistry): void {
126
+ registry.register({
127
+ name: 'mail',
128
+ aliases: ['email'],
129
+ description: 'Mail over the daemon: connection status, inbox, read, draft, send',
130
+ usage: '[status|list|read|draft|send] …',
131
+ argsHint: '[status|list <n>|read <uid>|draft|send]',
132
+ async handler(args, ctx) {
133
+ const gateway = ctx.workspace.gatewayMethods;
134
+ const sub = (args[0] ?? '').toLowerCase();
135
+
136
+ if (sub === 'status') {
137
+ ctx.print(renderConnectionStatus(await probeConnection(gateway, 'mail')));
138
+ return;
139
+ }
140
+
141
+ if (!gateway) {
142
+ ctx.print(renderConnectionStatus(await probeConnection(undefined, 'mail')));
143
+ return;
144
+ }
145
+
146
+ if (sub === '' || sub === 'list') {
147
+ await listInbox(ctx, gateway, parseLimit(args[1]), sub === '');
148
+ return;
149
+ }
150
+ if (sub === 'read') {
151
+ await readMessage(ctx, gateway, args[1]);
152
+ return;
153
+ }
154
+ if (sub === 'draft' || sub === 'send') {
155
+ await compose(ctx, gateway, sub, args.slice(1));
156
+ return;
157
+ }
158
+ ctx.print(USAGE);
159
+ },
160
+ });
161
+ }
162
+
163
+ type Gateway = NonNullable<CommandContext['workspace']['gatewayMethods']>;
164
+
165
+ /**
166
+ * Every failure path renders the connection status rather than a bare error:
167
+ * "not configured" is far more often the real cause than a transient fault,
168
+ * and the status carries the exact next step.
169
+ */
170
+ async function withStatusOnFailure(
171
+ ctx: CommandContext,
172
+ gateway: Gateway,
173
+ label: string,
174
+ run: () => Promise<void>,
175
+ ): Promise<void> {
176
+ try {
177
+ await run();
178
+ } catch (error) {
179
+ ctx.print(`[mail ${label}] ${errorText(error)}`);
180
+ ctx.print(renderConnectionStatus(await probeConnection(gateway, 'mail')));
181
+ }
182
+ }
183
+
184
+ async function listInbox(ctx: CommandContext, gateway: Gateway, limit: number, withStatus: boolean): Promise<void> {
185
+ await withStatusOnFailure(ctx, gateway, 'list', async () => {
186
+ const result = await gateway.invoke('email.inbox.list', {
187
+ ...READ_INVOCATION_CONTEXT,
188
+ body: { limit },
189
+ }) as { readonly messages?: readonly InboxMessage[] };
190
+ if (withStatus) {
191
+ ctx.print(renderConnectionStatus({
192
+ surface: 'mail',
193
+ state: 'ready',
194
+ detail: 'Mail is connected — the daemon reached the server and returned a result.',
195
+ nextActions: [],
196
+ }));
197
+ }
198
+ ctx.print(renderInboxList(result.messages ?? []));
199
+ });
200
+ }
201
+
202
+ async function readMessage(ctx: CommandContext, gateway: Gateway, rawUid: string | undefined): Promise<void> {
203
+ const uid = Number(rawUid);
204
+ if (!Number.isFinite(uid) || uid <= 0) {
205
+ ctx.print('Usage: /mail read <uid> — the uid column from /mail list.');
206
+ return;
207
+ }
208
+ await withStatusOnFailure(ctx, gateway, 'read', async () => {
209
+ const message = await gateway.invoke('email.inbox.read', {
210
+ ...READ_INVOCATION_CONTEXT,
211
+ body: { uid },
212
+ }) as FullMessage;
213
+ ctx.print(renderMessage(message));
214
+ });
215
+ }
216
+
217
+ async function compose(ctx: CommandContext, gateway: Gateway, verb: 'draft' | 'send', rest: readonly string[]): Promise<void> {
218
+ const raw = rest.filter((part) => part !== '--confirm').join(' ');
219
+ const confirmed = rest.includes('--confirm');
220
+ const parsed = parseComposeArgs(raw);
221
+ if (parsed === null) {
222
+ ctx.print([
223
+ `Usage: /mail ${verb} <to | subject | body>`,
224
+ ' Three fields separated by | — for example:',
225
+ ` /mail ${verb} me@example.com | Build is green | All gates passed.`,
226
+ ].join('\n'));
227
+ return;
228
+ }
229
+
230
+ if (verb === 'draft') {
231
+ await withStatusOnFailure(ctx, gateway, 'draft', async () => {
232
+ // No `confirm` in the body: the SDK contract for draft.create does not
233
+ // declare one (its input schema is closed), and the handler is registered
234
+ // without the confirmation wrapper. Sending one would be a field the
235
+ // schema rejects.
236
+ const result = await gateway.invoke('email.draft.create', {
237
+ ...EXPLICIT_WRITE_INVOCATION_CONTEXT,
238
+ body: { to: parsed.to, subject: parsed.subject, body: parsed.body },
239
+ }) as { readonly uid: number; readonly draftId: string };
240
+ ctx.print(`Draft saved to the account (uid ${result.uid}). Nothing was sent.`);
241
+ });
242
+ return;
243
+ }
244
+
245
+ // Send: preview first, always. The daemon would refuse an unconfirmed call
246
+ // anyway; showing exactly what would leave the machine is the point.
247
+ if (!confirmed) {
248
+ ctx.print([
249
+ 'This would send:',
250
+ ` To: ${parsed.to}`,
251
+ ` Subject: ${parsed.subject}`,
252
+ '',
253
+ parsed.body,
254
+ '',
255
+ 'Nothing has been sent. Re-run the same line with --confirm to send it.',
256
+ ].join('\n'));
257
+ return;
258
+ }
259
+
260
+ await withStatusOnFailure(ctx, gateway, 'send', async () => {
261
+ const result = await gateway.invoke('email.send', {
262
+ ...EXPLICIT_WRITE_INVOCATION_CONTEXT,
263
+ body: { to: parsed.to, subject: parsed.subject, body: parsed.body, confirm: true },
264
+ }) as { readonly messageId: string; readonly sentAt: string };
265
+ ctx.print(`Sent to ${parsed.to} at ${result.sentAt} (message id ${result.messageId}).`);
266
+ });
267
+ }
@@ -65,7 +65,12 @@ export function registerOperatorRuntimeCommands(registry: CommandRegistry): void
65
65
 
66
66
  registry.register({
67
67
  name: 'profiles',
68
- aliases: ['profile'],
68
+ // The singular `profile` alias was given up to /profile, the owner profile
69
+ // (docs/owner-profile.md). These are two unrelated things — this command
70
+ // loads saved display/provider settings presets, and the other one is what
71
+ // the platform knows about the person who owns it — and the singular word
72
+ // now belongs to the second. `/profiles` is unchanged; only the abbreviation
73
+ // moved. See the naming note in docs/owner-profile.md §3.
69
74
  description: 'Browse and load config profiles',
70
75
  handler(_args, ctx) {
71
76
  if (ctx.openProfilePicker) {
@@ -0,0 +1,200 @@
1
+ /**
2
+ * /payments card — capture a payment card through the composer's concealed
3
+ * input mode, following the same guided-chain pattern as /channel pair
4
+ * (channel-pairing.ts) and the SAME concealed-input mechanism as
5
+ * provider-key-intake.ts (see concealed-input.ts).
6
+ *
7
+ * Card number, expiry, CVV, and cardholder name are secret-tier (see
8
+ * config/secret-config.ts's SECRET_CONFIG_KEYS): each is entered through one
9
+ * masked concealed-input prompt in turn and stored via
10
+ * persistSecretBackedConfigValue, the same secret-manager + config-reference
11
+ * path every other secret-backed setting in this app uses. The plaintext
12
+ * never reaches the transcript, input history, or a log line — only a
13
+ * redacted confirmation is printed after each field.
14
+ *
15
+ * Billing and shipping address are ordinary (non-secret) config fields, now
16
+ * real structured CONFIG_SCHEMA entries (payments.billingAddress.{name,
17
+ * line1, line2, city, region, postalCode, country} and the shippingAddress
18
+ * equivalents) — this command points the user at the Payments settings
19
+ * category (or /config payments) to set them, the same way /channel pair
20
+ * directs non-secret credential fields, rather than routing plain text
21
+ * through the concealed (masked) input path where it does not belong.
22
+ *
23
+ * Stored with `scope: 'daemon'`: the daemon — not just this interactive
24
+ * client — is what completes an autonomous purchase, so the credential must
25
+ * land in the daemon-scoped secret tier the daemon actually reads.
26
+ *
27
+ * The config-reference half (the `goodvibes://secrets/...` marker
28
+ * persistSecretBackedConfigValue writes alongside the secret) goes through
29
+ * `ctx.platform.configManager` directly — the same real ConfigManager every
30
+ * other command in this app uses, not a TUI-local store. See
31
+ * payments-config.ts's header comment for why these particular keys
32
+ * (`payments.cardNumber`, etc., flat sub-keys under the SDK's real
33
+ * `payments` section) are ConfigManager-safe even though the SDK's
34
+ * CONFIG_SCHEMA has no scalar entry for them yet: `payments.` is a
35
+ * daemon-owned config prefix, so the reference lands in the daemon's own
36
+ * settings file and is visible to the daemon, the webui and the agent —
37
+ * not just this TUI session.
38
+ *
39
+ * ── Card details are entered only at a local terminal or the webui ────────
40
+ *
41
+ * Owner ruling (see the SDK's platform/payments/entry-surface.ts): card
42
+ * material may be TYPED only on `tui`, `agent-terminal`, or `webui` — never
43
+ * over Telegram, ntfy, Discord, Slack, WhatsApp, Signal, a webhook, or any
44
+ * other remote messaging surface. A card number typed into a hosted chat is
45
+ * stored on that provider's own servers, in history this app cannot erase,
46
+ * and it already passed through their infrastructure before reaching us —
47
+ * our own encryption at rest does nothing for a value copied elsewhere on
48
+ * its way in. This is a SEPARATE question from which surfaces may APPROVE or
49
+ * VETO a purchase (every command-authority channel still can); the two are
50
+ * never merged into one check.
51
+ *
52
+ * This command always runs inside the TUI's own composer (CARD_ENTRY_SURFACE
53
+ * below), which the SDK's allowlist accepts — so this gate changes nothing
54
+ * about today's behavior. What it buys is that the refusal already exists
55
+ * the moment this app (or a shared command registry) ever grows a path that
56
+ * lets `/payments card` be reached from somewhere else: the check is real
57
+ * and SDK-driven, not an implicit "this is the TUI so it's fine" assumption.
58
+ */
59
+
60
+ import type { CommandRegistry, CommandContext } from '../command-registry.ts';
61
+ import { persistSecretBackedConfigValue } from '../../config/secret-config.ts';
62
+ import type { ConfigKey } from '../../config/index.ts';
63
+ import { describeCardEntryRefusal, mayOfferCardEntryFlow } from '@pellux/goodvibes-sdk/platform/payments';
64
+ import {
65
+ PAYMENTS_CARD_NUMBER_CONFIG_KEY,
66
+ PAYMENTS_CARD_EXPIRY_CONFIG_KEY,
67
+ PAYMENTS_CARD_CVV_CONFIG_KEY,
68
+ PAYMENTS_CARD_CARDHOLDER_NAME_CONFIG_KEY,
69
+ } from '../payments-config.ts';
70
+
71
+ /**
72
+ * This command is wired only into the TUI's own composer command registry —
73
+ * it is never reachable from a channel bridge (Telegram, Discord, ...), each
74
+ * of which has its own separate command surface in the daemon. The constant
75
+ * still names the surface explicitly and is checked through the SDK's own
76
+ * allowlist below, rather than skipping the check because "this is the TUI".
77
+ */
78
+ export const CARD_ENTRY_SURFACE = 'tui';
79
+
80
+ interface CardField {
81
+ readonly key: ConfigKey;
82
+ readonly label: string;
83
+ readonly placeholder: string;
84
+ }
85
+
86
+ /** Order matters only for the guided prompt sequence, not for storage. */
87
+ const CARD_SECRET_FIELDS: readonly CardField[] = [
88
+ { key: PAYMENTS_CARD_NUMBER_CONFIG_KEY, label: 'Card number', placeholder: '4242424242424242' },
89
+ { key: PAYMENTS_CARD_EXPIRY_CONFIG_KEY, label: 'Expiry (MM/YY)', placeholder: '12/34' },
90
+ { key: PAYMENTS_CARD_CVV_CONFIG_KEY, label: 'CVV', placeholder: '123' },
91
+ { key: PAYMENTS_CARD_CARDHOLDER_NAME_CONFIG_KEY, label: 'Cardholder name', placeholder: 'as printed on the card' },
92
+ ];
93
+
94
+ function fieldConfigured(ctx: CommandContext, field: CardField): boolean {
95
+ const raw: unknown = ctx.platform.configManager.get(field.key);
96
+ return typeof raw === 'string' && raw.trim().length > 0;
97
+ }
98
+
99
+ function renderCardStatus(ctx: CommandContext): string[] {
100
+ const lines = ['Payment card on file:'];
101
+ for (const field of CARD_SECRET_FIELDS) {
102
+ lines.push(` ${field.label.padEnd(20)} ${fieldConfigured(ctx, field) ? 'set' : 'not set'}`);
103
+ }
104
+ lines.push('');
105
+ lines.push('Run /payments card to enter or replace the card (masked input, chained prompts).');
106
+ lines.push('Billing/shipping address, budgets, windows and the rest of the payment capability are ordinary config values: set them via /config payments or the Settings > Payments category.');
107
+ return lines;
108
+ }
109
+
110
+ /** Chain a masked prompt for each card secret field, storing each through the daemon-scoped secret tier. */
111
+ function promptCardFields(ctx: CommandContext, fields: readonly CardField[], index: number): void {
112
+ if (index >= fields.length) {
113
+ ctx.print('\nCard stored. Billing/shipping address and the rest of the payment capability are ordinary config values — set them via /config payments or the Settings > Payments category.');
114
+ ctx.renderRequest();
115
+ return;
116
+ }
117
+ const field = fields[index]!;
118
+ if (!ctx.beginConcealedInput) {
119
+ ctx.print('[payments] Concealed input is unavailable on this surface; card entry requires it and cannot fall back to plaintext.');
120
+ return;
121
+ }
122
+ ctx.print(`[payments] Enter ${field.label} (e.g. ${field.placeholder}) — masked; Enter to store, Esc to stop.`);
123
+ ctx.beginConcealedInput({
124
+ label: field.label,
125
+ onSubmit: (value) => {
126
+ if (value.length === 0) {
127
+ ctx.print(`[payments] ${field.label} left unset.`);
128
+ promptCardFields(ctx, fields, index + 1);
129
+ return;
130
+ }
131
+ void persistSecretBackedConfigValue(
132
+ ctx.platform.configManager,
133
+ ctx.platform.secretsManager,
134
+ field.key,
135
+ value,
136
+ { scope: 'daemon' },
137
+ )
138
+ .then(() => {
139
+ ctx.print(`[payments] ${field.label} stored securely (hidden).`);
140
+ promptCardFields(ctx, fields, index + 1);
141
+ })
142
+ .catch((error: unknown) => {
143
+ ctx.print(`[payments] Failed to store ${field.label}: ${error instanceof Error ? error.message : String(error)}`);
144
+ promptCardFields(ctx, fields, index + 1);
145
+ });
146
+ },
147
+ onCancel: () => {
148
+ ctx.print(`[payments] Stopped. Re-run /payments card to finish the remaining fields.`);
149
+ },
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Start the card-entry flow, gated on the SDK's own entry-surface allowlist
155
+ * rather than an assumption baked into this command. `surface` defaults to
156
+ * this command's real, fixed identity (CARD_ENTRY_SURFACE) — exposed as a
157
+ * parameter only so tests can drive the refusal path without needing this
158
+ * app to actually be reachable from a remote channel.
159
+ */
160
+ export function startCardEntryFlow(ctx: CommandContext, surface: string = CARD_ENTRY_SURFACE): void {
161
+ if (!mayOfferCardEntryFlow(surface)) {
162
+ ctx.print(describeCardEntryRefusal(surface));
163
+ return;
164
+ }
165
+ if (!ctx.beginConcealedInput) {
166
+ ctx.print('[payments] Concealed input is unavailable on this surface.');
167
+ return;
168
+ }
169
+ ctx.print(`Entering ${CARD_SECRET_FIELDS.length} card field(s) — masked; Esc to stop at any point.`);
170
+ promptCardFields(ctx, CARD_SECRET_FIELDS, 0);
171
+ }
172
+
173
+ /** Entry point for `/payments [card|status]` — exported directly so tests can drive it without the registry. */
174
+ export function runPaymentsCommand(args: readonly string[], ctx: CommandContext): void {
175
+ const sub = (args[0] ?? '').toLowerCase();
176
+ if (sub === '' || sub === 'status') {
177
+ ctx.print(renderCardStatus(ctx).join('\n'));
178
+ return;
179
+ }
180
+ if (sub === 'card') {
181
+ startCardEntryFlow(ctx);
182
+ return;
183
+ }
184
+ ctx.print('Usage: /payments [card|status]');
185
+ }
186
+
187
+ /** Card secret fields in prompt order — exported for tests that need to drive the chain field-by-field. */
188
+ export { CARD_SECRET_FIELDS };
189
+
190
+ export function registerPaymentCardCommands(registry: CommandRegistry): void {
191
+ registry.register({
192
+ name: 'payments',
193
+ description: 'Payment card on file for daemon-initiated purchases (card entry is masked input)',
194
+ usage: '/payments [card]',
195
+ argsHint: '[card]',
196
+ handler(args, ctx) {
197
+ runPaymentsCommand(args, ctx);
198
+ },
199
+ });
200
+ }