@shardflux/cli 0.14.4 → 0.15.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 (59) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +73 -11
  3. package/dist/account-args.js +28 -7
  4. package/dist/account-commands.js +140 -11
  5. package/dist/acp-catalog.js +2 -2
  6. package/dist/acp-login.d.ts +20 -0
  7. package/dist/acp-login.js +122 -0
  8. package/dist/acp.js +36 -13
  9. package/dist/agent-commands.js +58 -6
  10. package/dist/agent-git-commands.js +2 -1
  11. package/dist/agent-reachability.js +1 -1
  12. package/dist/agent-target-commands.js +26 -1
  13. package/dist/agent-targets.d.ts +7 -0
  14. package/dist/agent-targets.js +44 -32
  15. package/dist/agent-terminal.d.ts +24 -2
  16. package/dist/agent-terminal.js +82 -4
  17. package/dist/args.d.ts +3 -1
  18. package/dist/args.js +21 -9
  19. package/dist/browser-login.d.ts +35 -0
  20. package/dist/browser-login.js +145 -0
  21. package/dist/claude-cloud-login.js +2 -2
  22. package/dist/codex-cloud.d.ts +49 -0
  23. package/dist/codex-cloud.js +157 -0
  24. package/dist/command-center-actions.js +7 -0
  25. package/dist/command-center-renderer.d.ts +3 -0
  26. package/dist/command-center-renderer.js +42 -38
  27. package/dist/command-center.js +65 -11
  28. package/dist/conversation-terminal.js +19 -0
  29. package/dist/conversations.d.ts +1 -1
  30. package/dist/conversations.js +21 -8
  31. package/dist/credential-prompts.d.ts +4 -2
  32. package/dist/credential-prompts.js +38 -16
  33. package/dist/device-bridge.d.ts +13 -0
  34. package/dist/device-bridge.js +14 -4
  35. package/dist/device-helper.js +2 -1
  36. package/dist/errors.d.ts +9 -0
  37. package/dist/errors.js +15 -2
  38. package/dist/handlers.js +2 -1
  39. package/dist/initial-prompt.d.ts +26 -0
  40. package/dist/initial-prompt.js +196 -0
  41. package/dist/legal.d.ts +36 -0
  42. package/dist/legal.js +44 -0
  43. package/dist/main.js +6 -2
  44. package/dist/menubar.d.ts +8 -0
  45. package/dist/menubar.js +9 -1
  46. package/dist/onboarding-app/AGENTS.md +19 -0
  47. package/dist/onboarding-app/CLAUDE.md +19 -0
  48. package/dist/onboarding-app/README.md +12 -0
  49. package/dist/onboarding-app/dev.mjs +18 -0
  50. package/dist/onboarding-app/index.html +25 -0
  51. package/dist/onboarding-app/package.json +12 -0
  52. package/dist/onboarding-app/server.mjs +41 -0
  53. package/dist/onboarding-sample.d.ts +3 -0
  54. package/dist/onboarding-sample.js +29 -0
  55. package/dist/onboarding.d.ts +258 -0
  56. package/dist/onboarding.js +371 -0
  57. package/dist/terminal-brand.d.ts +5 -0
  58. package/dist/terminal-brand.js +18 -0
  59. package/package.json +3 -3
@@ -21,12 +21,20 @@ export class SiteVaultStore {
21
21
  throw new UsageError('Could not read site vault choices.');
22
22
  return raw;
23
23
  }
