@shardflux/cli 0.28.1 → 0.28.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - The Shardflux app 0.9.3 (0.28.2+): `shard` installs and updates `@shardflux/desktop-darwin-arm64` / `-x64` and
6
+ `@shardflux/desktop-linux-x64` / `-arm64` 0.9.3 (files open beside your work, pictures and videos picture in picture,
7
+ ⌘2 shows the agents your orchestrator starts, needs-you questions read whole).
8
+
9
+ - Agent credentials (0.28.2+, SDK 0.30.2): environment credential questions can be answered with `shard needs-you answer acred_ID --credential --ask` (or stdin / `--from-file`), with scope chosen before the write. Scope flags preserve organization and thread identity; workspace-only permission flags are rejected for agent credentials.
10
+
11
+ - Agent credential scopes (0.28.2+): `shard secrets create NAME --scope agents --org ORG --ask` adds a credential for all your agents; use `--scope project --project PROJECT` or `--scope conversation --conversation CONVERSATION` for a local override. Import, list, get, update, rotate, versions, access-log and delete accept the same scope. Values use the secure page, stdin, or `--from-file` and never command arguments.
12
+ - Conversation origin (0.28.2+, SDK 0.30.2): conversation starts stay compatible with older APIs: send launching-conversation provenance only after `/v1/meta` advertises `capabilities.conversation_origin: true`; no create fallback retry.
13
+
14
+ - Question explanations (0.28.2+, SDK 0.30.2): needs-you output prints each question and its optional `option_details` in full, including what each choice does and what follows it.
15
+
16
+ - Launch attribution (0.28.2+, SDK 0.30.2): preserve the requesting orchestrator thread when `project_link` starts work in a laptop folder. API and guest title sources strip pasted-content/image transport markers, so `shard agents list` shows the request text. Tasks delete their workers and spawned descendants along with their computers.
17
+
5
18
  - The Shardflux app 0.9.2 (0.28.1+): `shard` installs and updates `@shardflux/desktop-darwin-arm64` / `-x64` and
6
19
  `@shardflux/desktop-linux-x64` / `-arm64` 0.9.2 (Stop in ⌘1, organization choice at the first sign-in, Sign in after
7
20
  signing out).
package/README.md CHANGED
@@ -34,6 +34,12 @@ an explicit selection keeps the default credentials and target status. An approv
34
34
 
