@shardflux/cli 0.19.1 → 0.22.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 (115) hide show
  1. package/CHANGELOG.md +278 -1
  2. package/README.md +225 -73
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +20 -15
  6. package/dist/account-pool.js +51 -97
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-login.d.ts +4 -3
  10. package/dist/acp-login.js +7 -5
  11. package/dist/acp-models.js +20 -15
  12. package/dist/acp.js +66 -13
  13. package/dist/agent-commands.d.ts +4 -1
  14. package/dist/agent-commands.js +86 -32
  15. package/dist/agent-config-files.d.ts +19 -0
  16. package/dist/agent-config-files.js +70 -1
  17. package/dist/agent-config-return.d.ts +194 -0
  18. package/dist/agent-config-return.js +724 -0
  19. package/dist/agent-config-sync.d.ts +10 -2
  20. package/dist/agent-config-sync.js +45 -51
  21. package/dist/agent-credentials.js +7 -3
  22. package/dist/agent-repository.d.ts +16 -3
  23. package/dist/agent-repository.js +149 -74
  24. package/dist/agent-setup.d.ts +14 -3
  25. package/dist/agent-setup.js +78 -36
  26. package/dist/agent-target-commands.js +24 -13
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +104 -55
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-forward.d.ts +2 -0
  35. package/dist/cli-proxy-forward.js +9 -2
  36. package/dist/cli-proxy-hub.d.ts +69 -25
  37. package/dist/cli-proxy-hub.js +231 -54
  38. package/dist/cli-proxy.d.ts +70 -1
  39. package/dist/cli-proxy.js +298 -32
  40. package/dist/client-settings.d.ts +39 -0
  41. package/dist/client-settings.js +91 -0
  42. package/dist/codex-cloud.d.ts +5 -0
  43. package/dist/codex-cloud.js +88 -17
  44. package/dist/command-center-actions.js +5 -1
  45. package/dist/commands.js +171 -22
  46. package/dist/conversation-commands.d.ts +5 -0
  47. package/dist/conversation-commands.js +8 -5
  48. package/dist/conversation-terminal.d.ts +10 -2
  49. package/dist/conversation-terminal.js +81 -15
  50. package/dist/conversations.d.ts +77 -1
  51. package/dist/conversations.js +257 -23
  52. package/dist/device-bridge.d.ts +1 -1
  53. package/dist/device-bridge.js +6 -4
  54. package/dist/device-helper.js +52 -4
  55. package/dist/errors.d.ts +34 -1
  56. package/dist/errors.js +56 -5
  57. package/dist/filesystem-commands.d.ts +22 -3
  58. package/dist/filesystem-commands.js +132 -16
  59. package/dist/format.d.ts +2 -1
  60. package/dist/format.js +10 -1
  61. package/dist/handlers.js +4 -1
  62. package/dist/initial-prompt.d.ts +8 -0
  63. package/dist/initial-prompt.js +43 -1
  64. package/dist/large-media.d.ts +14 -0
  65. package/dist/large-media.js +100 -0
  66. package/dist/linux-terminal.d.ts +5 -3
  67. package/dist/linux-terminal.js +6 -20
  68. package/dist/local-sessions.d.ts +26 -2
  69. package/dist/local-sessions.js +113 -24
  70. package/dist/machine-setup.d.ts +190 -0
  71. package/dist/machine-setup.js +849 -0
  72. package/dist/machine-status.d.ts +67 -0
  73. package/dist/machine-status.js +140 -0
  74. package/dist/main.js +23 -13
  75. package/dist/move-worktrees-restore.py +144 -0
  76. package/dist/move-worktrees.d.ts +63 -0
  77. package/dist/move-worktrees.js +201 -0
  78. package/dist/move.d.ts +10 -1
  79. package/dist/move.js +116 -16
  80. package/dist/onboarding.d.ts +11 -7
  81. package/dist/onboarding.js +59 -27
  82. package/dist/prepared-repository.d.ts +202 -0
  83. package/dist/prepared-repository.js +860 -0
  84. package/dist/prepared-repository.py +1306 -0
  85. package/dist/project-preparation.d.ts +48 -0
  86. package/dist/project-preparation.js +304 -0
  87. package/dist/project-sync-manifest.d.ts +4 -0
  88. package/dist/project-sync-manifest.js +5 -1
  89. package/dist/project-sync-transfer.js +1 -1
  90. package/dist/project-sync.d.ts +19 -4
  91. package/dist/project-sync.js +31 -8
  92. package/dist/project-template-setup.d.ts +33 -1
  93. package/dist/project-template-setup.js +59 -2
  94. package/dist/secret-ask.d.ts +72 -0
  95. package/dist/secret-ask.js +351 -0
  96. package/dist/seed-phases.d.ts +13 -3
  97. package/dist/seed-phases.js +21 -3
  98. package/dist/setup-cache.d.ts +7 -0
  99. package/dist/setup-cache.js +32 -0
  100. package/dist/t3-launch.js +1 -4
  101. package/dist/t3-mode.d.ts +8 -2
  102. package/dist/t3-mode.js +19 -4
  103. package/dist/t3-phone.d.ts +4 -2
  104. package/dist/t3-phone.js +12 -4
  105. package/dist/terminal-apps.d.ts +157 -0
  106. package/dist/terminal-apps.js +561 -0
  107. package/dist/terminal-onboarding.d.ts +2 -0
  108. package/dist/terminal-onboarding.js +11 -1
  109. package/dist/this-computer.d.ts +11 -0
  110. package/dist/this-computer.js +36 -0
  111. package/dist/thread-on-computer.js +4 -1
  112. package/dist/update.js +11 -3
  113. package/dist/worktree-twin.d.ts +10 -2
  114. package/dist/worktree-twin.js +27 -9
  115. package/package.json +5 -5