24
- get(api, project, site) {
25
- return this.read()[this.key(api, project, site)];
24
+ get(api, project, site, profile) {
25
+ const all = this.read();
26
+ return all[this.key(api, project, site)] ?? (profile ? all[this.key(api, `machine:${profile}`, site)] : undefined);
26
27
  }
27
- save(api, project, site, selection) {
28
+ save(api, project, site, selection, profile) {
28
29
  const all = this.read();
29
- all[this.key(api, project, site)] = selection;
30
+ if (selection.scope === 'machine') {
31
+ if (!profile)
32
+ throw new UsageError('Sign in or save a project profile before choosing machine scope.');
33
+ delete all[this.key(api, project, site)];
34
+ all[this.key(api, `machine:${profile}`, site)] = selection;
35
+ }
36
+ else
37
+ all[this.key(api, project, site)] = selection;
30
38
  mkdirSync(join(this.file, '..'), { recursive: true, mode: 0o700 });
31
39
  const temp = `${this.file}.${process.pid}.tmp`;
32
40
  writeFileSync(temp, JSON.stringify(all), { mode: 0o600, flag: 'wx' });
@@ -34,9 +42,20 @@ export class SiteVaultStore {
34
42
  chmodSync(this.file, 0o600);
35
43
  }
36
44
  }
45
+ function vaultProfile(ctx) {
46
+ const profile = ctx.store.profile(ctx.apiUrl);
47
+ return profile.organization_id ? `org:${profile.organization_id}${profile.session?.user?.id ? `|user:${profile.session.user.id}` : ""}` : profile.session?.user?.id ? `user:${profile.session.user.id}` : profile.api_key?.key_id ? `key:${profile.api_key.key_id}` : undefined;
48
+ }
49
+ function approvalScope(value) {
50
+ if (value !== undefined && !['ask', 'project', 'machine'].includes(value))
51
+ throw new UsageError('--scope must be ask, project or machine');
52
+ return value;
53
+ }
37
54
  export async function approveCredential(ctx, request, selection, remember = false) {
38
55
  if (request.state !== 'pending' || Date.parse(request.expires_at) <= Date.now())
39
56
  throw new UsageError(`Credential request ${request.id} has expired or ended. ${VAULT_SECURITY_NOTE}`);
57
+ if (selection.scope === 'machine' && !vaultProfile(ctx))
58
+ throw new UsageError('Sign in or save a project profile before choosing machine scope.');
40
59
  const credential = await vaultCredential(selection.vault, selection.item, request.site, { signal: ctx.signal });
41
60
  for (const value of Object.values(credential))
42
61
  if (typeof value === 'string' && value)
@@ -50,8 +69,8 @@ export async function approveCredential(ctx, request, selection, remember = fals
50
69
  if (answered.state !== 'fulfilled')
51
70
  throw new UsageError(`Credential request is ${answered.state}. ${VAULT_SECURITY_NOTE}`);
52
71
  if (remember)
53
- await ctx.cloud().credentialRequests.remember(request.workspace_id, request.site, credential);
54
- new SiteVaultStore(ctx.store.dir).save(ctx.apiUrl, request.project_id, request.site, selection);
72
+ await ctx.cloud().credentialRequests.remember(request.workspace_id, request.site, subset, selection.scope ? { scope: selection.scope === 'ask' ? 'conversation' : selection.scope } : {});
73
+ new SiteVaultStore(ctx.store.dir).save(ctx.apiUrl, request.project_id, request.site, { ...selection, always: selection.scope ? selection.scope !== 'ask' : selection.always, remember }, vaultProfile(ctx));
55
74
  ctx.io.stderr.write(`Approved ${request.site}. ${remember ? REMEMBERED_SECURITY_NOTE : VAULT_SECURITY_NOTE}\n`);
56
75
  }
57
76
  export async function configureCredentialFill(ctx, ws) {
@@ -88,23 +107,23 @@ export async function watchCredentialPrompts(ctx, ws, ask, signal) {
88
107
  continue;
89
108
  seen.add(r.id);
90
109
  try {
91
- const saved = store.get(ctx.apiUrl, r.project_id, r.site);
92
- if (saved?.always && r.fields.every((f) => saved.fields.includes(f))) {
93
- await approveCredential(ctx, r, saved);
110
+ const saved = store.get(ctx.apiUrl, r.project_id, r.site, vaultProfile(ctx));
111
+ if (saved && (saved.scope ? saved.scope !== 'ask' : saved.always) && r.fields.every((f) => saved.fields.includes(f))) {
112
+ await approveCredential(ctx, r, saved, saved.remember);
94
113
  continue;
95
114
  }
96
- const choice = (await ask(`${r.site} requests ${r.fields.join(', ')}. Allow once / Always for this project / Deny [once/always/deny]. ${VAULT_SECURITY_NOTE}\n`)).toLowerCase();
115
+ const choice = (await ask(`${r.site} requests ${r.fields.join(', ')}. Ask every time / Allow for this project / Allow on my machine / Deny [once/project/machine/deny]. ${VAULT_SECURITY_NOTE}\n`)).toLowerCase();
97
116
  if (choice === 'deny') {
98
117
  await ctx.cloud().credentialRequests.deny(r.id);
99
118
  ctx.io.stderr.write(`Denied ${r.site}. ${VAULT_SECURITY_NOTE}\n`);
100
119
  continue;
101
120
  }
102
- if (choice !== 'once' && choice !== 'always')
103
- throw new UsageError(`Choose once, always or deny. ${VAULT_SECURITY_NOTE}`);
121
+ if (!['once', 'always', 'project', 'machine'].includes(choice))
122
+ throw new UsageError(`Choose once, project, machine or deny. ${VAULT_SECURITY_NOTE}`);
104
123
  const vault = vaultType(saved?.vault ?? (await ask(`Choose hsec, 1password, bitwarden or keychain. ${VAULT_SECURITY_NOTE}\n`)));
105
124
  const item = saved?.item ?? (await ask(`Vault item name for ${r.site}. ${VAULT_SECURITY_NOTE}\n`));
106
125
  const remember = (await ask(`Remember for unattended use? [yes/no]. ${REMEMBERED_SECURITY_NOTE}\n`)).toLowerCase() === 'yes';
107
- await approveCredential(ctx, r, { vault, item, always: choice === 'always', fields: r.fields }, remember);
126
+ await approveCredential(ctx, r, { vault, item, always: choice !== 'once', scope: choice === 'once' ? 'ask' : choice === 'machine' ? 'machine' : 'project', fields: r.fields }, remember);
108
127
  }
109
128
  catch (e) {
110
129
  if (signal.aborted)
@@ -162,7 +181,7 @@ export const CREDENTIAL_HANDLERS = {
162
181
  const item = str(values.item);
163
182
  if (!item)
164
183
  throw new UsageError('--item names a vault login');
165
- await approveCredential(ctx, request, { vault, item, always: values.always === true, fields: request.fields }, values.remember === true);
184
+ await approveCredential(ctx, request, { vault, item, always: values.always === true || ['project', 'machine'].includes(str(values.scope) ?? ''), scope: approvalScope(str(values.scope)), fields: request.fields }, values.remember === true);
166
185
  if (ctx.json)
167
186
  ctx.io.stdout.write(JSON.stringify({
168
187
  approved: true,
@@ -178,9 +197,12 @@ export const CREDENTIAL_HANDLERS = {
178
197
  : `Credential request ${r.state}. ${VAULT_SECURITY_NOTE}\n`);
179
198
  return 0;
180
199
  },
181
- async 'credentials forget'(ctx, _values, [ref, site]) {
200
+ async 'credentials forget'(ctx, values, [ref, site]) {
182
201
  const ws = await resolveWorkspace(ctx, ref);
183
- await ctx.cloud().credentialRequests.forget(ws.id, site);
202
+ const scope = str(values.scope);
203
+ if (scope && !['conversation', 'project', 'machine'].includes(scope))
204
+ throw new UsageError('--scope must be conversation, project or machine');
205
+ await ctx.cloud().credentialRequests.forget(ws.id, site, { scope: scope });
184
206
  ctx.io.stdout.write(ctx.json
185
207
  ? JSON.stringify({
186
208
  forgotten: true,
@@ -31,6 +31,14 @@ export interface DeviceApproval {
31
31
  conversation_id: string;
32
32
  summary: string;
33
33
  outside: boolean;
34
+ kind?: DeviceRequest['kind'];
35
+ argv?: string[];
36
+ local_path?: string;
37
+ remote_path?: string;
38
+ project_name?: string;
39
+ conversation_title?: string;
40
+ local_port?: number;
41
+ remote_port?: number;
34
42
  }
35
43
  type Result = {
36
44
  state: 'approved' | 'denied' | 'running' | 'done' | 'failed';
@@ -56,6 +64,11 @@ export declare class DeviceGrants {
56
64
  save(api: string, project: string, root: string, kind: string): Promise<void>;
57
65
  }
58
66
  export declare function deviceSummary(r: DeviceRequest): string;
67
+ /** Structured local approval details; the summary remains for older menu bar and ACP clients. */
68
+ export declare function deviceApproval(r: DeviceRequest, c: Conversation, target: {
69
+ path: string;
70
+ outside: boolean;
71
+ }, folder: string): DeviceApproval;
59
72
  export declare function tryLocallyAction(r: DeviceRequest): 'start' | 'stop' | null;
60
73
  export declare function ensureConversationWorktree(ctx: Ctx, c: Conversation, folder: string, ws?: Workspace): Promise<string>;
61
74
  export declare function deviceExec(argv: string[], cwd: string, timeoutSeconds?: number, signal?: AbortSignal): Promise<Result>;
@@ -3,7 +3,7 @@ import { spawn } from 'node:child_process';
3
3
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
4
4
  import { readFile, writeFile, mkdir, realpath, stat } from 'node:fs/promises';
5
5
  import { createConnection, createServer } from 'node:net';
6
- import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
7
  import { WorkspaceTunnels } from '@shardflux/sdk';
8
8
  import { writePrivateFile } from "./credentials.js";
9
9
  import { conversationName, conversationWorkspace, getConversation, localConversations } from "./conversations.js";
@@ -73,8 +73,18 @@ export function deviceSummary(r) {
73
73
  if (r.kind === 'exec')
74
74
  return `Run on your Mac: ${JSON.stringify(r.exec?.argv)} · directory ${r.exec?.cwd_relative ?? '.'}`;
75
75
  if (r.files)
76
- return `${r.kind === 'files_pull' ? 'Send to VM' : 'Save on your Mac'}: ${r.files.local_path_relative} ↔ ${r.files.remote_path}`;
77
- return `${r.kind === 'port_forward' ? 'Open VM port on your Mac' : 'Reach your Mac from VM'}: ${r.port?.remote_port} ↔ localhost:${r.port?.local_port}`;
76
+ return `${r.kind === 'files_pull' ? 'Share with conversation' : 'Save on your Mac'}: ${r.files.local_path_relative} ↔ ${r.files.remote_path}`;
77
+ return `${r.kind === 'port_forward' ? 'Open conversation preview on your Mac' : 'Connect conversation to your Mac'}: ${r.port?.remote_port} ↔ localhost:${r.port?.local_port}`;
78
+ }
79
+ /** Structured local approval details; the summary remains for older menu bar and ACP clients. */
80
+ export function deviceApproval(r, c, target, folder) {
81
+ return {
82
+ type: 'approval', request_id: r.id, project_id: r.project_id, conversation_id: c.id,
83
+ summary: deviceSummary(r), outside: target.outside, kind: r.kind, local_path: target.path,
84
+ project_name: basename(folder), conversation_title: c.title || conversationName(c),
85
+ ...(r.exec ? { argv: r.exec.argv } : {}), ...(r.files ? { remote_path: r.files.remote_path } : {}),
86
+ ...(r.port ? { local_port: r.port.local_port, remote_port: r.port.remote_port } : {}),
87
+ };
78
88
  }
79
89
  export function tryLocallyAction(r) {
80
90
  if (r.kind === 'try_locally')
@@ -172,7 +182,7 @@ export class DeviceExecutor {
172
182
  const requested = r.exec?.cwd_relative ?? r.files?.local_path_relative ?? '.';
173
183
  const target = devicePath(planned.path, requested), grants = new DeviceGrants(ctx.store.dir);
174
184
  target.outside ||= planned.outside;
175
- const choice = grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside) ? 'once' : await this.#ask({ type: 'approval', request_id: r.id, project_id: r.project_id, conversation_id: c.id, summary: deviceSummary(r), outside: target.outside });
185
+ const choice = grants.allows(ctx.apiUrl, r.project_id, record.folder, r.kind, target.outside) ? 'once' : await this.#ask(deviceApproval(r, c, target, record.folder));
176
186
  if (choice === 'deny') {
177
187
  await this.result(r, { state: 'denied' });
178
188
  return;
@@ -46,7 +46,8 @@ export async function runDeviceHelper(ctx) {
46
46
  ctx.io.stdout.write(JSON.stringify(approval) + '\n');
47
47
  if (signal.aborted)
48
48
  denied();
49
- });
49
+ }).finally(() => { if (!signal.aborted)
50
+ ctx.io.stdout.write(JSON.stringify({ type: 'approval_resolved', request_id: approval.request_id }) + '\n'); });
50
51
  };
51
52
  const working = new Map();
52
53
  const mirrored = new ConversationMirrorState();
package/dist/errors.d.ts CHANGED
@@ -45,6 +45,15 @@ export declare class CodedError extends HintedError {
45
45
  readonly details: Record<string, unknown> | undefined;
46
46
  constructor(code: string, message: string, hint?: string, details?: Record<string, unknown>);
47
47
  }
48
+ /**
49
+ * A command that can create an account ran without the agreement to the Terms of Service (0.15.0+): no
50
+ * --accept-terms / SHARDFLUX_ACCEPT_TERMS=1 and no terminal to ask on, or the person answered no. Nothing was sent.
51
+ * Code `terms_not_accepted`, exit 2 (usage); `details` has both links and the flag.
52
+ */
53
+ export declare class TermsNotAcceptedError extends CodedError {
54
+ name: string;
55
+ constructor(declined?: boolean);
56
+ }
48
57
  /** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
49
58
  export declare class AuthConfigError extends HintedError {
50
59
  name: string;
package/dist/errors.js CHANGED
@@ -66,6 +66,19 @@ export class CodedError extends HintedError {
66
66
  this.details = details;
67
67
  }
68
68
  }
69
+ /**
70
+ * A command that can create an account ran without the agreement to the Terms of Service (0.15.0+): no
71
+ * --accept-terms / SHARDFLUX_ACCEPT_TERMS=1 and no terminal to ask on, or the person answered no. Nothing was sent.
72
+ * Code `terms_not_accepted`, exit 2 (usage); `details` has both links and the flag.
73
+ */
74
+ export class TermsNotAcceptedError extends CodedError {
75
+ name = 'TermsNotAcceptedError';
76
+ constructor(declined = false) {
77
+ super('terms_not_accepted', declined
78
+ ? 'Not signed up: creating an account needs your agreement to the Terms of Service and Privacy Policy.'
79
+ : 'Re-run with --accept-terms to agree to the Terms of Service (https://shardflux.dev/terms) and Privacy Policy (https://shardflux.dev/privacy); creating an account needs that agreement.', 'read https://shardflux.dev/terms and https://shardflux.dev/privacy, then re-run with --accept-terms (or set SHARDFLUX_ACCEPT_TERMS=1)', { terms_url: 'https://shardflux.dev/terms', privacy_url: 'https://shardflux.dev/privacy', flag: '--accept-terms' });
80
+ }
81
+ }
69
82
  /** No usable credential: SHARDFLUX_API_KEY / the saved key, or the session, missing or malformed (never carries it). */
70
83
  export class AuthConfigError extends HintedError {
71
84
  name = 'AuthConfigError';
@@ -139,7 +152,7 @@ export class ExecSessionRunningError extends HintedError {
139
152
  this.session = session;
140
153
  }
141
154
  }
142
- const AUTH_CODES = new Set(['unauthenticated', 'invalid_credentials', 'token_invalid', 'forbidden', 'mfa_required', 'email_unverified', 'step_up_required', 'csrf_failed']);
155
+ const AUTH_CODES = new Set(['unauthenticated', 'invalid_credentials', 'token_invalid', 'forbidden', 'mfa_required', 'email_unverified', 'step_up_required', 'csrf_failed', 'access_denied', 'expired_token']);
143
156
  const KEY_PATTERN = /sfk_[A-Za-z0-9]{8,}_[A-Za-z0-9]+/g;
144
157
  /** User session tokens (`sfu_` + 43 base64url characters), also cut short. */
145
158
  const SESSION_PATTERN = /sfu_[A-Za-z0-9_-]{16,}/g;
@@ -261,7 +274,7 @@ export function describeError(err) {
261
274
  export function exitCodeFor(err, interrupted = false) {
262
275
  if (interrupted || err instanceof InterruptedError)
263
276
  return EXIT.interrupted;
264
- if (err instanceof UsageError || err instanceof TemplateFileError)
277
+ if (err instanceof UsageError || err instanceof TemplateFileError || err instanceof TermsNotAcceptedError)
265
278
  return EXIT.usage;
266
279
  if (err instanceof AuthConfigError || err instanceof MfaPendingError)
267
280
  return EXIT.auth;
package/dist/handlers.js CHANGED
@@ -15,4 +15,5 @@ import { MENUBAR_HANDLERS } from "./menubar.js";
15
15
  import { UPDATE_HANDLERS } from "./update.js";
16
16
  import { FILESYSTEM_HANDLERS } from "./filesystem-commands.js";
17
17
  import { CONVERSATION_HANDLERS as LOCAL_CONVERSATION_HANDLERS } from "./conversation-local.js";
18
- export const HANDLERS = { ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
18
+ import { CODEX_CLOUD_HANDLERS } from "./codex-cloud.js";
19
+ export const HANDLERS = { ...CODEX_CLOUD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
@@ -0,0 +1,26 @@
1
+ import type { Ctx } from './commands.js';
2
+ import type { Values } from './args.js';
3
+ import type { TerminalConnection } from './agent-terminal.js';
4
+ import type { CellClient } from '@shardflux/sdk';
5
+ /** The user chose this repository. Trust exactly that new VM worktree before launching Codex's first turn. */
6
+ export declare function prepareInitialCodexProject(cell: CellClient, cwd: string, ptyId: string): Promise<void>;
7
+ export declare function readInitialPrompt(ctx: Ctx, values: Values): Promise<string | undefined>;
8
+ /** Images for a new conversation's first message. They are held in memory and written only into the conversation VM. */
9
+ export interface InitialImage {
10
+ path: string;
11
+ data: Uint8Array;
12
+ type: 'png' | 'jpeg' | 'gif' | 'webp';
13
+ }
14
+ export declare const ATTACHMENT_DIR = "/home/user/.shardflux/attachments";
15
+ export declare const MAX_INITIAL_IMAGES = 5;
16
+ export declare const MAX_INITIAL_IMAGE_BYTES: number;
17
+ /** Standard input may carry several PNG files back to back (the menu bar's in-memory captures). */
18
+ export declare function splitImages(data: Uint8Array): Uint8Array[];
19
+ /** Reads and validates `--image <file|->` before any VM is allocated. */
20
+ export declare function readInitialImages(ctx: Ctx, values: Values, prompt: string | undefined): Promise<InitialImage[]>;
21
+ /** One VM path per line: Claude Code attaches each as an image when the prompt is pasted; Codex opens it with view_image. */
22
+ export declare function promptWithImages(prompt: string, images: readonly InitialImage[]): string;
23
+ /** Writes the images into the conversation VM before the first message refers to them. */
24
+ export declare function uploadInitialImages(cell: Pick<CellClient, 'files'>, images: readonly InitialImage[], signal?: AbortSignal): Promise<void>;
25
+ /** Wait for the native terminal to enable paste before submitting one first turn. No blind timing or input replay. */
26
+ export declare function submitInitialPrompt(connect: (signal: AbortSignal) => Promise<TerminalConnection>, prompt: string, workspaceKey: string, signal: AbortSignal, app?: 'claude' | 'codex', write?: (bytes: Uint8Array) => Promise<unknown>): Promise<void>;
@@ -0,0 +1,196 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readInput, str, strs } from "./commands.js";
3
+ import { PtyOutputCursor } from "./agent-terminal.js";
4
+ import { CodedError, UsageError } from "./errors.js";
5
+ import { ShardfluxApiError } from '@shardflux/sdk';
6
+ import { parse, stringify } from 'smol-toml';
7
+ /** The user chose this repository. Trust exactly that new VM worktree before launching Codex's first turn. */
8
+ export async function prepareInitialCodexProject(cell, cwd, ptyId) {
9
+ const path = '/home/user/.codex/config.toml';
10
+ let source = '', revision = 'absent';
11
+ try {
12
+ const old = await cell.files.readWithInfo(path);
13
+ source = new TextDecoder().decode(old.data);
14
+ if (!old.revision)
15
+ throw new Error('Codex config omitted its revision.');
16
+ revision = old.revision;
17
+ }
18
+ catch (error) {
19
+ if (!(error instanceof ShardfluxApiError && error.status === 404))
20
+ throw error;
21
+ }
22
+ const doc = parse(source);
23
+ const projects = doc.projects && typeof doc.projects === 'object' && !Array.isArray(doc.projects) ? doc.projects : {};
24
+ projects[cwd] = { ...(projects[cwd] ?? {}), trust_level: 'trusted' };
25
+ doc.projects = projects;
26
+ // The VM image owns Codex's version. An interactive updater must not intercept the first task.
27
+ doc.check_for_update_on_startup = false;
28
+ await cell.files.mkdir('/home/user/.codex', { parents: true });
29
+ await cell.files.patch({ path, content: stringify(doc), expectedRevision: revision, mode: '0600' });
30
+ // A new conversation's VM can auto-start its empty terminal before the CLI seeds/configures it.
31
+ // Reload that owned, never-prompted terminal so startup reads the chosen worktree's trust setting.
32
+ try {
33
+ const session = await cell.pty.get(ptyId);
34
+ if (session.state === 'running' || session.state === 'starting')
35
+ await cell.pty.close(ptyId);
36
+ }
37
+ catch (error) {
38
+ if (!(error instanceof ShardfluxApiError && error.status === 404))
39
+ throw error;
40
+ }
41
+ }
42
+ export async function readInitialPrompt(ctx, values) {
43
+ const prompt = str(values.prompt), file = str(values['prompt-file']);
44
+ if (prompt !== undefined && file !== undefined)
45
+ throw new UsageError('Use --prompt or --prompt-file, not both.');
46
+ const text = file === undefined ? prompt : new TextDecoder('utf-8', { fatal: true }).decode(await readInput(ctx, file, 262144, '--prompt-file'));
47
+ if (text !== undefined && (!text.trim() || text.includes('\0') || text.includes('\x1b') || Buffer.byteLength(text) > 262144)) {
48
+ throw new UsageError('The initial prompt must contain text, be at most 256 KiB, and contain no NUL or terminal escape characters.');
49
+ }
50
+ if (text !== undefined && values.resume === true)
51
+ throw new UsageError('An initial prompt is for a new conversation; use conversations send for an existing conversation.');
52
+ return text;
53
+ }
54
+ export const ATTACHMENT_DIR = '/home/user/.shardflux/attachments';
55
+ export const MAX_INITIAL_IMAGES = 5;
56
+ export const MAX_INITIAL_IMAGE_BYTES = 10 << 20;
57
+ const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
58
+ function imageType(data) {
59
+ const ascii = (from, to) => Buffer.from(data.subarray(from, to)).toString('latin1');
60
+ if (PNG.every((b, i) => data[i] === b))
61
+ return 'png';
62
+ if (data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
63
+ return 'jpeg';
64
+ if (ascii(0, 6) === 'GIF87a' || ascii(0, 6) === 'GIF89a')
65
+ return 'gif';
66
+ if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP')
67
+ return 'webp';
68
+ return undefined;
69
+ }
70
+ /** Standard input may carry several PNG files back to back (the menu bar's in-memory captures). */
71
+ export function splitImages(data) {
72
+ if (imageType(data) !== 'png')
73
+ return [data];
74
+ const images = [];
75
+ let start = 0;
76
+ while (start < data.length) {
77
+ if (!PNG.every((b, i) => data[start + i] === b))
78
+ throw new UsageError('--image -: standard input must be one image or PNG images back to back.');
79
+ let at = start + 8, end = 0;
80
+ while (!end) {
81
+ if (at + 12 > data.length)
82
+ throw new UsageError('--image -: a PNG image on standard input is truncated.');
83
+ const length = Buffer.from(data.subarray(at, at + 4)).readUInt32BE(0);
84
+ const type = Buffer.from(data.subarray(at + 4, at + 8)).toString('latin1');
85
+ at += 12 + length;
86
+ if (at > data.length)
87
+ throw new UsageError('--image -: a PNG image on standard input is truncated.');
88
+ if (type === 'IEND')
89
+ end = at;
90
+ }
91
+ images.push(data.subarray(start, end));
92
+ start = end;
93
+ }
94
+ return images;
95
+ }
96
+ /** Reads and validates `--image <file|->` before any VM is allocated. */
97
+ export async function readInitialImages(ctx, values, prompt) {
98
+ const sources = strs(values.image);
99
+ if (!sources.length)
100
+ return [];
101
+ if (prompt === undefined)
102
+ throw new UsageError('--image needs an initial prompt: add --prompt or --prompt-file.');
103
+ if (sources.filter(s => s === '-').length > 1)
104
+ throw new UsageError('Use --image - once; standard input may carry several PNG images back to back.');
105
+ if (sources.includes('-') && str(values['prompt-file']) === '-')
106
+ throw new UsageError('--prompt-file - and --image - both read standard input. Pass one of them as a file.');
107
+ const id = randomUUID();
108
+ const images = [];
109
+ for (const source of sources) {
110
+ const data = await readInput(ctx, source, MAX_INITIAL_IMAGES * MAX_INITIAL_IMAGE_BYTES, '--image');
111
+ for (const part of source === '-' ? splitImages(data) : [data]) {
112
+ const type = imageType(part);
113
+ if (!type)
114
+ throw new UsageError(`--image ${source}: use a PNG, JPEG, GIF or WebP image.`);
115
+ if (part.length > MAX_INITIAL_IMAGE_BYTES)
116
+ throw new UsageError(`--image ${source}: each image must be at most ${MAX_INITIAL_IMAGE_BYTES >> 20} MiB.`);
117
+ if (images.length === MAX_INITIAL_IMAGES)
118
+ throw new UsageError(`Attach at most ${MAX_INITIAL_IMAGES} images.`);
119
+ images.push({ path: `${ATTACHMENT_DIR}/${id}-${images.length + 1}.${type === 'jpeg' ? 'jpg' : type}`, data: part, type });
120
+ }
121
+ }
122
+ return images;
123
+ }
124
+ /** One VM path per line: Claude Code attaches each as an image when the prompt is pasted; Codex opens it with view_image. */
125
+ export function promptWithImages(prompt, images) {
126
+ if (!images.length)
127
+ return prompt;
128
+ const text = `${prompt}\n\nAttached ${images.length === 1 ? 'image' : 'images'} (view ${images.length === 1 ? 'it' : 'each one'} before you start):\n${images.map(i => i.path).join('\n')}`;
129
+ if (Buffer.byteLength(text) > 262144)
130
+ throw new UsageError('The initial prompt with its image references must be at most 256 KiB.');
131
+ return text;
132
+ }
133
+ /** Writes the images into the conversation VM before the first message refers to them. */
134
+ export async function uploadInitialImages(cell, images, signal) {
135
+ for (const image of images) {
136
+ signal?.throwIfAborted();
137
+ await cell.files.write(image.path, image.data, { mode: '0600', createParents: true });
138
+ }
139
+ }
140
+ /** Wait for the native terminal to enable paste before submitting one first turn. No blind timing or input replay. */
141
+ export async function submitInitialPrompt(connect, prompt, workspaceKey, signal, app = 'claude', write) {
142
+ const bounded = AbortSignal.any([signal, AbortSignal.timeout(20000)]);
143
+ let connection;
144
+ let sent = false, pasted = false;
145
+ try {
146
+ connection = await connect(bounded);
147
+ let tail = '', pasteEnabled = false;
148
+ const cursor = new PtyOutputCursor();
149
+ for await (const message of connection.messages()) {
150
+ if (bounded.aborted)
151
+ throw bounded.reason;
152
+ if (message.type === 'exit')
153
+ throw new Error('The agent exited before accepting a prompt.');
154
+ const bytes = cursor.consume(message);
155
+ if (!bytes)
156
+ continue;
157
+ tail = (tail + bytes.toString('utf8')).slice(-32768);
158
+ pasteEnabled ||= tail.includes('\x1b[?2004h');
159
+ // eslint-disable-next-line no-control-regex -- Strip native terminal ANSI before matching prompt echo.
160
+ const plain = tail.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\s/g, '');
161
+ if (sent && bytes.length)
162
+ return;
163
+ if (pasted) {
164
+ // A long prompt overflows the input box: a 24-row terminal shows only its end, so either end confirms the paste.
165
+ const compact = prompt.replace(/\s/g, '');
166
+ if (!plain.includes(compact.slice(0, 32)) && !plain.includes(compact.slice(-32)) && !/Pasted(?:text|content)/i.test(plain))
167
+ continue;
168
+ const enter = Buffer.from('\r');
169
+ if (write)
170
+ await write(enter);
171
+ else
172
+ connection.input(enter);
173
+ sent = true;
174
+ tail = '';
175
+ continue;
176
+ }
177
+ const ready = app === 'claude' ? tail.includes('❯') : tail.includes('›');
178
+ if (!ready || !pasteEnabled)
179
+ continue;
180
+ const paste = Buffer.from(`\x1b[200~${prompt}\x1b[201~`);
181
+ if (write)
182
+ await write(paste);
183
+ else
184
+ connection.input(paste);
185
+ pasted = true;
186
+ tail = '';
187
+ }
188
+ throw new Error('The agent terminal closed before accepting a prompt.');
189
+ }
190
+ catch {
191
+ throw new CodedError('initial_prompt_failed', `Created ${workspaceKey}, but the initial prompt ${sent ? 'could not be confirmed' : 'was not submitted'}. Open that conversation before starting another.`, undefined, { workspace_key: workspaceKey, submitted: sent });
192
+ }
193
+ finally {
194
+ await connection?.close();
195
+ }
196
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The Terms of Service notice and acceptance, one place for every sign-in and sign-up path (the dashboard's sign-up
3
+ * page and the Mac app show the same line).
4
+ *
5
+ * - Signing in to an existing account (a password, the browser) only shows the notice: one line on stderr before it
6
+ * acts, or `terms_url` and `privacy_url` in the `--json` output for an app or agent to show.
7
+ * - A command that can create an account (`shard signup`, `shard auth register`, `shard auth login --codex`) needs the
8
+ * agreement first: `--accept-terms` (or SHARDFLUX_ACCEPT_TERMS=1), or "Continue? [Y/n]" on a terminal. Without
9
+ * either it stops before any network call with `terms_not_accepted` (exit 2), so an agent asks its person and
10
+ * re-runs with the flag. The API records the agreement on the new account (`accept_terms`, `terms_version`).
11
+ */
12
+ import type { Ctx } from './commands.js';
13
+ import type { Values } from './args.js';
14
+ export declare const TERMS_URL = "https://shardflux.dev/terms";
15
+ export declare const PRIVACY_URL = "https://shardflux.dev/privacy";
16
+ /** The published version of the terms: the effective date on the terms page. */
17
+ export declare const TERMS_VERSION = "2026-09-28";
18
+ export declare const ACCEPT_TERMS_FLAG = "--accept-terms";
19
+ export declare const ACCEPT_TERMS_ENV = "SHARDFLUX_ACCEPT_TERMS";
20
+ /** "By continuing, you agree to our Terms of Service (<url>) and acknowledge our Privacy Policy (<url>)." */
21
+ export declare function legalNotice(): string;
22
+ /** The two links in a command's `--json` output. */
23
+ export declare function legalFields(): {
24
+ terms_url: string;
25
+ privacy_url: string;
26
+ };
27
+ /** The notice before a sign-in acts: a line on stderr (none in `--json`, where the output carries the links). */
28
+ export declare function showLegalNotice(ctx: Ctx): void;
29
+ /**
30
+ * The agreement before a command that can create an account, before any network call. Returns what the API records;
31
+ * throws TermsNotAcceptedError when there is none (no flag and no terminal to ask on, or the person said no).
32
+ */
33
+ export declare function requireTermsAcceptance(ctx: Ctx, values: Values): Promise<{
34
+ acceptTerms: true;
35
+ termsVersion: string;
36
+ }>;
package/dist/legal.js ADDED
@@ -0,0 +1,44 @@
1
+ import { TermsNotAcceptedError } from "./errors.js";
2
+ export const TERMS_URL = 'https://shardflux.dev/terms';
3
+ export const PRIVACY_URL = 'https://shardflux.dev/privacy';
4
+ /** The published version of the terms: the effective date on the terms page. */
5
+ export const TERMS_VERSION = '2026-09-28';
6
+ export const ACCEPT_TERMS_FLAG = '--accept-terms';
7
+ export const ACCEPT_TERMS_ENV = 'SHARDFLUX_ACCEPT_TERMS';
8
+ /** "By continuing, you agree to our Terms of Service (<url>) and acknowledge our Privacy Policy (<url>)." */
9
+ export function legalNotice() {
10
+ return `By continuing, you agree to our Terms of Service (${TERMS_URL}) and acknowledge our Privacy Policy (${PRIVACY_URL}).`;
11
+ }
12
+ /** The two links in a command's `--json` output. */
13
+ export function legalFields() {
14
+ return { terms_url: TERMS_URL, privacy_url: PRIVACY_URL };
15
+ }
16
+ /** The notice before a sign-in acts: a line on stderr (none in `--json`, where the output carries the links). */
17
+ export function showLegalNotice(ctx) {
18
+ if (!ctx.json)
19
+ ctx.io.stderr.write(`${legalNotice()}\n`);
20
+ }
21
+ function acceptedUpFront(ctx, values) {
22
+ if (values['accept-terms'] === true)
23
+ return true;
24
+ const env = ctx.io.env[ACCEPT_TERMS_ENV]?.trim().toLowerCase();
25
+ return env === '1' || env === 'true' || env === 'yes';
26
+ }
27
+ /**
28
+ * The agreement before a command that can create an account, before any network call. Returns what the API records;
29
+ * throws TermsNotAcceptedError when there is none (no flag and no terminal to ask on, or the person said no).
30
+ */
31
+ export async function requireTermsAcceptance(ctx, values) {
32
+ const accepted = { acceptTerms: true, termsVersion: TERMS_VERSION };
33
+ if (acceptedUpFront(ctx, values)) {
34
+ showLegalNotice(ctx);
35
+ return accepted;
36
+ }
37
+ if (!ctx.input.interactive || ctx.json)
38
+ throw new TermsNotAcceptedError();
39
+ ctx.io.stderr.write(`${legalNotice()}\n`);
40
+ const answer = (await ctx.input.answer('Continue? [Y/n] '))?.trim().toLowerCase() ?? '';
41
+ if (answer === '' || answer === 'y' || answer === 'yes')
42
+ return accepted;
43
+ throw new TermsNotAcceptedError(true);
44
+ }
package/dist/main.js CHANGED
@@ -18,6 +18,7 @@ import { commandCenter } from "./command-center.js";
18
18
  import { launchConversationProject } from "./conversation-terminal.js";
19
19
  import { runAcp } from "./acp.js";
20
20
  import { registerInstalledT3 } from "./t3-provider.js";
21
+ import { runAcpLogin } from "./acp-login.js";
21
22
  import { runDeviceHelper } from "./device-helper.js";
22
23
  import { housekeeping, startupHousekeeping } from "./agent-git-commands.js";
23
24
  import { CredentialStore, configDir } from "./credentials.js";
@@ -84,6 +85,9 @@ function hintFor(info, cred, file, command = '') {
84
85
  return STEP_UP_HINT;
85
86
  if (info.code === 'mfa_required')
86
87
  return 'finish signing in with the code of your authenticator app: shard auth mfa --code <code>';
88
+ // The browser sign-in (0.15.0+): refused in the browser, or not approved within its 10 minutes.
89
+ if (info.code === 'access_denied' || info.code === 'expired_token')
90
+ return 'start the sign-in again: shard auth login --browser';
87
91
  if (info.code === 'email_unverified')
88
92
  return `verify your email first: shard auth verify-email '<link from the email>' (a new link: shard auth resend-verification)`;
89
93
  if (info.code === 'conflict' && reasonOf(info) === 'subscription_exists')
@@ -316,7 +320,7 @@ const FEEDBACK_EMAIL = 'shardflux@heliosone.fi';
316
320
  */
317
321
  // allowance_exhausted (0.5.1): a used-up allowance, a reached spend cap or a paused overage is a billing state whose hint
318
322
  // names the next step (upgrade, overage, the cap, the payment method), not a problem to report.
319
- const NO_NUDGE_CODES = new Set(['usage', 'interrupted', 'unauthenticated', 'validation_failed', 'mfa_required', 'step_up_required', 'email_unverified', 'timeout', 'checkout_ended', 'allowance_exhausted']);
323
+ const NO_NUDGE_CODES = new Set(['usage', 'terms_not_accepted', 'interrupted', 'unauthenticated', 'validation_failed', 'mfa_required', 'step_up_required', 'email_unverified', 'timeout', 'checkout_ended', 'allowance_exhausted']);
320
324
  const NO_NUDGE_REASONS = new Set([
321
325
  'revision_mismatch',
322
326
  'tree_revision_mismatch',
@@ -644,7 +648,7 @@ export async function run(argv, io, opts = {}) {
644
648
  catch (err) {
645
649
  return report(err);
646
650
  }
647
- const handler = parsed.command.path[0] === 'acp' ? (ctx) => runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
651
+ const handler = parsed.command.path[0] === 'acp' ? (ctx) => parsed.values.login === true ? runAcpLogin(ctx) : runAcp(ctx) : parsed.command.path[0] === 'device-helper' ? runDeviceHelper : parsed.command === PROJECT_COMMAND ? launchConversationProject : parsed.command === CENTER_COMMAND ? commandCenter : HANDLERS[parsed.command.path.join(' ')];
648
652
  if (!handler)
649
653
  return report(new UsageError(`unknown command "${parsed.command.path.join(' ')}"`));
650
654
  if (!['acp', 'device-helper'].includes(parsed.command.path[0]))
package/dist/menubar.d.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import type { Handler } from './commands.js';
2
2
  export type NativeRunner = (tool: string, args: string[]) => Promise<string>;
3
+ /**
4
+ * Every native tool a default MenubarManager runs (plutil, codesign, xattr, open, the app's --control) goes through
5
+ * `nativeTools.run`. The CLI test support replaces it, so a test run never starts, stops or inspects a real app,
6
+ * whether or not a built Shardflux.app is in the package.
7
+ */
8
+ export declare const nativeTools: {
9
+ run: (tool: string, args: string[], signal?: AbortSignal) => Promise<string>;
10
+ };
3
11
  export interface MenubarStatus {
4
12
  installed: boolean;
5
13
  version: string | null;
package/dist/menubar.js CHANGED
@@ -8,6 +8,14 @@ import { AgentStateStore } from "./agent-state.js";
8
8
  import { UsageError } from "./errors.js";
9
9
  import { json } from "./format.js";
10
10
  const execute = promisify(execFile);
11
+ /**
12
+ * Every native tool a default MenubarManager runs (plutil, codesign, xattr, open, the app's --control) goes through
13
+ * `nativeTools.run`. The CLI test support replaces it, so a test run never starts, stops or inspects a real app,
14
+ * whether or not a built Shardflux.app is in the package.
15
+ */
16
+ export const nativeTools = {
17
+ run: async (tool, args, signal) => (await execute(tool, args, { timeout: 15000, maxBuffer: 1024 * 1024, signal })).stdout,
18
+ };
11
19
  const BUNDLE_ID = 'dev.shardflux.menubar';
12
20
  export function compareBundleVersions(left, right) {
13
21
  if (![left, right].every(v => /^\d+(\.\d+){0,3}$/.test(v)))
@@ -58,7 +66,7 @@ export class MenubarManager {
58
66
  constructor(options = {}) {
59
67
  this.app = join(options.home ?? homedir(), 'Applications', 'Shardflux.app');
60
68
  this.#platform = options.platform ?? process.platform;
61
- this.#run = options.run ?? (async (tool, args) => (await execute(tool, args, { timeout: 15000, maxBuffer: 1024 * 1024, signal: options.signal })).stdout);
69
+ this.#run = options.run ?? ((tool, args) => nativeTools.run(tool, args, options.signal));
62
70
  this.#packageApp = options.packageApp ?? (() => {
63
71
  try {
64
72
  return join(dirname(createRequire(import.meta.url).resolve('@shardflux/menubar-darwin/package.json')), 'Shardflux.app');