@shardflux/cli 0.24.0 → 0.24.1

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 (49) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +227 -1
  3. package/dist/account-args.js +5 -0
  4. package/dist/account-pool.js +5 -1
  5. package/dist/acp-permission-elsewhere.d.ts +38 -0
  6. package/dist/acp-permission-elsewhere.js +85 -0
  7. package/dist/acp-rpc.d.ts +2 -1
  8. package/dist/acp-rpc.js +11 -2
  9. package/dist/acp.js +95 -22
  10. package/dist/agent-credentials.d.ts +3 -0
  11. package/dist/agent-credentials.js +7 -0
  12. package/dist/args.d.ts +1 -1
  13. package/dist/args.js +143 -8
  14. package/dist/board-args.d.ts +6 -0
  15. package/dist/board-args.js +155 -0
  16. package/dist/board-commands.d.ts +62 -0
  17. package/dist/board-commands.js +395 -0
  18. package/dist/codex-cloud.d.ts +30 -3
  19. package/dist/codex-cloud.js +86 -12
  20. package/dist/conversation-commands.d.ts +30 -0
  21. package/dist/conversation-commands.js +312 -9
  22. package/dist/conversation-transcript.d.ts +44 -0
  23. package/dist/conversation-transcript.js +206 -0
  24. package/dist/conversations.d.ts +5 -2
  25. package/dist/conversations.js +20 -10
  26. package/dist/device-approvals.js +4 -3
  27. package/dist/device-bridge.d.ts +42 -1
  28. package/dist/device-bridge.js +223 -12
  29. package/dist/device-folders.d.ts +58 -0
  30. package/dist/device-folders.js +228 -0
  31. package/dist/device-helper.d.ts +6 -0
  32. package/dist/device-helper.js +87 -14
  33. package/dist/drive-commands.d.ts +25 -0
  34. package/dist/drive-commands.js +201 -0
  35. package/dist/handlers.js +6 -1
  36. package/dist/main.js +3 -0
  37. package/dist/move-files.d.ts +39 -0
  38. package/dist/move-files.js +167 -0
  39. package/dist/move.js +12 -11
  40. package/dist/onboarding.d.ts +6 -2
  41. package/dist/onboarding.js +24 -6
  42. package/dist/project-git.d.ts +7 -1
  43. package/dist/project-git.js +16 -3
  44. package/dist/project-preparation.d.ts +7 -0
  45. package/dist/project-preparation.js +14 -0
  46. package/dist/project-template-setup.js +32 -11
  47. package/dist/router-commands.d.ts +22 -0
  48. package/dist/router-commands.js +179 -0
  49. package/package.json +2 -2
@@ -15,20 +15,16 @@ import { conversationClient } from "./conversation-commands.js";
15
15
  import { withTargetLock } from "./target-lock.js";
16
16
  import { checkConversationWorktree, rememberConversationWorktree } from "./conversation-worktree.js";
17
17
  import { yourComputer } from "./this-computer.js";
18
+ import { canonicalDevicePath, deviceHome, displayPath, expandDevicePath, FOLDER_KINDS, isDevicePath, linkLocalProject, listDeviceDir } from "./device-folders.js";
18
19
  const LIMIT = 256 << 20, OUTPUT = (192 << 10) - 128;
19
20
  const inside = (root, target) => target === root || target.startsWith(root + sep);
21
+ /**
22
+ * A request belongs to its conversation, not to a turn: it is answered while the conversation exists, between turns
23
+ * (done) and as a turn ends too (0.25.0+); only an archived (or deleted) conversation refuses it.
24
+ */
25
+ const archived = (c) => c.state === 'archived';
20
26
  const safeId = (value) => { if (!/^[a-zA-Z0-9_-]{1,96}$/.test(value))
21
27
  throw new Error('Invalid device request identity'); return value; };
22
- function canonicalDevicePath(target) {
23
- let ancestor = target;
24
- while (!existsSync(ancestor)) {
25
- const parent = dirname(ancestor);
26
- if (parent === ancestor)
27
- throw new UsageError('Device path is unavailable.');
28
- ancestor = parent;
29
- }
30
- return resolve(realpathSync(ancestor), relative(ancestor, target));
31
- }
32
28
  /** Resolve existing ancestors even before the conversation worktree is created. */