35
35
  Filesystem recovery timing (0.28.0+): cold resume output identifies `guest_filesystem` in JSON and the restart notice. See the [workspace lifecycle](https://docs.shardflux.dev/concepts/lifecycle) for retained files and process restart semantics.
36
36
 
37
+ ## Agent credentials (0.28.2+)
38
+
39
+ Answer a credential question with `shard needs-you answer acred_ID --credential --ask --scope project`. The value goes to the write-only answer field and the asking agent receives an availability message.
40
+
41
+ `shard secrets create NAME --scope agents --org ORG --ask` adds a credential for all your agents; use `--scope project --project PROJECT` or `--scope conversation --conversation CONVERSATION` for a local override. Import, list, get, update, rotate, versions, access-log and delete accept the same scope. Values use the secure page, stdin, or `--from-file` and never command arguments.
42
+
37
43
  ## Support and bug reports
38
44
 
39
45
  [Report a bug](https://github.com/shardfluxdev/community/issues/new?template=bug_report.yml) or
@@ -1635,6 +1641,8 @@ hour), and `--json` prints the API's answer. Pass an id to `shard agents new --m
1635
1641
 
1636
1642
  ## Conversations
1637
1643
 
1644
+ When an orchestrator starts work through a device project link (0.28.2+), the new conversation carries its requesting thread through to the API. `shard agents list --json` retains the orchestrator conversation and known input IDs.
1645
+
1638
1646
  From 0.14.0, `shard conversations list|get|send|wait|land|move` manages project conversations. `shard c` is the short form. Conversations retain their branch and transcript as their VM binding changes.
1639
1647
 
1640
1648
  Watch an agent work: `shard conversations watch <id>` shows the conversation's terminal live, read-only. It starts
@@ -1907,7 +1915,7 @@ shard board --follow # each change as it happens,
1907
1915
  changes the words (`--text`, and the task's master hears it), the title (`--title`), the bucket (`--now`, `--next`,
1908
1916
  `--later`) and the order (`--first`, `--after`), keeps an agent's suggestion as your own (`--keep`) or clears the task
1909
1917
  (`--clear`; `--no-clear` brings it back). `shard task steer <id> <words...>` sends steering words to the task's master,
1910
- `shard task rm <id>` deletes the task, and `shard task clear-done` clears every finished one. `shard task show <id>`
1918
+ `shard task rm <id>` deletes the task, every nested follow-up, and their owned conversation trees and computers; masters shared by other tasks stay. `shard task clear-done` clears every finished one. `shard task show <id>`
1911
1919
  prints the task, its plan and each report: milestone, outcome and evidence (`tests 12/12 (pnpm test)`).
1912
1920
  `shard task land <id>` lands a finished task: its result (the branch `shard/<task id>`, where its master merged its
1913
1921
  workers' branches) goes into the project's default branch and into your folder on this computer the way finished
@@ -2086,3 +2094,9 @@ shard volumes detach --machine datasets
2086
2094
  Defaults mount in new and running conversations. Removal waits for current work to finish and preserves manual attachments. Add `--json` to read propagation results.
2087
2095
 
2088
2096
  Bare `shard` in a coding-agent shell or without a TTY returns its setup guide using locally saved sign-in and API-key state.
2097
+
2098
+ ### Question explanations (0.28.2+)
2099
+
2100
+ Needs-you output prints each question and its optional `option_details` in full, including what each choice does and what follows it. The first suggested answer is the agent’s recommendation. Secret values go through the project’s secure setup, never a question or its ordinary answer.
2101
+
2102
+ Conversation creation (0.28.2+) includes the optional launching conversation only when `/v1/meta` advertises `capabilities.conversation_origin: true`. Older APIs receive the existing create body; no rejected create is replayed with different fields.
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.28.1";
1
+ export declare const CLI_VERSION = "0.28.2";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -15,7 +15,7 @@ import { BOARD_COMMANDS } from "./board-args.js";
15
15
  import { ORCHESTRATOR_COMMANDS } from "./orchestrator-args.js";
16
16
  import { UsageError } from "./errors.js";
17
17
  import { DEFAULT_CHOICE, MODEL_CHOICES } from "./agent-models.js";
18
- export const CLI_VERSION = '0.28.1';
18
+ export const CLI_VERSION = '0.28.2';
19
19
  export const GLOBAL_OPTIONS = {
20
20
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
21
21
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -127,6 +127,9 @@ const LIFETIME_FILTER = {
127
127
  };
128
128
  const SECRET_REF = { name: 'id|name', required: true };
129
129
  const SECRET_SCOPE = {
130
+ scope: { type: 'string', value: '<agents|project|conversation>', description: 'Agent credential scope: all your agents, this project, or one thread. Omit for workspace-bound secrets.' },
131
+ project: { type: 'string', value: '<project-id>', description: 'Project for a local agent credential (default: the key’s project).' },
132
+ conversation: { type: 'string', value: '<conversation-id>', description: 'Thread for --scope conversation.' },
130
133
  org: { type: 'string', value: '<organization-id>', description: 'Organization-wide secrets of this organization (owners/admins; project API keys get 403). Default: the API key’s project.' },
131
134
  };
132
135
  const SECRET_VALUE = {
@@ -993,6 +996,7 @@ const KEY_COMMANDS = [
993
996
  summary: 'Import named items from your vault as encrypted project secrets',
994
997
  positionals: [{ name: 'name', required: true, variadic: true }],
995
998
  options: {
999
+ ...SECRET_SCOPE,
996
1000
  from: { type: 'string', value: '<hsec|1password|bitwarden|keychain>', description: 'Vault to read; values go straight into the encrypted secrets request.' },
997
1001
  as: { type: 'string', value: '<secret-name>', description: 'Secret name for a single imported item.' },
998
1002
  keychain: { type: 'string', value: '<path>', description: 'Read from this Keychain (keychain source only).' },
@@ -123,6 +123,12 @@ export const BOARD_COMMANDS = [
123
123
  summary: 'Answer something that waits on you (0.25.0+)',
124
124
  positionals: [{ name: 'id', required: true }, { name: 'decision' }],
125
125
  options: {
126
+ credential: { type: 'boolean', description: 'Provide an environment credential through the secure value input; never through --text.' },
127
+ scope: { type: 'string', value: '<agents|project|conversation>', description: 'Credential storage scope (default: what the request suggests).' },
128
+ org: { type: 'string', value: '<organization-id>', description: 'The organization for a signed-in credential answer.' },
129
+ ask: { type: 'boolean', description: 'Open the secure page for the credential value.' },
130
+ 'from-file': { type: 'string', value: '<file>', description: 'Read the credential value from this file; never from an argument.' },
131
+ timeout: { type: 'string', value: '<duration>', description: 'How long the secure page waits (default 10m).' },
126
132
  everywhere: { type: 'boolean', description: 'With always: the rule covers every project.' },
127
133
  text: { type: 'string', value: '<answer>', description: 'Your answer to an agent’s question.' },
128
134
  pick: { type: 'string', multiple: true, value: '<option|question=option>', description: 'The option you pick for an agent’s question in a permission request (repeatable for a multi-select question; "<question>=<option>" when it asks several).' },
@@ -136,6 +142,7 @@ export const BOARD_COMMANDS = [
136
142
  ' ask_… --text <answer>: the task’s master gets it',
137
143
  ' oq_… --text <answer>: the orchestrator thread that asked gets it (0.27.0+)',
138
144
  ' cred_… deny (the value goes through the credential request, sealed for its computer)',
145
+ ' acred_… --credential --ask: provide an environment value securely, or deny to decline it',
139
146
  ' land_… land (as shard task land: the result into your folder and the default branch), keep (the',
140
147
  ' branch stays) or discard',
141
148
  ' prune_… --keep <task-id> for each task to keep, keep for all of them, or discard to clear them',
@@ -44,7 +44,7 @@ export declare function feedJson(u: BoardUpdate): Record<string, unknown>;
44
44
  /** `shard task show`: the task, its plan and conversations, then every report oldest first with its evidence. Every
45
45
  * title, step and outcome prints whole (0.27.0+), however long the agent wrote it; the terminal wraps it. */
46
46
  export declare function taskDetailText(d: TaskDetail): string;
47
- /** An item's kind from its id (perm_, cq_, ask_, oq_, cred_, land_, prune_, dev_); undefined for another id. */
47
+ /** An item's kind from its id (perm_, cq_, ask_, oq_, cred_, acred_, land_, prune_, dev_); undefined for another id. */
48
48
  export declare function itemKind(id: string): NeedsYouItem['kind'] | undefined;
49
49
  export interface AnswerInput {
50
50
  word?: string | undefined;
@@ -1,4 +1,5 @@
1
- import { out, principal, str, strs } from "./commands.js";
1
+ import { findOrg, resolveOrg } from "./account-scope.js";
2
+ import { out, principal, str, strs, readSecretValue, askSecretValue } from "./commands.js";
2
3
  import { detailLine, pickAnswers } from "./conversation-commands.js";
3
4
  import { NotFoundError, UsageError } from "./errors.js";
4
5
  import { time } from "./format.js";
@@ -36,8 +37,8 @@ export function taskLine(t) {
36
37
  * request your orchestrator made to your computer while it was offline reads "Waiting for your computer: list ~/code".
37
38
  */
38
39
  export function needsLine(n) {
39
- const detail = n.permission ? detailLine(n.permission.detail) : n.options?.length ? n.options.join(' / ') : n.land ? n.land.branch : n.device ? 'cancel drops it' : '';
40
- return `${n.id}\t${oneLine(n.ask, 100)}${detail ? `\t${detail}` : ''}`;
40
+ const detail = n.credential?.kind === 'environment' ? [n.credential.name, n.credential.suggested_scope].filter(Boolean).join(' · ') : n.permission ? detailLine(n.permission.detail) : n.options?.length ? n.options.map(o => n.option_details?.[o] ? `${o}: ${n.option_details[o]}` : o).join(' / ') : n.land ? n.land.branch : n.device ? 'cancel drops it' : '';
41
+ return `${n.id}\t${oneLine(n.ask, n.kind === 'question' || n.credential?.kind === 'environment' ? Infinity : 100)}${detail ? `\t${detail}` : ''}`;
41
42
  }
42
43
  /** A sealed report as --follow prints it: id, milestone, title, then its evidence labels. */
43
44
  export function reportLine(r) {
@@ -140,7 +141,7 @@ export function taskDetailText(d) {
140
141
  if (r.outcome.trim())
141
142
  lines.push(` ${whole(r.outcome)}`);
142
143
  if (r.options?.length)
143
- lines.push(` Options: ${r.options.join(' / ')}`);
144
+ lines.push(` Options: ${r.options.map(o => r.option_details?.[o] ? `${o}: ${r.option_details[o]}` : o).join(' / ')}`);
144
145
  if (r.evidence.length)
145
146
  lines.push(` Evidence: ${r.evidence.map(evidenceText).join('; ')}`);
146
147
  if (r.media.length)
@@ -166,8 +167,8 @@ function orderOf(values) {
166
167
  return first ? null : after;
167
168
  }
168
169
  // ---- needs you -------------------------------------------------------------------------------
169
- const KINDS = { perm: 'permission', cq: 'question', ask: 'question', oq: 'question', cred: 'credential', land: 'land', prune: 'prune', dev: 'device' };
170
- /** An item's kind from its id (perm_, cq_, ask_, oq_, cred_, land_, prune_, dev_); undefined for another id. */
170
+ const KINDS = { perm: 'permission', cq: 'question', ask: 'question', oq: 'question', cred: 'credential', acred: 'credential', land: 'land', prune: 'prune', dev: 'device' };
171
+ /** An item's kind from its id (perm_, cq_, ask_, oq_, cred_, acred_, land_, prune_, dev_); undefined for another id. */
171
172
  export function itemKind(id) {
172
173
  return KINDS[id.slice(0, Math.max(0, id.indexOf('_')))];
173
174
  }
@@ -364,6 +365,45 @@ export const BOARD_HANDLERS = {
364
365
  return 0;
365
366
  },
366
367
  async 'needs-you answer'(ctx, values, [id, word]) {
368
+ if (values.credential === true) {
369
+ if (!id?.startsWith('acred_') || word !== undefined || values.text !== undefined || values.pick !== undefined || values.keep !== undefined || values.everywhere === true) {
370
+ throw new UsageError('Use --credential for an environment request, without a decision or chat answer.');
371
+ }
372
+ const hasKey = Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || ctx.store.profile(ctx.apiUrl).api_key !== undefined;
373
+ const scope = str(values.scope);
374
+ if (scope !== undefined && !['agents', 'project', 'conversation'].includes(scope))
375
+ throw new UsageError('--scope must be agents, project or conversation');
376
+ const signed = scope === 'agents' || !hasKey;
377
+ let cloud = signed ? ctx.account() : ctx.cloud();
378
+ let organizationId = str(values.org);
379
+ if (signed) {
380
+ const org = organizationId ? await findOrg(ctx.account(), organizationId) : await resolveOrg(ctx, ctx.account(), {});
381
+ organizationId = org.id;
382
+ }
383
+ // Inspect the request before reading a file, stdin or opening the secure page.
384
+ const item = (await cloud.needsYou.list({ ...(organizationId ? { organizationId } : {}), signal: ctx.signal })).find(n => n.id === id);
385
+ if (!item || item.kind !== 'credential' || item.credential?.kind !== 'environment' || !item.credential.name)
386
+ throw new NotFoundError('Nothing waits for that environment credential.');
387
+ const target = scope ?? item.credential.suggested_scope ?? 'project';
388
+ if (target === 'agents' && !signed) {
389
+ organizationId ??= (await principal(ctx)).organization_id;
390
+ cloud = ctx.account();
391
+ await findOrg(ctx.account(), organizationId);
392
+ }
393
+ const save = (value) => {
394
+ ctx.addSecret(value);
395
+ return cloud.needsYou.answer(id, { credential: { value, scope: target } }, { ...(organizationId ? { organizationId } : {}), signal: ctx.signal });
396
+ };
397
+ const value = await readSecretValue(ctx, values, item.credential.name);
398
+ const r = value !== null ? await save(value) : await askSecretValue(ctx, values, {
399
+ title: `Provide ${item.credential.name}`, purpose: item.credential.purpose ?? item.ask, name: item.credential.name,
400
+ }, save);
401
+ out(ctx, `Credential ${item.credential.name} is available (${target}).\n`, r);
402
+ return 0;
403
+ }
404
+ if (values.scope !== undefined || values.ask === true || values['from-file'] !== undefined || values.org !== undefined || values.timeout !== undefined) {
405
+ throw new UsageError('Use --credential for a secure environment credential answer.');
406
+ }
367
407
  const input = { word, text: str(values.text), picks: strs(values.pick), keep: strs(values.keep), everywhere: values.everywhere === true };
368
408
  const cloud = boardClient(ctx);
369
409
  // Land merges here (shard task land) and records itself after; the board only takes keep and discard.
@@ -152,4 +152,16 @@ export declare function withConversations(rows: import('@shardflux/sdk').Agent[]
152
152
  question?: string | null;
153
153
  }[] | null): ThreadRow[];
154
154
  export declare const KEY_HANDLERS: Record<string, Handler>;
155
+ /**
156
+ * The secret value (never from arguments): --from-file, piped standard input, or a no-echo prompt on a terminal. Null
157
+ * when the person types it on the page shard opens instead (0.20.1+): --ask, or a coding agent's shell with nothing
158
+ * piped (a terminal there is not the person's).
159
+ */
160
+ export declare function readSecretValue(ctx: Ctx, values: Values, name: string): Promise<string | null>;
161
+ /** The secret's value from the page shard opens, handed straight to `save` (create or rotate); the page shows its outcome. */
162
+ export declare function askSecretValue<T>(ctx: Ctx, values: Values, page: {
163
+ title: string;
164
+ purpose: string;
165
+ name: string;
166
+ }, save: (value: string) => Promise<T>): Promise<T>;
155
167
  export {};
package/dist/commands.js CHANGED
@@ -2345,17 +2345,20 @@ export const KEY_HANDLERS = {
2345
2345
  if (keychain && from !== 'keychain')
2346
2346
  throw new UsageError('--keychain applies to the keychain vault');
2347
2347
  const targets = secretNames(names.map(n => alias ?? n), '--as');
2348
- const project = (await principal(ctx)).project_id;
2348
+ const agent = values.scope !== undefined ? await agentSecretContext(ctx, values) : null;
2349
+ const project = agent ? null : (await principal(ctx)).project_id;
2349
2350
  const imported = [];
2350
2351
  for (const [i, name] of names.entries()) {
2351
2352
  const value = await vaultValue(from, name, { signal: ctx.signal, ...(keychain ? { keychain } : {}) });
2352
2353
  ctx.addSecret(value);
2353
- imported.push(await ctx.cloud().secrets.create(project, { name: targets[i], value }));
2354
+ imported.push(agent ? await agent.cloud.secrets.createAgent(agent.target, { name: targets[i], value }) : await ctx.cloud().secrets.create(project, { name: targets[i], value }));
2354
2355
  }
2355
2356
  out(ctx, `Imported ${imported.map(s => s.name).join(', ')}. ${SECRET_SECURITY_NOTE}\n`, { data: imported, security_note: SECRET_SECURITY_NOTE });
2356
2357
  return 0;
2357
2358
  },
2358
2359
  async 'secrets create'(ctx, values, [name]) {
2360
+ if (values.scope !== undefined)
2361
+ return agentSecretCommand(ctx, values, 'create', name);
2359
2362
  if (!SECRET_NAME.test(name))
2360
2363
  throw new UsageError(`<name> must be an environment variable name like OPENAI_API_KEY (^[A-Z_][A-Z0-9_]*$), got "${name}"`);
2361
2364
  const scope = scopeFlag(values);
@@ -2377,6 +2380,8 @@ export const KEY_HANDLERS = {
2377
2380
  return 0;
2378
2381
  },
2379
2382
  async 'secrets list'(ctx, values) {
2383
+ if (values.scope !== undefined)
2384
+ return agentSecretCommand(ctx, values, 'list', undefined);
2380
2385
  const cloud = ctx.cloud();
2381
2386
  const org = scopeFlag(values);
2382
2387
  const project = org === null ? (await principal(ctx)).project_id : null;
@@ -2404,11 +2409,15 @@ export const KEY_HANDLERS = {
2404
2409
  return 0;
2405
2410
  },
2406
2411
  async 'secrets get'(ctx, values, [ref]) {
2412
+ if (values.scope !== undefined)
2413
+ return agentSecretCommand(ctx, values, 'get', ref);
2407
2414
  const s = await resolveSecret(ctx, values, ref);
2408
2415
  out(ctx, secretDetail(s), s);
2409
2416
  return 0;
2410
2417
  },
2411
2418
  async 'secrets update'(ctx, values, [ref]) {
2419
+ if (values.scope !== undefined)
2420
+ return agentSecretCommand(ctx, values, 'update', ref);
2412
2421
  const s = await resolveSecret(ctx, values, ref);
2413
2422
  const params = { ...permissionParams(values, s.scope, 'update') };
2414
2423
  const description = str(values.description);
@@ -2421,6 +2430,8 @@ export const KEY_HANDLERS = {
2421
2430
  return 0;
2422
2431
  },
2423
2432
  async 'secrets rotate'(ctx, values, [ref]) {
2433
+ if (values.scope !== undefined)
2434
+ return agentSecretCommand(ctx, values, 'rotate', ref);
2424
2435
  const value = await readSecretValue(ctx, values, ref);
2425
2436
  const s = await resolveSecret(ctx, values, ref);
2426
2437
  const rotate = (secret) => ctx.cloud().secrets.rotate(s.id, secret);
@@ -2433,6 +2444,8 @@ export const KEY_HANDLERS = {
2433
2444
  return 0;
2434
2445
  },
2435
2446
  async 'secrets versions'(ctx, values, [ref]) {
2447
+ if (values.scope !== undefined)
2448
+ return agentSecretCommand(ctx, values, 'versions', ref);
2436
2449
  const s = await resolveSecret(ctx, values, ref);
2437
2450
  const limitText = str(values.limit);
2438
2451
  const page = await ctx.cloud().secrets.versions(s.id, limitText ? { limit: parsePositiveInt(limitText, 'limit', 200) } : {});
@@ -2440,6 +2453,8 @@ export const KEY_HANDLERS = {
2440
2453
  return 0;
2441
2454
  },
2442
2455
  async 'secrets delete'(ctx, values, [ref]) {
2456
+ if (values.scope !== undefined)
2457
+ return agentSecretCommand(ctx, values, 'delete', ref);
2443
2458
  if (values.yes !== true)
2444
2459
  throw new UsageError('refusing to delete without --yes (the values are erased and the name is removed from every workspace binding)');
2445
2460
  const s = await resolveSecret(ctx, values, ref);
@@ -2448,6 +2463,8 @@ export const KEY_HANDLERS = {
2448
2463
  return 0;
2449
2464
  },
2450
2465
  async 'secrets access-log'(ctx, values, [ref]) {
2466
+ if (values.scope !== undefined)
2467
+ return agentSecretCommand(ctx, values, 'access-log', ref);
2451
2468
  const outcome = str(values.outcome);
2452
2469
  if (outcome !== undefined && !['granted', 'denied', 'failed'].includes(outcome))
2453
2470
  throw new UsageError('--outcome must be granted, denied or failed');
@@ -2549,6 +2566,106 @@ function updateLine(u, d) {
2549
2566
  }
2550
2567
  }
2551
2568
  /** `--org <id>`: organization scope; null: the API key's project. */
2569
+ async function agentSecretContext(ctx, values) {
2570
+ const scope = str(values.scope);
2571
+ if (!['agents', 'project', 'conversation'].includes(scope ?? ''))
2572
+ throw new UsageError('--scope must be agents, project or conversation');
2573
+ const restrictions = ['tool', 'allow-workspace', 'any-workspace', 'allow-project', 'all-projects', 'clear-projects'];
2574
+ if (restrictions.some(k => Array.isArray(values[k]) ? values[k].length > 0 : values[k] !== undefined && values[k] !== false))
2575
+ throw new UsageError('Workspace tool permissions apply to workspace-bound secrets. Agent credentials use their agents, project or conversation scope.');
2576
+ if (scope === 'agents' && (values.project !== undefined || values.conversation !== undefined))
2577
+ throw new UsageError('--scope agents takes --org; use a local scope for --project or --conversation.');
2578
+ if (scope === 'project' && values.conversation !== undefined)
2579
+ throw new UsageError('Use --scope conversation with --conversation.');
2580
+ const hasKey = Boolean(ctx.io.env.SHARDFLUX_API_KEY?.trim()) || ctx.store.profile(ctx.apiUrl).api_key !== undefined;
2581
+ const cloud = scope === 'agents' || !hasKey ? ctx.account() : ctx.cloud();
2582
+ if (scope === 'agents') {
2583
+ const account = ctx.account(), org = str(values.org);
2584
+ const organization = org ? await findOrg(account, org) : await resolveOrg(ctx, account, {});
2585
+ return { cloud, target: { scope, organizationId: organization.id } };
2586
+ }
2587
+ const localOrg = str(values.org);
2588
+ if (localOrg !== undefined && !UUID.test(localOrg))
2589
+ throw new UsageError('--org must be an organization id for a local credential.');
2590
+ const organization = localOrg ? { organizationId: localOrg } : {};
2591
+ if (scope === 'conversation') {
2592
+ const conversationId = str(values.conversation);
2593
+ if (!conversationId)
2594
+ throw new UsageError('--conversation is required with --scope conversation');
2595
+ return { cloud, target: { scope, conversationId, ...organization, ...(str(values.project) ? { projectId: str(values.project) } : {}) } };
2596
+ }
2597
+ const projectId = str(values.project) ?? (hasKey ? (await principal(ctx)).project_id : undefined);
2598
+ if (!projectId)
2599
+ throw new UsageError('--project is required with a signed-in session and --scope project');
2600
+ return { cloud, target: { scope: 'project', projectId, ...organization } };
2601
+ }
2602
+ async function agentSecretCommand(ctx, values, action, ref) {
2603
+ const { cloud, target } = await agentSecretContext(ctx, values);
2604
+ if (action === 'list' && (values.limit !== undefined || values.cursor !== undefined || values['include-deleted'] === true))
2605
+ throw new UsageError('Agent credential lists return all active names. Pagination and deleted-history flags apply to workspace-bound secrets.');
2606
+ const describe = (r) => `${r.name} (${r.id}) · ${r.scope} · version ${r.current_version}\n`;
2607
+ if (action === 'list') {
2608
+ const data = await cloud.secrets.listAgents(target);
2609
+ out(ctx, data.map(describe).join('') || 'No credentials.\n', { data });
2610
+ return 0;
2611
+ }
2612
+ if (action === 'create') {
2613
+ if (!SECRET_NAME.test(ref ?? ''))
2614
+ throw new UsageError('Use an environment variable name for the credential.');
2615
+ const value = await readSecretValue(ctx, values, ref), save = (value) => { ctx.addSecret(value); return cloud.secrets.createAgent(target, { name: ref, value, ...(str(values.description) !== undefined ? { description: str(values.description) } : {}) }); };
2616
+ const r = value !== null ? await save(value) : await askSecretValue(ctx, values, { title: `Add ${ref}`, purpose: `Saved encrypted for your agents as ${ref}.`, name: ref }, save);
2617
+ out(ctx, describe(r), r);
2618
+ return 0;
2619
+ }
2620
+ const r = UUID.test(ref ?? '') ? await cloud.secrets.getAgent(ref) : (await cloud.secrets.listAgents(target)).find(r => r.name === ref);
2621
+ if (!r)
2622
+ throw new NotFoundError(`No credential named ${ref} in this scope.`);
2623
+ // An id is still checked against the explicitly named target rather than silently operating in another scope.
2624
+ if (r.scope !== target.scope || target.organizationId && r.organization_id !== target.organizationId || target.projectId && r.project_id !== target.projectId || target.conversationId && r.conversation_id !== target.conversationId)
2625
+ throw new NotFoundError('That credential belongs to another scope.');
2626
+ if (action === 'get') {
2627
+ out(ctx, describe(r), r);
2628
+ return 0;
2629
+ }
2630
+ if (action === 'update') {
2631
+ const description = str(values.description);
2632
+ if (description === undefined)
2633
+ throw new UsageError('Pass --description to update an agent credential.');
2634
+ const changed = await cloud.secrets.updateAgent(r.id, description);
2635
+ out(ctx, describe(changed), changed);
2636
+ return 0;
2637
+ }
2638
+ if (action === 'rotate') {
2639
+ const value = await readSecretValue(ctx, values, r.name), save = (value) => { ctx.addSecret(value); return cloud.secrets.rotateAgent(r.id, value); };
2640
+ const changed = value !== null ? await save(value) : await askSecretValue(ctx, values, { title: `New value for ${r.name}`, purpose: `Replaces the encrypted credential ${r.name}.`, name: r.name }, save);
2641
+ out(ctx, describe(changed), changed);
2642
+ return 0;
2643
+ }
2644
+ if (action === 'delete') {
2645
+ if (values.yes !== true)
2646
+ throw new UsageError('Pass --yes to erase this credential.');
2647
+ await cloud.secrets.deleteAgent(r.id);
2648
+ out(ctx, `Deleted ${r.name}.\n`, { deleted: true, id: r.id, name: r.name, scope: r.scope });
2649
+ return 0;
2650
+ }
2651
+ if (action === 'versions') {
2652
+ const limit = str(values.limit);
2653
+ const page = await cloud.secrets.versions(r.id, limit ? { limit: parsePositiveInt(limit, 'limit', 200) } : {});
2654
+ out(ctx, secretVersionTable(page.data), { data: page.data, next_cursor: page.nextCursor });
2655
+ return 0;
2656
+ }
2657
+ if (action === 'access-log') {
2658
+ const outcome = str(values.outcome), workspace = str(values.workspace), limit = str(values.limit);
2659
+ if (outcome !== undefined && !['granted', 'denied', 'failed'].includes(outcome))
2660
+ throw new UsageError('--outcome must be granted, denied or failed');
2661
+ if (workspace !== undefined && !UUID.test(workspace))
2662
+ throw new UsageError('--workspace must be a workspace id');
2663
+ const page = await cloud.secrets.accessEvents(r.id, { ...(outcome ? { outcome: outcome } : {}), ...(workspace ? { workspaceId: workspace } : {}), ...(limit ? { limit: parsePositiveInt(limit, 'limit', 200) } : {}) });
2664
+ out(ctx, JSON.stringify(page.data) + '\n', { data: page.data, next_cursor: page.nextCursor });
2665
+ return 0;
2666
+ }
2667
+ throw new UsageError('Unknown credential action.');
2668
+ }
2552
2669
  function scopeFlag(values) {
2553
2670
  const org = str(values.org);
2554
2671
  if (org === undefined)
@@ -2599,7 +2716,7 @@ function permissionParams(values, scope, mode) {
2599
2716
  * when the person types it on the page shard opens instead (0.20.1+): --ask, or a coding agent's shell with nothing
2600
2717
  * piped (a terminal there is not the person's).
2601
2718
  */
2602
- async function readSecretValue(ctx, values, name) {
2719
+ export async function readSecretValue(ctx, values, name) {
2603
2720
  const file = str(values['from-file']);
2604
2721
  if (values.ask === true) {
2605
2722
  if (file !== undefined)
@@ -2643,7 +2760,7 @@ function secretText(value) {
2643
2760
  return text;
2644
2761
  }
2645
2762
  /** The secret's value from the page shard opens, handed straight to `save` (create or rotate); the page shows its outcome. */
2646
- async function askSecretValue(ctx, values, page, save) {
2763
+ export async function askSecretValue(ctx, values, page, save) {
2647
2764
  const asked = await askInBrowser(ctx, {
2648
2765
  kind: 'secret',
2649
2766
  title: page.title,
@@ -59,6 +59,8 @@ export interface Conversation {
59
59
  started_by?: {
60
60
  user_id: string;
61
61
  } | null;
62
+ orchestrator_conversation_id?: string | null;
63
+ orchestrator_input_id?: string | null;
62
64
  }
63
65
  export interface LocalConversation {
64
66
  api_url: string;
@@ -250,7 +250,7 @@ export async function createConversationRecord(ctx, folder, harness, route, name
250
250
  capture.catch(() => undefined);
251
251
  // The title marker this computer chose (`shard settings --title-prefix`, 0.20.1+); none sent means the platform's.
252
252
  const titlePrefix = storedTitlePrefix(ctx.store.dir);
253
- const response = await conversationClient(ctx).conversations.create({ repoRemote: project.remote ?? `file://${project.folder}`, harness, route, mode: 'new',
253
+ const response = await conversationClient(ctx).conversations.create({ fromConversationId: ctx.io.env.SF_CONVERSATION_ID, repoRemote: project.remote ?? `file://${project.folder}`, harness, route, mode: 'new',
254
254
  base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), ...choice, projectId: ctx.store.profile(ctx.apiUrl).project_id,
255
255
  ...(titlePrefix !== undefined ? { titlePrefix } : {}),
256
256
  ...(movedIn ? { harnessSessionId: movedIn.session_id, ...(movedIn.title ? { title: movedIn.title.slice(0, 200) } : {}) } : {}) }, { idempotencyKey, signal: ctx.signal });
@@ -585,7 +585,7 @@ export class DeviceExecutor {
585
585
  if (r.kind === 'dir_list')
586
586
  result = { state: 'done', dir: await listDeviceDir(target, r.dir ?? {}, ctx.signal) };
587
587
  else if (r.kind === 'project_link')
588
- 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, seed: true }, undefined, this.#seedHub) };
588
+ 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, seed: true, fromConversationId: r.conversation_id }, undefined, this.#seedHub) };
589
589
  else {
590
590
  const f = r.files;
591
591
  if (!f.remote_path.startsWith('/') || /[\0\r\n]/.test(f.remote_path))
@@ -36,7 +36,7 @@ export interface LinkedProject {
36
36
  commit?: string;
37
37
  branch?: string;
38
38
  }
39
- type Starter = (ctx: Ctx, folder: string, harness: 'claude' | 'codex', brief: string) => Promise<{
39
+ type Starter = (ctx: Ctx, folder: string, harness: 'claude' | 'codex', brief: string, fromConversationId?: string) => Promise<{
40
40
  id: string;
41
41
  project_id: string;
42
42
  }>;
@@ -65,6 +65,7 @@ export declare function linkLocalProject(ctx: Ctx, path: string, options?: {
65
65
  harness?: 'claude' | 'codex';
66
66
  projectId?: string;
67
67
  seed?: boolean;
68
+ fromConversationId?: string;
68
69
  }, start?: Starter, seedHub?: (ctx: Ctx, folder: string, projectId: string) => Promise<HubSeed>): Promise<LinkedProject>;
69
70
  /** `shard projects link <path>` (0.25.0+): what the desktop app's "Add a folder…" runs, the same as a device's project_link. */
70
71
  export declare const PROJECT_LINK_HANDLERS: Record<string, Handler>;
@@ -139,14 +139,14 @@ export async function listDeviceDir(root, options = {}, signal) {
139
139
  * working tree (uncommitted files too, .gitignore respected) seeds it while its VM boots, and the brief is its first
140
140
  * prompt. The brief goes in on standard input, never in a command line another user can read.
141
141
  */
142
- export const startFromFolder = (ctx, folder, harness, brief) => {
142
+ export const startFromFolder = (ctx, folder, harness, brief, fromConversationId) => {
143
143
  const node = ctx.runtime?.node ?? process.execPath, script = ctx.runtime?.script ?? process.argv[1];
144
144
  if (!script)
145
145
  return Promise.reject(new Error('The Shardflux CLI could not find itself to start the conversation.'));
146
146
  return new Promise((resolveStart, reject) => {
147
147
  const child = spawn(node, [script, 'agents', 'new', '--app', harness, '--path', folder, '--prompt-file', '-', '--json'], {
148
148
  cwd: folder, stdio: ['pipe', 'pipe', 'pipe'],
149
- env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, CI: '1' },
149
+ env: { ...process.env, ...ctx.io.env, SHARDFLUX_CONFIG_DIR: ctx.store.dir, SHARDFLUX_API_URL: ctx.apiUrl, CI: '1', ...(fromConversationId ? { SF_CONVERSATION_ID: fromConversationId } : {}) },
150
150
  });
151
151
  const out = [], err = [];
152
152
  let outBytes = 0;
@@ -209,7 +209,7 @@ export async function linkLocalProject(ctx, path, options = {}, start = startFro
209
209
  const profile = ctx.store.profile(ctx.apiUrl);
210
210
  let projectId = profile.project_id ?? profile.api_key?.project_id ?? options.projectId, conversationId;
211
211
  if (options.brief) {
212
- const started = await start(ctx, folder, options.harness ?? 'claude', options.brief);
212
+ const started = await start(ctx, folder, options.harness ?? 'claude', options.brief, options.fromConversationId);
213
213
  conversationId = started.id;
214
214
  projectId = started.project_id || projectId;
215
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.28.1",
3
+ "version": "0.28.2",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -53,7 +53,7 @@
53
53
  "snappyjs": "0.7.0",
54
54
  "ws": "8.22.0",
55
55
  "yaml": "2.9.1",
56
- "@shardflux/sdk": "^0.30.1"
56
+ "@shardflux/sdk": "^0.30.2"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@eslint/js": "10.0.1",
@@ -65,10 +65,10 @@
65
65
  "typescript-eslint": "8.70.1"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@shardflux/desktop-darwin-arm64": "0.9.2",
69
- "@shardflux/desktop-darwin-x64": "0.9.2",
70
- "@shardflux/desktop-linux-arm64": "0.9.2",
71
- "@shardflux/desktop-linux-x64": "0.9.2"
68
+ "@shardflux/desktop-darwin-arm64": "0.9.3",
69
+ "@shardflux/desktop-darwin-x64": "0.9.3",
70
+ "@shardflux/desktop-linux-arm64": "0.9.3",
71
+ "@shardflux/desktop-linux-x64": "0.9.3"
72
72
  },
73
73
  "scripts": {
74
74
  "build": "node scripts/build.mjs",