@@ -0,0 +1,561 @@
1
+ /**
2
+ * The terminal app conversations open in (0.20.1+): chosen once (`terminal_app` in agents.json, written by
3
+ * `shard agents targets --terminal <app>[:tab|:window]`), a tab in its front window by default. One launcher serves
4
+ * every client: `shard agents open <ref> --in terminal`, `shard ./` from a coding agent's shell, the macOS menu bar and
5
+ * the Linux desktop (`shard agents terminal-run`, hidden).
6
+ *
7
+ * macOS: Ghostty 1.3+ and iTerm2 by AppleScript (a tab in the front window, or a window), Terminal.app by AppleScript (a
8
+ * window), WezTerm and kitty by their remote-control CLIs, Alacritty with `alacritty msg` (a window), Warp with a tab
9
+ * config and its warp:// link. Linux: the same CLIs and `ghostty +new-window`, then $TERMINAL, x-terminal-emulator and
10
+ * gnome-terminal. When the chosen app is missing or refuses, the next running terminal opens it, then Terminal.app (or
11
+ * the Linux default), and the reason is said in one line.
12
+ */
13
+ import { spawn, spawnSync } from 'node:child_process';
14
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { basename, dirname, join } from 'node:path';
17
+ import { AgentStateStore } from "./agent-state.js";
18
+ import { codingAgentShell } from "./agent-setup.js";
19
+ import { applicationDirs } from "./agent-targets.js";
20
+ import { UsageError } from "./errors.js";
21
+ import { linuxTerminalCommand } from "./linux-terminal.js";
22
+ export const TERMINAL_IDS = ['ghostty', 'iterm', 'terminal', 'wezterm', 'kitty', 'alacritty', 'warp'];
23
+ const SPECS = {
24
+ ghostty: { name: 'Ghostty', bundle: 'com.mitchellh.ghostty', app: 'Ghostty.app', termProgram: ['ghostty'], tabs: { darwin: true, linux: false }, linux: ['ghostty'] },
25
+ iterm: { name: 'iTerm2', bundle: 'com.googlecode.iterm2', app: 'iTerm.app', termProgram: ['iTerm.app'], tabs: { darwin: true, linux: false }, linux: [] },
26
+ terminal: { name: 'Terminal', bundle: 'com.apple.Terminal', app: 'Terminal.app', termProgram: ['Apple_Terminal'], tabs: { darwin: false, linux: false }, linux: [] },
27
+ wezterm: { name: 'WezTerm', bundle: 'com.github.wez.wezterm', app: 'WezTerm.app', termProgram: ['WezTerm'], tabs: { darwin: true, linux: true }, linux: ['wezterm'] },
28
+ kitty: { name: 'kitty', bundle: 'net.kovidgoyal.kitty', app: 'kitty.app', tabs: { darwin: true, linux: true }, linux: ['kitty'] },
29
+ alacritty: { name: 'Alacritty', bundle: 'org.alacritty', app: 'Alacritty.app', tabs: { darwin: false, linux: false }, linux: ['alacritty'] },
30
+ warp: { name: 'Warp', bundle: 'dev.warp.Warp-Stable', app: 'Warp.app', termProgram: ['WarpTerminal'], tabs: { darwin: true, linux: true }, linux: ['warp-terminal'] },
31
+ };
32
+ export const terminalName = (id) => SPECS[id].name;
33
+ const isId = (v) => typeof v === 'string' && TERMINAL_IDS.includes(v);
34
+ /** The terminals this platform can open conversations in (iTerm2 is macOS only). */
35
+ export const platformTerminals = (platform = process.platform) => platform === 'darwin' ? [...TERMINAL_IDS] : platform === 'linux' ? TERMINAL_IDS.filter(id => id !== 'iterm') : [];
36
+ export const tabSupported = (id, platform = process.platform) => platform === 'darwin' ? SPECS[id].tabs.darwin : SPECS[id].tabs.linux;
37
+ /** Native lookups and launches; tests replace them (no osascript, no terminal ever starts in a test). */
38
+ export const terminalNative = {
39
+ /** A short program's result; `code` is null when it did not start or ran out of time. */
40
+ run(command, args, opts) {
41
+ return new Promise(resolve => {
42
+ let stdout = '', stderr = '', done = false;
43
+ const finish = (code) => { if (!done) {
44
+ done = true;
45
+ clearTimeout(timer);
46
+ resolve({ code, stdout, stderr });
47
+ } };
48
+ let child;
49
+ try {
50
+ child = spawn(command, args, { env: opts.env, cwd: opts.cwd, stdio: [opts.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
51
+ }
52
+ catch (error) {
53
+ resolve({ code: null, stdout: '', stderr: String(error) });
54
+ return;
55
+ }
56
+ const timer = setTimeout(() => { child.kill('SIGKILL'); stderr ||= `${basename(command)} did not answer in time`; finish(null); }, opts.timeoutMs ?? 15_000);
57
+ child.stdout?.on('data', (c) => { stdout += c.toString(); });
58
+ child.stderr?.on('data', (c) => { stderr += c.toString(); });
59
+ child.once('error', e => { stderr ||= e.message; finish(null); });
60
+ child.once('close', code => finish(code));
61
+ // A program that exits without reading its input (a refusal) must not fail this one with EPIPE.
62
+ child.stdin?.on('error', () => undefined);
63
+ if (opts.input !== undefined)
64
+ child.stdin?.end(opts.input);
65
+ });
66
+ },
67
+ /** Starts a program that outlives shard (a new terminal window): true once it started. */
68
+ start(command, args, opts) {
69
+ return new Promise(resolve => {
70
+ let child;
71
+ try {
72
+ child = spawn(command, args, { env: opts.env, cwd: opts.cwd, detached: true, stdio: 'ignore' });
73
+ }
74
+ catch {
75
+ resolve(false);
76
+ return;
77
+ }
78
+ child.once('spawn', () => { child.unref(); resolve(true); });
79
+ child.once('error', () => resolve(false));
80
+ });
81
+ },
82
+ /** Running processes: executable paths on macOS, program names on Linux (one `ps`). */
83
+ processes(env) {
84
+ const r = spawnSync(process.platform === 'darwin' ? '/bin/ps' : 'ps', ['-Ao', 'comm='], { env, encoding: 'utf8', input: '', timeout: 2000 });
85
+ return r.status === 0 ? r.stdout.split('\n').map(l => l.trim()).filter(Boolean) : [];
86
+ },
87
+ /** App bundles with these bundle ids anywhere Spotlight knows (macOS), one query. */
88
+ spotlight(bundles, env) {
89
+ if (!bundles.length)
90
+ return [];
91
+ const query = bundles.map(b => `kMDItemCFBundleIdentifier == '${b}'`).join(' || ');
92
+ const r = spawnSync('/usr/bin/mdfind', [query], { env, encoding: 'utf8', input: '', timeout: 2000 });
93
+ return r.status === 0 ? r.stdout.split('\n').map(l => l.trim()).filter(p => p.endsWith('.app')) : [];
94
+ },
95
+ /** CFBundleShortVersionString of an app bundle. */
96
+ appVersion(path) {
97
+ const r = spawnSync('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleShortVersionString', join(path, 'Contents', 'Info.plist')], { encoding: 'utf8', input: '', timeout: 1000 });
98
+ return r.status === 0 ? r.stdout.trim() || null : null;
99
+ },
100
+ writeFile(file, text) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, text, { mode: 0o600 }); },
101
+ };
102
+ const bundleIs = (path, bundle) => {
103
+ try {
104
+ return readFileSync(join(path, 'Contents', 'Info.plist')).includes(bundle);
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ };
110
+ const onPath = (name, env) => {
111
+ for (const dir of (env.PATH ?? '').split(':').filter(Boolean)) {
112
+ const file = join(dir, name);
113
+ try {
114
+ accessSync(file, constants.X_OK);
115
+ if (statSync(file).isFile())
116
+ return file;
117
+ }
118
+ catch { /* next */ }
119
+ }
120
+ return null;
121
+ };
122
+ /**
123
+ * The terminals this platform can use, installed or not. macOS: app bundles in the app folders (applicationDirs, and
124
+ * the system's Utilities folder) and, with `spotlight`, anywhere Spotlight finds their bundle ids; Terminal.app is part
125
+ * of macOS. SHARDFLUX_APPLICATIONS_DIRS (tests) replaces every folder and turns Spotlight off. Linux: programs on PATH;
126
+ * `terminal` is the default terminal ($TERMINAL, x-terminal-emulator, gnome-terminal).
127
+ */
128
+ export function detectTerminals(env, platform = process.platform, opts = {}) {
129
+ const ids = platformTerminals(platform);
130
+ if (!ids.length)
131
+ return [];
132
+ const paths = {};
133
+ if (platform === 'darwin') {
134
+ const custom = env.SHARDFLUX_APPLICATIONS_DIRS !== undefined;
135
+ const dirs = [...applicationDirs(env, platform), ...(custom ? [] : ['/Applications/Utilities', '/System/Applications/Utilities'])];
136
+ for (const id of ids) {
137
+ const { app, bundle } = SPECS[id];
138
+ const found = dirs.map(d => join(d, app)).find(p => bundleIs(p, bundle));
139
+ if (found)
140
+ paths[id] = found;
141
+ }
142
+ const missing = ids.filter(id => !paths[id]);
143
+ if (!custom && opts.spotlight && missing.length) {
144
+ for (const path of terminalNative.spotlight(missing.map(id => SPECS[id].bundle), env)) {
145
+ const id = missing.find(m => !paths[m] && bundleIs(path, SPECS[m].bundle));
146
+ if (id)
147
+ paths[id] = path;
148
+ }
149
+ }
150
+ if (!paths.terminal && !custom)
151
+ paths.terminal = '/System/Applications/Utilities/Terminal.app';
152
+ }
153
+ else {
154
+ for (const id of ids) {
155
+ if (id === 'terminal') {
156
+ const choice = linuxTerminalCommand(['true'], env);
157
+ if (choice)
158
+ paths.terminal = onPath(choice.command, env) ?? choice.command;
159
+ continue;
160
+ }
161
+ const found = SPECS[id].linux.map(name => onPath(name, env)).find(Boolean);
162
+ if (found)
163
+ paths[id] = found;
164
+ }
165
+ }
166
+ const procs = ids.some(id => paths[id]) ? terminalNative.processes(env) : [];
167
+ const running = (id) => {
168
+ const path = paths[id];
169
+ if (!path)
170
+ return false;
171
+ if (platform === 'darwin')
172
+ return procs.some(p => p.startsWith(`${path}/Contents/MacOS/`));
173
+ const names = id === 'wezterm' ? ['wezterm-gui', 'wezterm'] : id === 'warp' ? ['warp-terminal', 'warp'] : id === 'terminal' ? [] : [basename(path)];
174
+ return procs.some(p => names.includes(basename(p)));
175
+ };
176
+ return ids.map(id => ({ id, name: SPECS[id].name, installed: !!paths[id], running: running(id), tab_supported: tabSupported(id, platform), path: paths[id] ?? null }));
177
+ }
178
+ /** The terminal a command runs in: TERM_PROGRAM, the terminals' own variables, then the app that started it. */
179
+ export function currentTerminal(env) {
180
+ const program = env.TERM_PROGRAM;
181
+ const byProgram = TERMINAL_IDS.find(id => program && SPECS[id].termProgram?.includes(program));
182
+ if (byProgram)
183
+ return byProgram;
184
+ if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty')
185
+ return 'kitty';
186
+ if (env.ALACRITTY_WINDOW_ID || env.ALACRITTY_SOCKET || env.TERM === 'alacritty')
187
+ return 'alacritty';
188
+ if (env.GHOSTTY_RESOURCES_DIR || env.TERM === 'xterm-ghostty')
189
+ return 'ghostty';
190
+ if (env.WEZTERM_PANE)
191
+ return 'wezterm';
192
+ if (env.ITERM_SESSION_ID)
193
+ return 'iterm';
194
+ return TERMINAL_IDS.find(id => env.__CFBundleIdentifier && SPECS[id].bundle === env.__CFBundleIdentifier) ?? null;
195
+ }
196
+ /** The choice saved with `agents targets --terminal`, or null (auto). */
197
+ export function storedTerminal(dir) {
198
+ const value = new AgentStateStore(dir).read().terminal_app;
199
+ if (!value || typeof value !== 'object' || !isId(value.id))
200
+ return null;
201
+ return { id: value.id, mode: value.mode === 'window' ? 'window' : 'tab' };
202
+ }
203
+ /**
204
+ * `--terminal <app>[:tab|:window]` checked against this computer: null for `auto` (clears the choice). A terminal that
205
+ * opens only windows takes `<app>` or `<app>:window`; without a mode, a tab where the app opens tabs.
206
+ */
207
+ export function parseTerminalChoice(spec, terminals, platform = process.platform) {
208
+ const [raw = '', mode, extra] = spec.trim().toLowerCase().split(':');
209
+ const id = raw === 'iterm2' ? 'iterm' : raw === 'apple-terminal' ? 'terminal' : raw;
210
+ if (id === 'auto' && mode === undefined)
211
+ return null;
212
+ const known = platformTerminals(platform);
213
+ if (!isId(id) || !known.includes(id) || extra !== undefined || (mode !== undefined && mode !== 'tab' && mode !== 'window')) {
214
+ throw new UsageError(`--terminal takes ${known.join(', ')} or auto, optionally with :tab or :window (e.g. ghostty:tab).`);
215
+ }
216
+ const info = terminals.find(t => t.id === id);
217
+ if (!info?.installed) {
218
+ const installed = terminals.filter(t => t.installed).map(t => t.id);
219
+ throw new UsageError(`--terminal: ${SPECS[id].name} is not installed on this computer${installed.length ? `; installed: ${installed.join(', ')}` : ''}.`);
220
+ }
221
+ if (mode === 'tab' && !info.tab_supported)
222
+ throw new UsageError(`--terminal: ${SPECS[id].name} opens each conversation in a new window; use ${id} or ${id}:window.`);
223
+ return { id, mode: mode ?? (info.tab_supported ? 'tab' : 'window') };
224
+ }
225
+ export function saveTerminalChoice(dir, choice) {
226
+ new AgentStateStore(dir).update(doc => { if (choice)
227
+ doc.terminal_app = choice;
228
+ else
229
+ delete doc.terminal_app; });
230
+ }
231
+ /** Where a terminal conversation opens now: the saved choice while installed, else the terminal this runs in, else a
232
+ * running terminal, else Terminal.app (macOS) or the default terminal (Linux). null when the computer has none. */
233
+ export function effectiveTerminal(stored, terminals, env) {
234
+ const installed = (id) => id ? terminals.find(t => t.id === id && t.installed) : undefined;
235
+ const saved = installed(stored?.id);
236
+ if (stored && saved)
237
+ return { id: stored.id, mode: saved.tab_supported ? stored.mode : 'window', saved: true };
238
+ const pick = installed(currentTerminal(env)) ?? terminals.find(t => t.installed && t.running && t.id !== 'terminal') ?? installed('terminal');
239
+ return pick ? { id: pick.id, mode: pick.tab_supported ? 'tab' : 'window', saved: false } : null;
240
+ }
241
+ /** The terminal part of `agents targets --json` (0.20.1+). */
242
+ export function terminalReport(dir, env, terminals) {
243
+ const stored = storedTerminal(dir);
244
+ const current = currentTerminal(env);
245
+ const effective = effectiveTerminal(stored, terminals, env);
246
+ return {
247
+ terminal_app: stored,
248
+ terminal: effective ? { ...effective, name: SPECS[effective.id].name } : null,
249
+ current_terminal: current && terminals.some(t => t.id === current) ? current : null,
250
+ terminals: terminals.map(({ id, name, installed, running, tab_supported, path }) => ({ id, name, installed, running, tab_supported, path })),
251
+ };
252
+ }
253
+ // ── The launcher ────────────────────────────────────────────────────────────────────────────────────────────────────
254
+ const BARE_WORD = /^[A-Za-z0-9_@%+=:,./-]+$/;
255
+ /** A shell word, quoted only when it needs it (the line shows in the person's terminal). */
256
+ export const shellWord = (s) => s && BARE_WORD.test(s) ? s : `'${s.replaceAll("'", "'\\''")}'`;
257
+ /** The command as typed: with this computer's config folder when it is not the default (an app's own, a test's). */
258
+ export function commandLine(argv, env) {
259
+ return `${env.SHARDFLUX_CONFIG_DIR ? `env SHARDFLUX_CONFIG_DIR=${shellWord(env.SHARDFLUX_CONFIG_DIR)} ` : ''}${argv.map(shellWord).join(' ')}`;
260
+ }
261
+ /** What a shell in a new tab types: into the project folder, then the command. */
262
+ export const typedLine = (argv, cwd, env) => `cd ${shellWord(cwd)} && ${commandLine(argv, env)}`;
263
+ /** A new window's own command (Linux, Alacritty): it keeps the window open on an error so the person can read it. */
264
+ export function windowCommand(argv, cwd, env) {
265
+ return ['sh', '-c', `cd ${shellWord(cwd)} 2>/dev/null; ${commandLine(argv, env)}; status=$?; if [ "$status" -ne 0 ]; then printf '\\nPress Enter to close.'; read answer; fi; exit "$status"`];
266
+ }
267
+ /** An AppleScript string literal. */
268
+ export const appleString = (s) => `"${s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n').replaceAll('\r', '\\r')}"`;
269
+ /**
270
+ * The AppleScript that opens `command` in `cwd` in a new shell of Ghostty, iTerm2 or Terminal.app. A tab goes in the
271
+ * front window; with no window, or `window`, a new window. An app this script starts uses the window it opens with.
272
+ */
273
+ export function appleScript(id, mode, command, cwd) {
274
+ const typed = appleString(`cd ${shellWord(cwd)} && ${command}`);
275
+ if (id === 'ghostty') {
276
+ // Ghostty 1.3+: a surface configuration carries the folder; the command is typed into the new tab's shell.
277
+ const run = (t) => [`set t to focused terminal of ${t}`, `input text ${appleString(command)} to t`, 'send key "enter" to t'];
278
+ const cfg = ['set cfg to new surface configuration', `set initial working directory of cfg to ${appleString(cwd)}`];
279
+ const lines = mode === 'window'
280
+ ? [...cfg, ...run('selected tab of (new window with configuration cfg)')]
281
+ : ['if not wasRunning and (count of windows) > 0 then', ` set t to focused terminal of selected tab of front window`, ` input text ${typed} to t`, ' send key "enter" to t',
282
+ 'else', ...cfg.map(l => ` ${l}`), ' if (count of windows) is 0 then', ...run('selected tab of (new window with configuration cfg)').map(l => ` ${l}`),
283
+ ' else', ...run('(new tab in front window with configuration cfg)').map(l => ` ${l}`), ' end if', 'end if'];
284
+ return ['set wasRunning to application id "com.mitchellh.ghostty" is running', 'tell application id "com.mitchellh.ghostty"', ' activate',
285
+ ...lines.map(l => ` ${l}`), 'end tell'].join('\n');
286
+ }
287
+ if (id === 'iterm') {
288
+ const window = `tell current session of (create window with default profile) to write text ${typed}`;
289
+ const lines = mode === 'window' ? [window]
290
+ : ['if (count of windows) is 0 then', ` ${window}`, 'else if not wasRunning then', ` tell current session of current window to write text ${typed}`, 'else',
291
+ ` tell current window to tell current session of (create tab with default profile) to write text ${typed}`, 'end if'];
292
+ return ['set wasRunning to application id "com.googlecode.iterm2" is running', 'tell application id "com.googlecode.iterm2"', ' activate',
293
+ ...lines.map(l => ` ${l}`), 'end tell'].join('\n');
294
+ }
295
+ return ['tell application id "com.apple.Terminal"', ' activate', ` do script ${typed}`, 'end tell'].join('\n');
296
+ }
297
+ /** Warp's tab config for one conversation (`warp://tab_config/shardflux`). */
298
+ export function warpTabConfig(argv, cwd, env) {
299
+ return [
300
+ '# Written by Shardflux each time it opens a conversation in Warp.',
301
+ 'name = "Shardflux conversation"',
302
+ '',
303
+ '[[panes]]',
304
+ 'id = "conversation"',
305
+ 'type = "terminal"',
306
+ `directory = ${JSON.stringify(cwd)}`,
307
+ `commands = [${JSON.stringify(commandLine(argv, env))}]`,
308
+ 'is_focused = true',
309
+ '',
310
+ ].join('\n');
311
+ }
312
+ export function warpTabConfigFile(env, platform = process.platform) {
313
+ const home = env.HOME || homedir();
314
+ return platform === 'darwin' ? join(home, '.warp', 'tab_configs', 'shardflux.toml')
315
+ : join(env.XDG_DATA_HOME || join(home, '.local', 'share'), 'warp-terminal', 'tab_configs', 'shardflux.toml');
316
+ }
317
+ /** kitty's remote-control socket: KITTY_LISTEN_ON, else `listen_on` in kitty.conf (kitty adds -<pid> to a unix path). */
318
+ export function kittySocket(env, platform = process.platform) {
319
+ if (env.KITTY_LISTEN_ON)
320
+ return env.KITTY_LISTEN_ON;
321
+ const home = env.HOME || homedir();
322
+ const files = [join(env.KITTY_CONFIG_DIRECTORY || join(env.XDG_CONFIG_HOME || join(home, '.config'), 'kitty'), 'kitty.conf'),
323
+ ...(platform === 'darwin' ? [join(home, 'Library', 'Preferences', 'kitty', 'kitty.conf')] : [])];
324
+ for (const file of files) {
325
+ let text;
326
+ try {
327
+ text = readFileSync(file, 'utf8');
328
+ }
329
+ catch {
330
+ continue;
331
+ }
332
+ const value = [...text.matchAll(/^\s*listen_on\s+(\S+)/gm)].at(-1)?.[1];
333
+ if (!value)
334
+ continue;
335
+ if (!value.startsWith('unix:') || value.startsWith('unix:@'))
336
+ return value;
337
+ const path = value.slice(5).replace(/^~(?=\/)/, home);
338
+ if (existsSync(path))
339
+ return `unix:${path}`;
340
+ const prefix = basename(path.replace('{kitty_pid}', '')).replace(/-$/, '');
341
+ try {
342
+ const match = readdirSync(dirname(path)).filter(n => n.startsWith(`${prefix}-`)).map(n => join(dirname(path), n))
343
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
344
+ if (match)
345
+ return `unix:${match}`;
346
+ }
347
+ catch { /* no socket folder */ }
348
+ }
349
+ return null;
350
+ }
351
+ /** Who macOS asks to allow: the app this runs under (the menu bar, a terminal, the Claude or Codex app). */
352
+ function requester(env) {
353
+ const bundle = env.__CFBundleIdentifier ?? '';
354
+ if (bundle.startsWith('dev.shardflux.'))
355
+ return 'Shardflux';
356
+ if (bundle === 'com.anthropic.claudefordesktop')
357
+ return 'Claude';
358
+ if (bundle === 'com.openai.codex')
359
+ return 'Codex';
360
+ const id = TERMINAL_IDS.find(t => SPECS[t].bundle === bundle) ?? currentTerminal(env);
361
+ return id ? SPECS[id].name : 'your terminal app';
362
+ }
363
+ const firstLine = (text) => text.trim().split('\n').at(-1)?.trim().slice(0, 200) ?? '';
364
+ /** Opens it in one terminal: undefined once it opened, else the one-line reason. */
365
+ async function openWith(t, mode, l) {
366
+ const { argv, cwd, env, platform } = l;
367
+ const line = typedLine(argv, cwd, env);
368
+ const name = t.name;
369
+ const command = commandLine(argv, env);
370
+ const mac = platform === 'darwin';
371
+ const activate = async () => { if (mac && t.path)
372
+ await terminalNative.run('/usr/bin/open', ['-a', t.path], { env, timeoutMs: 5000 }); };
373
+ const bin = (macName, linuxName) => mac ? (t.path ? join(t.path, 'Contents', 'MacOS', macName) : onPath(macName, env)) : onPath(linuxName, env);
374
+ if (mac && (t.id === 'ghostty' || t.id === 'iterm' || t.id === 'terminal')) {
375
+ if (t.id === 'ghostty' && t.path) {
376
+ const version = terminalNative.appVersion(t.path);
377
+ if (version && compareVersions(version, '1.3.0') < 0)
378
+ return `Ghostty ${version} can’t take commands from other apps; update Ghostty to 1.3 or later.`;
379
+ }
380
+ const r = await terminalNative.run('/usr/bin/osascript', ['-e', `with timeout of 20 seconds\n${appleScript(t.id, mode, command, cwd)}\nend timeout`], { env, timeoutMs: 25_000 });
381
+ if (r.code === 0)
382
+ return undefined;
383
+ if (/-1743|not authori[sz]ed|not allowed to send/i.test(r.stderr))
384
+ return `Allow ${requester(env)} to control ${name} in System Settings → Privacy & Security → Automation.`;
385
+ return `${name} didn’t open it${firstLine(r.stderr) ? `: ${firstLine(r.stderr)}` : ''}.`;
386
+ }
387
+ if (t.id === 'warp') {
388
+ const file = warpTabConfigFile(env, platform);
389
+ try {
390
+ terminalNative.writeFile(file, warpTabConfig(argv, cwd, env));
391
+ }
392
+ catch (error) {
393
+ return `Warp’s tab config could not be written: ${error.message}.`;
394
+ }
395
+ const r = await terminalNative.run(mac ? '/usr/bin/open' : 'xdg-open', [`warp://tab_config/shardflux${mode === 'window' ? '?new_window=true' : ''}`], { env, timeoutMs: 10_000 });
396
+ return r.code === 0 ? undefined : `Warp didn’t open its link${firstLine(r.stderr) ? `: ${firstLine(r.stderr)}` : ''}.`;
397
+ }
398
+ if (t.id === 'wezterm') {
399
+ const wezterm = bin('wezterm', 'wezterm');
400
+ if (!wezterm)
401
+ return 'WezTerm’s wezterm command was not found.';
402
+ const spawned = await terminalNative.run(wezterm, ['cli', 'spawn', ...(mode === 'window' ? ['--new-window'] : []), '--cwd', cwd], { env, timeoutMs: 10_000 });
403
+ const pane = spawned.stdout.trim();
404
+ if (spawned.code === 0 && /^\d+$/.test(pane)) {
405
+ const sent = await terminalNative.run(wezterm, ['cli', 'send-text', '--pane-id', pane, '--no-paste'], { env, input: `${line}\r`, timeoutMs: 10_000 });
406
+ if (sent.code !== 0)
407
+ return `WezTerm didn’t take the command${firstLine(sent.stderr) ? `: ${firstLine(sent.stderr)}` : ''}.`;
408
+ await terminalNative.run(wezterm, ['cli', 'activate-pane', '--pane-id', pane], { env, timeoutMs: 5000 });
409
+ await activate();
410
+ return undefined;
411
+ }
412
+ if (!t.running && await terminalNative.start(wezterm, ['start', '--cwd', cwd, '--', ...windowCommand(argv, cwd, env)], { env, cwd }))
413
+ return undefined;
414
+ return `WezTerm didn’t open a tab${firstLine(spawned.stderr) ? `: ${firstLine(spawned.stderr)}` : ''}.`;
415
+ }
416
+ if (t.id === 'kitty') {
417
+ const kitten = bin('kitten', 'kitten'), kitty = bin('kitty', 'kitty');
418
+ const socket = kittySocket(env, platform);
419
+ if (kitten && socket) {
420
+ const launched = await terminalNative.run(kitten, ['@', '--to', socket, 'launch', `--type=${mode === 'tab' ? 'tab' : 'os-window'}`, `--cwd=${cwd}`], { env, timeoutMs: 10_000 });
421
+ const window = launched.stdout.trim();
422
+ if (launched.code === 0 && /^\d+$/.test(window)) {
423
+ const sent = await terminalNative.run(kitten, ['@', '--to', socket, 'send-text', `--match=id:${window}`, '--stdin'], { env, input: `${line}\r`, timeoutMs: 10_000 });
424
+ if (sent.code !== 0)
425
+ return `kitty didn’t take the command${firstLine(sent.stderr) ? `: ${firstLine(sent.stderr)}` : ''}.`;
426
+ await activate();
427
+ return undefined;
428
+ }
429
+ }
430
+ if (!t.running && kitty && await terminalNative.start(kitty, ['--directory', cwd, ...windowCommand(argv, cwd, env)], { env, cwd }))
431
+ return undefined;
432
+ return 'Turn on remote control in kitty (allow_remote_control yes and listen_on unix:/tmp/kitty in kitty.conf), then restart kitty.';
433
+ }
434
+ if (t.id === 'alacritty') {
435
+ const alacritty = bin('alacritty', 'alacritty');
436
+ if (!alacritty)
437
+ return 'Alacritty’s alacritty command was not found.';
438
+ if (t.running) {
439
+ const r = await terminalNative.run(alacritty, ['msg', 'create-window', '--working-directory', cwd, '-e', ...windowCommand(argv, cwd, env)], { env, timeoutMs: 10_000 });
440
+ if (r.code === 0) {
441
+ await activate();
442
+ return undefined;
443
+ }
444
+ return `Alacritty didn’t open a window${firstLine(r.stderr) ? `: ${firstLine(r.stderr)}` : ''}.`;
445
+ }
446
+ return await terminalNative.start(alacritty, ['--working-directory', cwd, '-e', ...windowCommand(argv, cwd, env)], { env, cwd }) ? undefined : 'Alacritty did not start.';
447
+ }
448
+ if (t.id === 'ghostty') {
449
+ // Linux: a window in the running Ghostty over D-Bus (1.2+), else a new Ghostty.
450
+ const ghostty = onPath('ghostty', env);
451
+ if (!ghostty)
452
+ return 'Ghostty’s ghostty command was not found.';
453
+ const r = await terminalNative.run(ghostty, ['+new-window', '-e', ...windowCommand(argv, cwd, env)], { env, timeoutMs: 10_000 });
454
+ if (r.code === 0)
455
+ return undefined;
456
+ return await terminalNative.start(ghostty, [`--working-directory=${cwd}`, '-e', ...windowCommand(argv, cwd, env)], { env, cwd }) ? undefined : 'Ghostty did not start.';
457
+ }
458
+ // Linux's default terminal: $TERMINAL, x-terminal-emulator, gnome-terminal.
459
+ const choice = linuxTerminalCommand([...argv], env, cwd);
460
+ if (!choice)
461
+ return 'No terminal emulator was found ($TERMINAL, x-terminal-emulator or gnome-terminal).';
462
+ return await terminalNative.start(choice.command, choice.args, { env, cwd }) ? undefined : `${choice.command} did not start.`;
463
+ }
464
+ export function compareVersions(a, b) {
465
+ const pa = a.split(/[.-]/).map(n => Number.parseInt(n, 10) || 0), pb = b.split(/[.-]/).map(n => Number.parseInt(n, 10) || 0);
466
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++)
467
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0))
468
+ return (pa[i] ?? 0) - (pb[i] ?? 0);
469
+ return 0;
470
+ }
471
+ /**
472
+ * Opens `argv` in the person's terminal app, in `cwd`: the chosen app (effectiveTerminal) as a tab, or a window with
473
+ * `window`; when it fails, the other running terminals, then Terminal.app or the Linux default terminal.
474
+ */
475
+ export async function openInTerminal(dir, env, argv, opts) {
476
+ const platform = opts.platform ?? process.platform;
477
+ const stored = storedTerminal(dir);
478
+ let terminals = detectTerminals(env, platform);
479
+ if (stored && !terminals.find(t => t.id === stored.id)?.installed)
480
+ terminals = detectTerminals(env, platform, { spotlight: true });
481
+ const chosen = effectiveTerminal(stored, terminals, env);
482
+ const command = [...argv];
483
+ if (!chosen)
484
+ return { opened: false, app: null, mode: null, command, reason: platform === 'darwin' || platform === 'linux' ? 'No terminal app was found on this computer.' : 'Opening a terminal app needs macOS or Linux.' };
485
+ const order = [chosen.id, ...terminals.filter(t => t.installed && t.running && t.id !== 'terminal').map(t => t.id), 'terminal'];
486
+ let reason;
487
+ for (const id of [...new Set(order)]) {
488
+ const t = terminals.find(x => x.id === id && x.installed);
489
+ if (!t)
490
+ continue;
491
+ const mode = !t.tab_supported || opts.window ? 'window' : id === chosen.id ? chosen.mode : 'tab';
492
+ const failed = await openWith(t, mode, { argv, cwd: opts.cwd, env, platform });
493
+ if (!failed)
494
+ return { opened: true, app: id, mode, command, ...(id !== chosen.id ? { fallback_from: chosen.id, ...(reason ? { reason } : {}) } : {}) };
495
+ reason ??= failed;
496
+ }
497
+ return { opened: false, app: null, mode: null, command, ...(reason ? { reason } : {}) };
498
+ }
499
+ /** "Opened in Ghostty (new tab)." and, when it fell back, the reason on its own line. */
500
+ export function openedText(result) {
501
+ if (!result.opened || !result.app)
502
+ return { line: '', ...(result.reason ? { note: result.reason } : {}) };
503
+ const line = `Opened in ${SPECS[result.app].name} (new ${result.mode}).`;
504
+ return result.fallback_from ? { line, note: `Not in ${SPECS[result.fallback_from].name}: ${result.reason ?? 'it did not open.'}` } : { line };
505
+ }
506
+ /** The runnable command that opens a conversation: this shard (an app's own Node.js and script) when it is not the
507
+ * `shard` on PATH, with its API. */
508
+ export function shardArgv(runtime, apiUrl, words, env) {
509
+ if (runtime?.script && !sameFile(onPath('shard', env), runtime.script))
510
+ return [runtime.node, runtime.script, '--api-url', apiUrl, ...words];
511
+ return ['shard', '--api-url', apiUrl, ...words];
512
+ }
513
+ function sameFile(a, b) {
514
+ if (!a)
515
+ return false;
516
+ try {
517
+ return realpathSync(a) === realpathSync(b);
518
+ }
519
+ catch {
520
+ return false;
521
+ }
522
+ }
523
+ // ── Commands ────────────────────────────────────────────────────────────────────────────────────────────────────────
524
+ /** A terminal a person types into: not a pipe, and not a coding agent's shell (a PTY nobody types into, 0.17.1+). */
525
+ export const attachable = (io) => io.stdin.isTTY === true && io.stdout.isTTY !== false && !codingAgentShell(io.env);
526
+ /**
527
+ * The local folder a conversation or agent belongs to, where its terminal tab starts: the conversation's record, the
528
+ * Git agent's repository, else the current folder (the home folder for an app, whose folder is /).
529
+ */
530
+ export function referenceFolder(dir, apiUrl, ref, env) {
531
+ const doc = new AgentStateStore(dir).read();
532
+ const conversations = (doc.conversations ?? {});
533
+ const repos = (doc.git_repositories ?? {});
534
+ const record = conversations[ref]?.api_url === apiUrl ? conversations[ref]
535
+ : Object.values(conversations).find(c => c.api_url === apiUrl && c.conversation?.key === ref);
536
+ const repo = Object.values(repos).find(r => Object.values(r.agents ?? {}).some(a => a.workspace_key === ref));
537
+ const isDir = (p) => { try {
538
+ return typeof p === 'string' && p.startsWith('/') && statSync(p).isDirectory();
539
+ }
540
+ catch {
541
+ return false;
542
+ } };
543
+ const cwd = process.cwd();
544
+ return [record?.folder, repo?.root].find(isDir) ?? (cwd !== '/' ? cwd : env.HOME || homedir());
545
+ }
546
+ /** `--json`: `{target: 'terminal', opened, app, mode, command, fallback_from?, reason?}`; text: where it opened, the
547
+ * reason it fell back on stderr, and with nothing opened the runnable command. */
548
+ export function reportTerminalOpen(ctx, result, extra = {}) {
549
+ if (ctx.json) {
550
+ ctx.io.stdout.write(`${JSON.stringify({ target: 'terminal', ...extra, ...result })}\n`);
551
+ return;
552
+ }
553
+ const text = openedText(result);
554
+ if (text.note)
555
+ ctx.io.stderr.write(`${text.note}\n`);
556
+ ctx.io.stdout.write(result.opened ? `${text.line}\n` : `${commandLine(result.command, ctx.io.env)}\n`);
557
+ }
558
+ /** Opens `shard <words>` (a conversation's attach) in the person's terminal app, in `cwd`. */
559
+ export function openShardInTerminal(ctx, words, opts) {
560
+ return openInTerminal(ctx.store.dir, ctx.io.env, shardArgv(ctx.runtime, ctx.apiUrl, words, ctx.io.env), opts);
561
+ }
@@ -6,6 +6,8 @@ export interface OnboardingDeps {
6
6
  report?: typeof onboardingReport;
7
7
  startHelper?: () => void;
8
8
  loginCodex?: (ctx: Ctx) => Promise<void>;
9
+ /** Right after the Agents step (0.20.0+): `shard machine setup --from-this-computer --if-needed`, detached. */
10
+ startMachineSetup?: (ctx: Ctx) => boolean;
9
11
  }