33
29
  export function devicePath(root, requested = '.') {
34
30
  if (/[\0\r\n]/.test(requested))
@@ -60,6 +56,30 @@ export class DeviceGrants {
60
56
  const grant = this.read()[this.key(api, project)];
61
57
  return !outside && grant?.root === realpathSync(root) && Array.isArray(grant.kinds) && grant.kinds.includes(kind);
62
58
  }
59
+ /** A folder grant (0.25.0+): Always for `root` and everything inside it, for one kind of request. */
60
+ folderKey(api, root) { return JSON.stringify(['folder', api, root]); }
61
+ allowsFolder(api, target, kind) {
62
+ return Object.entries(this.read()).some(([key, grant]) => {
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(key);
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ return Array.isArray(parsed) && parsed.length === 3 && parsed[0] === 'folder' && parsed[1] === api && typeof grant?.root === 'string' && parsed[2] === grant.root
71
+ && Array.isArray(grant.kinds) && grant.kinds.includes(kind) && inside(grant.root, target);
72
+ });
73
+ }
74
+ async saveFolder(api, root, kind) {
75
+ if (kind === 'exec')
76
+ throw new UsageError('Commands never run under a folder grant.');
77
+ await withTargetLock(join(dirname(this.file), 'device-grants-lock'), () => {
78
+ const all = this.read(), key = this.folderKey(api, root);
79
+ all[key] = { root, kinds: [...new Set([...(all[key]?.root === root ? all[key].kinds : []), kind])] };
80
+ writePrivateFile(dirname(this.file), this.file, JSON.stringify(all) + '\n');
81
+ });
82
+ }
63
83
  async save(api, project, root, kind) {
64
84
  await withTargetLock(join(dirname(this.file), 'device-grants-lock'), () => {
65
85
  const all = this.read(), key = this.key(api, project), canonical = realpathSync(root);
@@ -70,6 +90,10 @@ export class DeviceGrants {
70
90
  }
71
91
  export function deviceSummary(r, platform = process.platform) {
72
92
  const yours = yourComputer(platform);
93
+ if (r.kind === 'dir_list')
94
+ return `List ${r.dir?.path} on ${yours}: names, sizes and Git remotes, no file contents.`;
95
+ if (r.kind === 'project_link')
96
+ return `Make ${r.link?.path} a project and copy its files to the cloud${r.link?.brief ? ', then start a conversation there' : ''}.`;
73
97
  if (tryLocallyAction(r))
74
98
  return tryLocallyAction(r) === 'stop' ? 'Stop this conversation’s local sync and ports.' : 'Try this conversation in an isolated local worktree with two-way sync and localhost ports.';
75
99
  if (r.kind === 'exec')
@@ -88,6 +112,23 @@ export function deviceApproval(r, c, target, folder) {
88
112
  ...(r.port ? { local_port: r.port.local_port, remote_port: r.port.remote_port } : {}),
89
113
  };
90
114
  }
115
+ /** A request on a folder anywhere on this device: the folder kinds, and file moves named by an absolute or ~/ path. */
116
+ export function folderRequest(r) {
117
+ return FOLDER_KINDS.has(r.kind) || ((r.kind === 'files_pull' || r.kind === 'files_push') && !!r.files && isDevicePath(r.files.local_path_relative));
118
+ }
119
+ /** What a folder request's approval shows: the canonical path, the folder Always would cover, and what will happen. */
120
+ export function folderApproval(r, c, target, folder, home, remote, platform = process.platform) {
121
+ const shown = displayPath(target, home), yours = yourComputer(platform);
122
+ const summary = r.kind === 'dir_list' ? `List ${shown} on ${yours}: names, sizes and Git remotes, no file contents.`
123
+ : r.kind === 'project_link' ? `Make ${shown} a project and copy its files to the cloud (uncommitted changes too; .gitignore respected)${r.link?.brief ? ', then start a conversation there' : ''}.`
124
+ : r.kind === 'files_pull' ? `Share with conversation: ${shown} → ${r.files?.remote_path}` : `Save on ${yours}: ${r.files?.remote_path} → ${shown}`;
125
+ return {
126
+ type: 'approval', request_id: r.id, project_id: r.project_id, conversation_id: c.id, summary, outside: true, scope: 'folder', folder,
127
+ kind: r.kind, local_path: target, project_name: basename(folder), conversation_title: c.title || conversationName(c),
128
+ ...(r.files ? { remote_path: r.files.remote_path } : {}), ...(r.dir ? { depth: r.dir.depth ?? 1 } : {}),
129
+ ...(r.kind === 'project_link' ? { repo_remote: remote ?? null, ...(r.link?.brief ? { brief: r.link.brief } : {}) } : {}),
130
+ };
131
+ }
91
132
  export function tryLocallyAction(r) {
92
133
  if (r.kind === 'try_locally')
93
134
  return 'start';
@@ -171,6 +212,8 @@ export class DeviceExecutor {
171
212
  safeId(r.id);
172
213
  if (r.state !== 'pending' || r.device_id !== this.#deviceId)
173
214
  return;
215
+ if (folderRequest(r))
216
+ return this.#folder(r);
174
217
  const records = localConversations(ctx);
175
218
  const matching = Object.values(records).filter(record => record.project_id === r.project_id);
176
219
  const folders = new Set(matching.map(record => record.folder));
@@ -199,7 +242,7 @@ export class DeviceExecutor {
199
242
  if (devicePath(record.folder, join('.shard', 'worktrees', conversationName(c))).path !== planned.path || devicePath(planned.path, requested).path !== target.path)
200
243
  throw new Error('Device path changed during approval.');
201
244
  const current = await getConversation(ctx, r.conversation_id);
202
- if (current.project_id !== c.project_id || current.branch !== c.branch || current.state === 'done' || current.state === 'archived')
245
+ if (current.project_id !== c.project_id || current.branch !== c.branch || archived(current))
203
246
  throw new Error('The conversation changed or ended during device approval.');
204
247
  c = current;
205
248
  await this.result(r, { state: 'approved' });
@@ -214,7 +257,7 @@ export class DeviceExecutor {
214
257
  if (root !== planned.path || devicePath(root, requested).path !== target.path)
215
258
  throw new Error('Device path changed during preparation.');
216
259
  const prepared = await getConversation(ctx, c.id);
217
- if (prepared.project_id !== c.project_id || prepared.workspace_id !== c.workspace_id || prepared.branch !== c.branch || prepared.state === 'done' || prepared.state === 'archived')
260
+ if (prepared.project_id !== c.project_id || prepared.workspace_id !== c.workspace_id || prepared.branch !== c.branch || archived(prepared))
218
261
  throw new Error('The conversation changed or ended during device preparation.');
219
262
  const cell = ws.cell();
220
263
  let result;
@@ -270,6 +313,8 @@ export class DeviceExecutor {
270
313
  result = { state: 'done', files_artifact: r.kind === 'files_pull' ? f.remote_path : relative(root, target.path) };
271
314
  }
272
315
  else {
316
+ if (r.kind !== 'port_forward' && r.kind !== 'port_reverse')
317
+ throw new UsageError('Unknown device request kind.');
273
318
  if (!r.port || ![r.port.local_port, r.port.remote_port].every(p => Number.isInteger(p) && p > 0 && p <= 65535))
274
319
  throw new UsageError('Device ports must be 1–65535.');
275
320
  const handle = await devicePort(ws, cell, r.kind, r.port, ctx.signal);
@@ -286,6 +331,129 @@ export class DeviceExecutor {
286
331
  ws?.cell().close();
287
332
  }
288
333
  }
334
+ /**
335
+ * A folder anywhere on this device (device-folders.ts). Every outcome is answered, so a request is never asked twice:
336
+ * denied before it was approved, failed after.
337
+ */
338
+ async #folder(r) {
339
+ const ctx = this.#ctx, home = deviceHome(ctx), deny = (message) => this.result(r, { state: 'denied', stderr_b64: Buffer.from(message).toString('base64') });
340
+ let c;
341
+ try {
342
+ c = await getConversation(ctx, r.conversation_id);
343
+ if (c.project_id !== r.project_id)
344
+ throw new Error('Device request project changed.');
345
+ }
346
+ catch (e) {
347
+ if (ctx.signal.aborted)
348
+ return;
349
+ await deny(e instanceof Error ? e.message : String(e));
350
+ return;
351
+ }
352
+ if (archived(c)) {
353
+ await deny('The conversation has ended.');
354
+ return;
355
+ }
356
+ const requested = r.dir?.path ?? r.link?.path ?? r.files?.local_path_relative ?? '';
357
+ const resolveTarget = () => canonicalDevicePath(expandDevicePath(requested, home));
358
+ let target, folder, remote = null;
359
+ try {
360
+ target = resolveTarget();
361
+ folder = r.kind === 'files_pull' || r.kind === 'files_push' ? canonicalDevicePath(dirname(target)) : target;
362
+ if (r.kind === 'project_link') {
363
+ const { projectIdentity } = await import("./project-identity.js");
364
+ remote = projectIdentity(target).remote;
365
+ }
366
+ }
367
+ catch (e) {
368
+ await deny(e instanceof Error ? e.message : String(e));
369
+ return;
370
+ }
371
+ const grants = new DeviceGrants(ctx.store.dir);
372
+ let choice;
373
+ try {
374
+ choice = grants.allowsFolder(ctx.apiUrl, target, r.kind) ? 'once' : await this.#ask(folderApproval(r, c, target, folder, home, remote));
375
+ }
376
+ catch (e) {
377
+ if (ctx.signal.aborted)
378
+ return;
379
+ await deny(e instanceof Error ? e.message : String(e));
380
+ return;
381
+ }
382
+ if (ctx.signal.aborted)
383
+ return;
384
+ if (choice !== 'once' && choice !== 'always') {
385
+ await this.result(r, { state: 'denied' });
386
+ return;
387
+ }
388
+ if (choice === 'always')
389
+ await grants.saveFolder(ctx.apiUrl, folder, r.kind);
390
+ // Re-read after the approval, before claiming or touching data: a symlink swapped meanwhile is a different path.
391
+ let now;
392
+ try {
393
+ now = resolveTarget();
394
+ }
395
+ catch {
396
+ now = '';
397
+ }
398
+ if (now !== target) {
399
+ await deny('Device path changed during approval.');
400
+ return;
401
+ }
402
+ const current = await getConversation(ctx, r.conversation_id).catch(() => undefined);
403
+ if (!current || current.project_id !== c.project_id || archived(current)) {
404
+ await deny('The conversation changed or ended during device approval.');
405
+ return;
406
+ }
407
+ await this.result(r, { state: 'approved' });
408
+ await this.result(r, { state: 'running' });
409
+ let ws;
410
+ try {
411
+ let result;
412
+ if (r.kind === 'dir_list')
413
+ result = { state: 'done', dir: await listDeviceDir(target, r.dir ?? {}, ctx.signal) };
414
+ else if (r.kind === 'project_link')
415
+ result = { state: 'done', project: await linkLocalProject(ctx, target, { ...(r.link?.brief ? { brief: r.link.brief } : {}), harness: r.link?.harness ?? current.harness, projectId: current.project_id }) };
416
+ else {
417
+ const f = r.files;
418
+ if (!f.remote_path.startsWith('/') || /[\0\r\n]/.test(f.remote_path))
419
+ throw new UsageError('Device transfer requires an absolute VM path.');
420
+ const max = f.max_bytes ?? LIMIT;
421
+ if (!Number.isSafeInteger(max) || max < 1 || max > LIMIT)
422
+ throw new UsageError('Device transfer maximum is 256 MiB.');
423
+ ws = await conversationWorkspace({ ...ctx, signal: AbortSignal.any([ctx.signal, AbortSignal.timeout(30_000)]) }, current);
424
+ const cell = ws.cell();
425
+ if (r.kind === 'files_pull') {
426
+ const info = await stat(target);
427
+ if (!info.isFile() || info.size > max)
428
+ throw new UsageError('Local transfer file exceeds the requested size or is not a file.');
429
+ const bytes = await readFile(target);
430
+ if (bytes.length > max)
431
+ throw new UsageError('Local transfer file grew during reading.');
432
+ await writeSyncBlob(cell, f.remote_path, bytes);
433
+ }
434
+ else {
435
+ const info = await cell.files.stat(f.remote_path);
436
+ if (info.type !== 'file' || info.size > max)
437
+ throw new UsageError('VM transfer file exceeds the requested size or is not a file.');
438
+ const bytes = await cell.files.read(f.remote_path, { length: max + 1 });
439
+ if (bytes.length > max || bytes.length !== info.size)
440
+ throw new UsageError('VM transfer file changed during reading.');
441
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
442
+ if (await realpath(dirname(target)) !== folder)
443
+ throw new Error('Transfer folder changed during the transfer.');
444
+ await writeFile(target, bytes, { mode: 0o600, flag: 'wx' }); // An existing file needs a fresh explicit request, never a silent clobber.
445
+ }
446
+ result = { state: 'done', files_artifact: r.kind === 'files_pull' ? f.remote_path : target };
447
+ }
448
+ await this.result(r, result);
449
+ }
450
+ catch (e) {
451
+ await this.result(r, { state: 'failed', stderr_b64: Buffer.from(e instanceof Error ? e.message : String(e)).toString('base64') });
452
+ }
453
+ finally {
454
+ ws?.cell().close();
455
+ }
456
+ }
289
457
  async reap() {
290
458
  for (const [id, handle] of this.#handles) {
291
459
  const c = await getConversation(this.#ctx, handle.conversation);
@@ -435,3 +603,46 @@ export async function askConnectedAcp(dir, approval, signal) {
435
603
  } });
436
604
  });
437
605
  }
606
+ /**
607
+ * The desktop app's approval hop (0.25.0+): while it runs it serves `<config dir>/device-approver.sock` (or, when that
608
+ * path is too long for a socket, `/tmp/sf-device-<uid>/<hash>-approver.sock`) and shows each approval as a Needs-you
609
+ * item. One line in (the DeviceApproval JSON), one line out (`{"choice":"once|always|deny"}`). No app, a socket that is
610
+ * not this user's own, or one that closes without an answer: null, and the next surface asks.
611
+ */
612
+ export function approverSocket(dir) {
613
+ const path = join(dir, 'device-approver.sock');
614
+ if (Buffer.byteLength(path) < 100)
615
+ return path;
616
+ return join('/tmp', `sf-device-${process.getuid?.() ?? 'user'}`, `${createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 20)}-approver.sock`);
617
+ }
618
+ export async function askDeviceApprover(dir, approval, signal) {
619
+ const path = approverSocket(dir), uid = process.getuid?.();
620
+ try {
621
+ const socket = lstatSync(path), parent = lstatSync(dirname(path));
622
+ if (!socket.isSocket() || parent.isSymbolicLink() || !parent.isDirectory() || (parent.mode & 0o077) || (uid !== undefined && (socket.uid !== uid || parent.uid !== uid)))
623
+ return null;
624
+ }
625
+ catch {
626
+ return null;
627
+ }
628
+ return new Promise((resolveChoice, reject) => {
629
+ const socket = createConnection(path);
630
+ let text = '';
631
+ const abort = () => { socket.destroy(); reject(signal.reason instanceof Error ? signal.reason : new Error('Device approval interrupted')); };
632
+ signal.addEventListener('abort', abort, { once: true });
633
+ socket.on('connect', () => socket.write(JSON.stringify(approval) + '\n'));
634
+ socket.on('data', bytes => { text += String(bytes); if (text.length > 1024)
635
+ socket.destroy(); });
636
+ socket.once('error', () => { signal.removeEventListener('abort', abort); resolveChoice(null); });
637
+ socket.once('close', () => {
638
+ signal.removeEventListener('abort', abort);
639
+ try {
640
+ const choice = JSON.parse(text.split('\n')[0]).choice;
641
+ resolveChoice(['once', 'always', 'deny'].includes(choice) ? choice : null);
642
+ }
643
+ catch {
644
+ resolveChoice(null);
645
+ }
646
+ });
647
+ });
648
+ }
@@ -0,0 +1,58 @@
1
+ import type { Ctx, Handler } from './commands.js';
2
+ export declare const FOLDER_KINDS: Set<string>;
3
+ /** An absolute or `~/…` path: one the device resolves on its own, outside any conversation's folder. */
4
+ export declare function isDevicePath(path: string): boolean;
5
+ export declare function deviceHome(ctx: Pick<Ctx, 'io'>): string;
6
+ export declare function expandDevicePath(path: string, home: string): string;
7
+ /** Through every symlink of the part that exists; a path that does not exist yet keeps its missing tail. */
8
+ export declare function canonicalDevicePath(target: string): string;
9
+ /** `/Users/me/code` as `~/code`: how a person reads their own folders. */
10
+ export declare function displayPath(path: string, home: string): string;
11
+ export interface DirListing {
12
+ path: string;
13
+ entries: {
14
+ path: string;
15
+ kind: 'file' | 'dir' | 'symlink' | 'other';
16
+ size?: number;
17
+ git_remote?: string;
18
+ }[];
19
+ truncated: boolean;
20
+ git_remote?: string;
21
+ }
22
+ /** Names, kinds, sizes and Git remotes under `root`, breadth first, bounded in depth, count and bytes. */
23
+ export declare function listDeviceDir(root: string, options?: {
24
+ depth?: number;
25
+ max_entries?: number;
26
+ include_hidden?: boolean;
27
+ }, signal?: AbortSignal): Promise<DirListing>;
28
+ export interface LinkedProject {
29
+ project_id: string;
30
+ repo_remote: string;
31
+ folder: string;
32
+ name: string;
33
+ conversation_id?: string;
34
+ }
35
+ type Starter = (ctx: Ctx, folder: string, harness: 'claude' | 'codex', brief: string) => Promise<{
36
+ id: string;
37
+ project_id: string;
38
+ }>;
39
+ /**
40
+ * A new chat in `folder`, started the way `shard agents new --path` starts one, in its own process: the folder's
41
+ * working tree (uncommitted files too, .gitignore respected) seeds it while its VM boots, and the brief is its first
42
+ * prompt. The brief goes in on standard input, never in a command line another user can read.
43
+ */
44
+ export declare const startFromFolder: Starter;
45
+ /**
46
+ * `folder` becomes one of your projects: the repository its Git origin names (else the folder itself), in your
47
+ * project on this API. It is selected for background preparation, so its files are in the cloud before the next chat,
48
+ * and kept as this device's live copy: what conversations started from it finish lands back in it. With `brief`, a
49
+ * conversation starts there at once. A folder without Git gets a repository, as a new chat from it does.
50
+ */
51
+ export declare function linkLocalProject(ctx: Ctx, path: string, options?: {
52
+ brief?: string;
53
+ harness?: 'claude' | 'codex';
54
+ projectId?: string;
55
+ }, start?: Starter): Promise<LinkedProject>;
56
+ /** `shard projects link <path>` (0.25.0+): what the desktop app's "Add a folder…" runs, the same as a device's project_link. */
57
+ export declare const PROJECT_LINK_HANDLERS: Record<string, Handler>;
58
+ export {};
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Folders on this device that a conversation's agent asks about (0.25.0+): `dir_list` lists one (names, kinds, sizes
3
+ * and Git remotes; never file contents), `project_link` makes one a project of yours, seeded from here, and
4
+ * `files_pull`/`files_push` with an absolute or `~/` local path move a file to or from anywhere on this device.
5
+ *
6
+ * Nothing runs before this device approves it. The choices are Allow once and Always for this folder. Always covers
7
+ * that kind of request for that folder and everything inside it, resolved through symlinks, from any of your
8
+ * conversations on this API: a path outside every such folder asks again, and `exec` never uses a folder grant.
9
+ */
10
+ import { execFile, spawn } from 'node:child_process';
11
+ import { existsSync, realpathSync } from 'node:fs';
12
+ import { lstat, readdir } from 'node:fs/promises';
13
+ import { homedir } from 'node:os';
14
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
15
+ import { out, str } from "./commands.js";
16
+ import { readInitialPrompt } from "./initial-prompt.js";
17
+ import { UsageError } from "./errors.js";
18
+ import { ensureProjectGit } from "./project-git.js";
19
+ import { normalizeRemote, projectIdentity } from "./project-identity.js";
20
+ import { selectProject } from "./project-preparation.js";
21
+ export const FOLDER_KINDS = new Set(['dir_list', 'project_link']);
22
+ /** An absolute or `~/…` path: one the device resolves on its own, outside any conversation's folder. */
23
+ export function isDevicePath(path) { return isAbsolute(path) || path === '~' || path.startsWith('~/'); }
24
+ export function deviceHome(ctx) { return ctx.io.env.HOME || homedir(); }
25
+ export function expandDevicePath(path, home) {
26
+ if (/[\0\r\n]/.test(path))
27
+ throw new UsageError('Device path contains invalid characters.');
28
+ if (path === '~')
29
+ return resolve(home);
30
+ if (path.startsWith('~/'))
31
+ return resolve(home, path.slice(2));
32
+ if (!isAbsolute(path))
33
+ throw new UsageError('Device path must be absolute or start with ~/.');
34
+ return resolve(path);
35
+ }
36
+ /** Through every symlink of the part that exists; a path that does not exist yet keeps its missing tail. */
37
+ export function canonicalDevicePath(target) {
38
+ let ancestor = target;
39
+ while (!existsSync(ancestor)) {
40
+ const parent = dirname(ancestor);
41
+ if (parent === ancestor)
42
+ throw new UsageError('Device path is unavailable.');
43
+ ancestor = parent;
44
+ }
45
+ return resolve(realpathSync(ancestor), relative(ancestor, target));
46
+ }
47
+ /** `/Users/me/code` as `~/code`: how a person reads their own folders. */
48
+ export function displayPath(path, home) {
49
+ const h = resolve(home);
50
+ return path === h ? '~' : path.startsWith(h + sep) ? `~/${path.slice(h.length + 1)}` : path;
51
+ }
52
+ /** Never descended into: their contents are noise to a person choosing a folder, and unbounded. */
53
+ const OPAQUE = new Set(['.git', 'node_modules', '.shard']);
54
+ /** The listing must fit the result body (1 MiB); paths and remotes over the API's 1024 characters are left out. */
55
+ const LISTING_BYTES = 900 << 10, NAME_MAX = 1024, GIT_LOOKUPS = 200;
56
+ function gitRemote(dir, signal) {
57
+ return new Promise(resolveRemote => {
58
+ execFile('git', ['-C', dir, 'remote', 'get-url', 'origin'], { timeout: 5000, signal, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' } }, (error, stdout) => {
59
+ if (error || !stdout.trim()) {
60
+ resolveRemote(undefined);
61
+ return;
62
+ }
63
+ // normalizeRemote drops the transport's user and password: a listing never carries credentials.
64
+ try {
65
+ const remote = normalizeRemote(stdout.trim(), dir);
66
+ resolveRemote(remote.length <= NAME_MAX ? remote : undefined);
67
+ }
68
+ catch {
69
+ resolveRemote(undefined);
70
+ }
71
+ });
72
+ });
73
+ }
74
+ /** Names, kinds, sizes and Git remotes under `root`, breadth first, bounded in depth, count and bytes. */
75
+ export async function listDeviceDir(root, options = {}, signal) {
76
+ const depth = Math.min(Math.max(options.depth ?? 1, 1), 3), max = Math.min(Math.max(options.max_entries ?? 500, 1), 2000);
77
+ if (!(await lstat(root)).isDirectory())
78
+ throw new UsageError('That path is not a folder.');
79
+ const entries = [], repos = [];
80
+ let truncated = false, bytes = 0, queue = [{ dir: root, rel: '', level: 1 }];
81
+ while (queue.length && !truncated) {
82
+ const next = [];
83
+ for (const { dir, rel, level } of queue) {
84
+ signal?.throwIfAborted();
85
+ let names;
86
+ try {
87
+ names = (await readdir(dir)).sort((a, b) => a.localeCompare(b));
88
+ }
89
+ catch {
90
+ continue;
91
+ } // an unreadable folder lists as itself
92
+ for (const name of names) {
93
+ if (!options.include_hidden && name.startsWith('.'))
94
+ continue;
95
+ const path = rel ? `${rel}/${name}` : name, full = join(dir, name);
96
+ if (path.length > NAME_MAX) {
97
+ truncated = true;
98
+ continue;
99
+ }
100
+ let info;
101
+ try {
102
+ info = await lstat(full);
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ const kind = info.isSymbolicLink() ? 'symlink' : info.isDirectory() ? 'dir' : info.isFile() ? 'file' : 'other';
108
+ const entry = { path, kind, ...(kind === 'file' ? { size: info.size } : {}) };
109
+ bytes += JSON.stringify(entry).length + 80;
110
+ if (entries.length >= max || bytes > LISTING_BYTES) {
111
+ truncated = true;
112
+ break;
113
+ }
114
+ entries.push(entry);
115
+ if (kind !== 'dir')
116
+ continue;
117
+ if (existsSync(join(full, '.git')) && repos.length < GIT_LOOKUPS)
118
+ repos.push({ index: entries.length - 1, dir: full });
119
+ if (level < depth && !OPAQUE.has(name))
120
+ next.push({ dir: full, rel: path, level: level + 1 });
121
+ }
122
+ if (truncated)
123
+ break;
124
+ }
125
+ queue = next;
126
+ }
127
+ // Git remotes are read eight at a time, each bounded: a folder of a hundred repositories lists in about a second.
128
+ const own = existsSync(join(root, '.git')) ? gitRemote(root, signal) : Promise.resolve(undefined);
129
+ for (let i = 0; i < repos.length; i += 8) {
130
+ await Promise.all(repos.slice(i, i + 8).map(async (repo) => { const remote = await gitRemote(repo.dir, signal); if (remote)
131
+ entries[repo.index].git_remote = remote; }));
132
+ }
133
+ const remote = await own;
134
+ return { path: root, entries, truncated, ...(remote ? { git_remote: remote } : {}) };
135
+ }
136
+ /**
137
+ * A new chat in `folder`, started the way `shard agents new --path` starts one, in its own process: the folder's
138
+ * working tree (uncommitted files too, .gitignore respected) seeds it while its VM boots, and the brief is its first
139
+ * prompt. The brief goes in on standard input, never in a command line another user can read.
140
+ */
141
+ export const startFromFolder = (ctx, folder, harness, brief) => {
142
+ const node = ctx.runtime?.node ?? process.execPath, script = ctx.runtime?.script ?? process.argv[1];
143
+ if (!script)
144
+ return Promise.reject(new Error('The Shardflux CLI could not find itself to start the conversation.'));
145
+ return new Promise((resolveStart, reject) => {
146
+ const child = spawn(node, [script, 'agents', 'new', '--app', harness, '--path', folder, '--prompt-file', '-', '--json'], {
147
+ cwd: folder, stdio: ['pipe', 'pipe', 'pipe'],
148
+ env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, CI: '1' },
149
+ });
150
+ const out = [], err = [];
151
+ const stop = () => child.kill('SIGTERM');
152
+ const timer = setTimeout(stop, 300_000);
153
+ ctx.signal.addEventListener('abort', stop, { once: true });
154
+ child.stdout.on('data', (b) => { if (out.length < 256)
155
+ out.push(b); });
156
+ child.stderr.on('data', (b) => { if (err.length < 64)
157
+ err.push(b); });
158
+ child.once('error', error => { clearTimeout(timer); reject(error); });
159
+ child.once('close', code => {
160
+ clearTimeout(timer);
161
+ ctx.signal.removeEventListener('abort', stop);
162
+ const text = Buffer.concat(out).toString('utf8');
163
+ try {
164
+ if (code !== 0)
165
+ throw new Error(lastLine(Buffer.concat(err).toString('utf8')) || `The conversation did not start (exit ${code}).`);
166
+ const started = JSON.parse(text.trim().split('\n').at(-1));
167
+ const id = started.conversation?.id ?? started.workspace?.labels?.shard_conversation;
168
+ if (!id)
169
+ throw new Error('The conversation did not start.');
170
+ resolveStart({ id, project_id: started.conversation?.project_id ?? started.workspace?.project_id ?? '' });
171
+ }
172
+ catch (error) {
173
+ reject(error instanceof Error ? error : new Error(String(error)));
174
+ }
175
+ });
176
+ child.stdin.end(brief);
177
+ });
178
+ };
179
+ function lastLine(stderr) {
180
+ const text = stderr.trim();
181
+ try {
182
+ const doc = JSON.parse(text);
183
+ if (doc.error?.message)
184
+ return doc.error.message;
185
+ }
186
+ catch { /* plain text */ }
187
+ return text.split('\n').at(-1)?.slice(0, 2000) ?? '';
188
+ }
189
+ /**
190
+ * `folder` becomes one of your projects: the repository its Git origin names (else the folder itself), in your
191
+ * project on this API. It is selected for background preparation, so its files are in the cloud before the next chat,
192
+ * and kept as this device's live copy: what conversations started from it finish lands back in it. With `brief`, a
193
+ * conversation starts there at once. A folder without Git gets a repository, as a new chat from it does.
194
+ */
195
+ export async function linkLocalProject(ctx, path, options = {}, start = startFromFolder) {
196
+ const { folder } = projectIdentity(path);
197
+ await ensureProjectGit(ctx, folder);
198
+ const identity = projectIdentity(folder);
199
+ selectProject(ctx, folder);
200
+ const profile = ctx.store.profile(ctx.apiUrl);
201
+ let projectId = profile.project_id ?? profile.api_key?.project_id ?? options.projectId, conversationId;
202
+ if (options.brief) {
203
+ const started = await start(ctx, folder, options.harness ?? 'claude', options.brief);
204
+ conversationId = started.id;
205
+ projectId = started.project_id || projectId;
206
+ }
207
+ // An API key from the environment (the desktop app's sign-in) names its project only to the API.
208
+ if (!projectId && ctx.io.env.SHARDFLUX_API_KEY?.trim())
209
+ projectId = (await ctx.cloud().me()).api_key?.project_id;
210
+ if (!projectId)
211
+ throw new UsageError('Choose your default project first: shard projects use <id|slug>.');
212
+ const name = [...(identity.remote ? identity.slug : basename(folder))].slice(0, 200).join('') || identity.slug;
213
+ return { project_id: projectId, repo_remote: identity.remote ?? `file://${folder}`, folder, name, ...(conversationId ? { conversation_id: conversationId } : {}) };
214
+ }
215
+ /** `shard projects link <path>` (0.25.0+): what the desktop app's "Add a folder…" runs, the same as a device's project_link. */
216
+ export const PROJECT_LINK_HANDLERS = {
217
+ async 'projects link'(ctx, values, [path]) {
218
+ if (!path)
219
+ throw new UsageError('Choose a local folder: shard projects link <path>.');
220
+ const app = str(values.app);
221
+ if (app !== undefined && app !== 'claude' && app !== 'codex')
222
+ throw new UsageError('--app must be claude or codex.');
223
+ const brief = await readInitialPrompt(ctx, values);
224
+ const project = await linkLocalProject(ctx, expandDevicePath(isDevicePath(path) ? path : resolve(path), deviceHome(ctx)), { ...(brief ? { brief } : {}), ...(app ? { harness: app } : {}) });
225
+ out(ctx, `Linked ${displayPath(project.folder, deviceHome(ctx))} as ${project.name} (${project.repo_remote}); it is prepared in the background.\n${project.conversation_id ? `Started conversation ${project.conversation_id}.\n` : ''}`, { project });
226
+ return 0;
227
+ },
228
+ };
@@ -3,3 +3,9 @@ import type { Ctx } from './commands.js';
3
3
  export declare function runDeviceHelper(ctx: Ctx, options?: {
4
4
  service?: boolean;
5
5
  }): Promise<number>;
6
+ /**
7
+ * The projects this device answers for besides its local conversations' (0.25.0+): the signed-in project (the API
8
+ * key's, or the default one), and with a person's own sign-in and no key, every project they can reach (at most 100).
9
+ * A home conversation, an Orchestrator master or a chat the desktop app started asks from these.
10
+ */
11
+ export declare function deviceProjects(ctx: Ctx): Promise<string[]>;