10
12
  /** Linux's first run orchestrates the same auth, setup and live inspection used by the Mac onboarding. */
11
13
  export declare function terminalOnboarding(ctx: Ctx, values: Values, deps?: OnboardingDeps): Promise<number>;
@@ -6,6 +6,8 @@ import { foundApps, onboardingReport, parseApps } from "./onboarding.js";
6
6
  import { requireTermsAcceptance, showLegalNotice } from "./legal.js";
7
7
  import { json } from "./format.js";
8
8
  import { UsageError } from "./errors.js";
9
+ import { startMachineSetupInBackground } from "./machine-setup.js";
10
+ import { rowText } from "./machine-status.js";
9
11
  const handlers = { ...ACCOUNT_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...CODEX_CLOUD_HANDLERS };
10
12
  async function loginCodex(ctx) {
11
13
  const child = spawn('codex', ['login', ...(!ctx.io.env.DISPLAY && !ctx.io.env.WAYLAND_DISPLAY ? ['--device-auth'] : [])], { env: ctx.io.env, stdio: 'inherit', signal: ctx.signal });
@@ -61,13 +63,21 @@ export async function terminalOnboarding(ctx, values, deps = {}) {
61
63
  }
62
64
  }
63
65
  state = await report(ctx, inspect);
66
+ // The machine sets itself up from this computer in the background; it never holds the onboarding back.
67
+ const settingUp = state.ready && (deps.startMachineSetup ?? startMachineSetupInBackground)(ctx);
64
68
  if (ctx.json)
65
69
  ctx.io.stdout.write(json({ onboarding: state, next: 'shard ./' }));
66
70
  else {
67
71
  for (const c of state.connections.filter(c => c.id === 'account' || c.picked))
68
72
  ctx.io.stdout.write(`${c.id}: ${c.fact}${c.action?.command && c.state !== 'verified' ? ` Run ${c.action.command.join(' ')}.` : ''}\n`);
73
+ if (settingUp)
74
+ ctx.io.stdout.write('Your agent is setting up your cloud machine from this computer in the background (shard machine setup --status).\n');
75
+ // Not set up from here this time (--if-needed found this computer's earlier setup): what the machine is now, so a
76
+ // failed save is never mistaken for one that carries your tools over.
77
+ else if (state.machine)
78
+ ctx.io.stdout.write(`machine: ${rowText(state.machine)}\n`);
69
79
  if (state.ready)
70
- ctx.io.stdout.write('Ready. Run shard ./ in your project.\n');
80
+ ctx.io.stdout.write('Agent connections ready. Run shard ./ in your project.\n');
71
81
  else
72
82
  ctx.io.stdout.write('Finish the connection above, then run shard again.\n');
73
83
  }
@@ -0,0 +1,11 @@
1
+ export declare const thisComputer: (platform?: NodeJS.Platform) => string;
2
+ export declare const yourComputer: (platform?: NodeJS.Platform) => string;
3
+ /** The Linux app's default launcher shortcut (apps/desktop-linux/src/main.c), as a GTK accelerator. */
4
+ export declare const LINUX_DEFAULT_SHORTCUT = "<Alt><Shift>space";
5
+ /** A GTK accelerator ("<Control><Shift>space") as the Linux app shows it ("Ctrl+Shift+Space"); shortcutLabel in its core.mjs. */
6
+ export declare function acceleratorLabel(accelerator: string): string;
7
+ /**
8
+ * The launcher's shortcut as the person presses it: ⌥⇧Space on macOS (the menu bar's), the Linux app's configured one
9
+ * on Linux, undefined elsewhere.
10
+ */
11
+ export declare function launcherShortcut(configDir: string, platform?: NodeJS.Platform): string | undefined;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How the CLI names the computer it runs on and the launcher's shortcut there (0.20.2+): "this Mac" and ⌥⇧Space on
3
+ * macOS; "this computer" and the Linux app's configured shortcut (desktop-linux.json, default Alt+Shift+Space) on
4
+ * Linux. Every line a person reads on Linux comes through here, never a Mac word.
5
+ */
6
+ import { readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ export const thisComputer = (platform = process.platform) => (platform === 'darwin' ? 'this Mac' : 'this computer');
9
+ export const yourComputer = (platform = process.platform) => (platform === 'darwin' ? 'your Mac' : 'your computer');
10
+ /** The Linux app's default launcher shortcut (apps/desktop-linux/src/main.c), as a GTK accelerator. */
11
+ export const LINUX_DEFAULT_SHORTCUT = '<Alt><Shift>space';
12
+ /** A GTK accelerator ("<Control><Shift>space") as the Linux app shows it ("Ctrl+Shift+Space"); shortcutLabel in its core.mjs. */
13
+ export function acceleratorLabel(accelerator) {
14
+ const names = { Control: 'Ctrl', Primary: 'Ctrl', Ctrl: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Super: 'Super', Meta: 'Meta' };
15
+ const mods = [...accelerator.matchAll(/<(\w+)>/g)].map(m => names[m[1]] ?? m[1]);
16
+ const key = accelerator.replace(/<\w+>/g, '');
17
+ return [...mods, key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1)].join('+');
18
+ }
19
+ /**
20
+ * The launcher's shortcut as the person presses it: ⌥⇧Space on macOS (the menu bar's), the Linux app's configured one
21
+ * on Linux, undefined elsewhere.
22
+ */
23
+ export function launcherShortcut(configDir, platform = process.platform) {
24
+ if (platform === 'darwin')
25
+ return '⌥⇧Space';
26
+ if (platform !== 'linux')
27
+ return undefined;
28
+ let accelerator = LINUX_DEFAULT_SHORTCUT;
29
+ try {
30
+ const prefs = JSON.parse(readFileSync(join(configDir, 'desktop-linux.json'), 'utf8'));
31
+ if (typeof prefs.shortcut === 'string' && /^(<\w+>)+\S+$/.test(prefs.shortcut))
32
+ accelerator = prefs.shortcut;
33
+ }
34
+ catch { /* the app's default */ }
35
+ return acceleratorLabel(accelerator);
36
+ }