@shardflux/cli 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -42,6 +42,8 @@ shard ws suspend acme/demo --wait
42
42
  | `SHARDFLUX_API_KEY` | Project API key (`sfk_<key id>_<secret>`). Required. `shard` never accepts the key as an argument (`--api-key`, `--token` and similar flags are refused) and never prints it; anything shaped like a key is redacted from error output. |
43
43
  | `SHARDFLUX_API_URL` | API base URL. Default `https://api.shardflux.dev`; `--api-url` overrides it. Plain `http://` is allowed only for loopback hosts. |
44
44
  | `SHARDFLUX_AGENT_LABEL` | Attribution label for the tool tokens `shard` obtains for exec and files. Default `cli`; `--agent-label` overrides it. Each label is one agent session, listed by `shard ws sessions`. |
45
+ | `SHARDFLUX_NO_WAKE=1` | Do not resume a suspended workspace on use; same as `--no-wake` (see "Suspended workspaces" below). `--wake` overrides it. |
46
+ | `SHARDFLUX_WAKE_TIMEOUT_MS` | Longest wait, in milliseconds, for a workspace to wake or finish a transition. Default `120000`; `--wake-timeout <ms>` overrides it. |
45
47
  | `SHARDFLUX_HTTP_KEEPALIVE=1` | Reuse HTTP connections (see "HTTP connections" below). |
46
48
 
47
49
  `shard login` stores nothing. There is no config file: the key is read from the environment on
@@ -54,8 +56,8 @@ every run.
54
56
  | `login`, `whoami` | Show the principal behind the key. |
55
57
  | `version` | Print the CLI and SDK versions. |
56
58
  | `usage [--org <id>]` | Usage summary for the current period: plan, meters and allowances. |
57
- | `workspaces open <key> --template <slug> [--cpu-millis N --memory-mib N --disk-gib N] [--no-wait] [--timeout 5m]` | Open by key. Creates the workspace on first use and reconnects or resumes it afterwards, never resetting it. Waits until it is ready unless you pass `--no-wait`. |
58
- | `workspaces list [--state S] [--prefix P] [--include-deleted] [--all] [--limit N] [--cursor C]` | One page, or every page with `--all`. The next cursor is printed on stderr. |
59
+ | `workspaces open <key> --template <slug> [--secret NAME]... [--lifetime persistent\|session] [--cpu-millis N --memory-mib N --disk-gib N] [--no-wait] [--timeout 5m]` | Open by key. Creates the workspace on first use and reconnects or resumes it afterwards, never resetting it. Waits until it is ready unless you pass `--no-wait`. `--secret` binds secret names (replacing the binding of an existing workspace). `--lifetime session`: the workspace is discarded when the session ends (`workspaces close` or the idle timeout), and the key then opens a new workspace. |
60
+ | `workspaces list [--state S] [--prefix P] [--lifetime persistent\|session\|any] [--purpose standard\|template_draft\|template_test\|any] [--include-deleted] [--all] [--limit N] [--cursor C]` | One page, or every page with `--all`. The next cursor is printed on stderr. By default only persistent standard workspaces are listed. |
59
61
  | `workspaces get <id\|key>` | One workspace (deleted ones included). |
60
62
  | `workspaces exec <id\|key> [--cwd D] [--env K=V]... [--timeout T] [--stdin F\|-] -- <cmd> [args...]` | Runs argv with no shell. Output is streamed and resumes from byte offsets after dropped connections. `shard` exits with the command's exit code. Ctrl-C cancels the command. |
61
63
  | `files read <id\|key> <path> [--out F]` | Raw bytes to stdout, or to a local file. |
@@ -63,13 +65,70 @@ every run.
63
65
  | `files ls <id\|key> <path> [--limit N]` | Directory listing. |
64
66
  | `workspaces suspend\|resume <id\|key> [--wait] [--timeout T]` | Lifecycle operation. Returns immediately unless you pass `--wait`. |
65
67
  | `workspaces fork <id\|key> <new-key> [caps] [--wait]` | Fork into a new key. |
66
- | `workspaces delete <id\|key> --yes [--wait]` | Delete the workspace. Tool access ends at once; keys are never reused. |
68
+ | `workspaces delete <id\|key> --yes [--wait]` | Delete the workspace. Tool access ends at once. The key of a persistent workspace is never reused. |
69
+ | `workspaces close <id\|key> [--wait]` | End a session workspace now: it is deleted, and the key then opens a new workspace. A persistent workspace is refused (`not_session`). |
70
+ | `workspaces reset <id\|key> --yes [--wait]` | Layered workspaces: wipe every change and restart on the template. The previous state stays restorable for 7 days. |
71
+ | `workspaces save-as-template <id\|key> --template <slug> [--display-name N] [--description T] [--checkpoint ID] [--default-lifetime L] [--default-idle-timeout D] [--acknowledge PATH]... [--no-auto-publish] [--wait]` | Save a layered workspace as the next version of an organization template. `--wait` follows the build. |
72
+ | `workspaces changes <id\|key> [--path P] [--hash] [--summary] [--all] [--limit N] [--cursor C]` | What a layered, running workspace changed against its template. |
67
73
  | `workspaces sessions <id\|key>` | Attributed agent sessions: label, principal and tokens issued. |
74
+ | `workspaces secrets <id\|key> [--set NAME]... [--clear]` | Show the secret names bound to the workspace and their status, or replace them. |
75
+ | `secrets create <NAME> [--from-file F] [--description T] [--tool T]... [--allow-workspace ID]... [--org ID [--allow-project ID]... [--all-projects]]` | Create a secret. The value comes from standard input or `--from-file`, never from an argument. |
76
+ | `secrets list [--org ID] [--include-deleted] [--all]`, `secrets get <id\|NAME>` | Secret metadata. Values are never shown. |
77
+ | `secrets update <id\|NAME> [--description T] [--tool T]... [--allow-workspace ID]... [--any-workspace] [--allow-project ID]... [--all-projects] [--clear-projects]` | Change the description or usage permissions. |
78
+ | `secrets rotate <id\|NAME> [--from-file F]` | Store a new value (standard input or file); older values are erased. |
79
+ | `secrets versions <id\|NAME>`, `secrets delete <id\|NAME> --yes`, `secrets access-log <id\|NAME>` | Versions, deletion (also removes the name from every workspace binding), and the access log. |
68
80
  | `operations list <id\|key> [--state S] [--kind K]`, `operations get <op>`, `operations wait <op> [--timeout T]` | Operations. |
81
+ | `templates list [--owner platform\|organization] [--include-archived] [--all]` | Templates the project can open, with their open version, layouts, file list state and draft. |
82
+ | `templates files <slug> <version> [path] [--stat] [--owner O] [--all] [--limit N]` | One directory of a version's file tree, or one entry with `--stat`. |
83
+ | `templates diff <slug> --from <version\|base> --to <version> [--prefix P] [--change K] [--all]` | Diff two versions: added, removed, changed, type_changed, metadata. The totals come first. |
84
+ | `templates draft open <slug> [--base slug@version] [caps] [--no-wait]` | Open the template's draft, a layered workspace you edit with `workspaces exec`/`files`. |
85
+ | `templates draft status <slug>` | The draft's workspace, base version, states and live test instances. |
86
+ | `templates draft state <slug> [--label T] [--wait]`, `... state <slug> --list` | Capture a state of the running draft, or list its states. |
87
+ | `templates draft test <slug> [--state ID] [--instance-key K] [--no-wait]`, `... test <slug> --list [--include-ended]` | Open a test instance (a session on a copy of a state), or list them. |
88
+ | `templates draft publish <slug> [--state ID] [--description T] [--default-lifetime L] [--acknowledge PATH]... [--no-auto-publish] [--wait]` | Publish the draft as the template's next version. |
89
+ | `templates draft discard <slug> --yes [--wait]` | Delete the draft and end its live test instances. |
90
+
91
+ Aliases: `ws` for `workspaces`, `ops` for `operations`, `secret` for `secrets`, `template` for `templates`.
92
+ `shard workspaces files ...` and `shard workspaces operations ...` also work. `<id|key>` is tried as an id when it
93
+ is a UUID. Otherwise, or if no workspace has that id, it is matched as an exact workspace key across every lifetime
94
+ and purpose, so sessions, drafts and test instances resolve too. The live workspace wins over tombstones that ended
95
+ sessions left with the same key.
96
+
97
+ ## Suspended workspaces
98
+
99
+ `workspaces exec`, `files read|write|ls` and `workspaces changes` wake a suspended workspace: they
100
+ resume it (or join the resume or open already running), wait until it runs, then run the request.
101
+ A request made during a suspend or resume waits for the transition to finish. The request itself
102
+ never runs twice, because the workspace refused it before doing anything.
103
+
104
+ - `--wake-timeout <ms>` (or `SHARDFLUX_WAKE_TIMEOUT_MS`, default `120000`) bounds the wait. Past
105
+ it, `shard` exits 5 and names the operation and its state (for example `Operation <id> (open)
106
+ is still queued after 120000 ms`). The operation continues server side:
107
+ `shard operations wait <id>` keeps waiting.
108
+ - A resume or open that fails exits 6.
109
+ - `--no-wake` (or `SHARDFLUX_NO_WAKE=1`) turns this off. A workspace that is not running is then
110
+ refused at once (exit 1, `workspace_not_running`).
111
+
112
+ ## Secrets
69
113
 
70
- Aliases: `ws` for `workspaces`, `ops` for `operations`. `shard workspaces files ...` and
71
- `shard workspaces operations ...` also work. `<id|key>` is tried as an id when it is a UUID;
72
- otherwise, or if no workspace has that id, it is matched as an exact workspace key.
114
+ ```sh
115
+ printf %s "$OPENAI_API_KEY" | shard secrets create OPENAI_API_KEY # value from standard input
116
+ shard secrets create DATABASE_URL --from-file ./database-url.txt # or from a file
117
+ shard ws open acme/demo --template python-node-browser --secret OPENAI_API_KEY --secret DATABASE_URL
118
+ shard ws exec acme/demo -- python3 agent.py # sees both as environment variables
119
+ shard ws secrets acme/demo # names and status, never values
120
+ printf %s "$NEW_KEY" | shard secrets rotate OPENAI_API_KEY
121
+ ```
122
+
123
+ - Secret values are read only from standard input or `--from-file` (one trailing newline is
124
+ removed). `shard` refuses `--value`, `--password` and similar flags, and never prints a value.
125
+ - Bound secrets reach every `exec` and terminal of the workspace. A name that is unknown, or a
126
+ secret the workspace may not use, is refused (exit 2, `secret_not_available` with the names).
127
+ - Secrets are project secrets of the API key's project by default. `--org <id>` addresses
128
+ organization-wide secrets; those, and `secrets access-log`, belong to organization owners and
129
+ admins in the console, so a project API key gets 403 (exit 4).
130
+ - `<id|NAME>` is tried as an id when it is a UUID; otherwise it is matched by name in the project
131
+ (or in the organization with `--org`).
73
132
 
74
133
  ## Output
75
134
 
@@ -82,6 +141,9 @@ otherwise, or if no workspace has that id, it is matched as an exact workspace k
82
141
  - `login`/`whoami` print `{api_url, organization_id, project_id, api_key:{id,key_id}, tool_permissions}`.
83
142
  - `exec` prints `{session_id, exit_code, term_signal, timed_out, canceled, stdout, stderr, truncated, reconnects}`.
84
143
  - `files read` prints `{path, bytes, content_base64}`; `files write` prints the write result.
144
+ - `secrets create`/`get`/`update` print the secret's metadata; `list`, `versions` and `access-log`
145
+ print `{data, next_cursor}`; `rotate` prints `{secret, version}`; `delete` prints
146
+ `{deleted, id, name, scope}`; `workspaces secrets` prints `{workspace_id, names, secrets}`.
85
147
 
86
148
  Errors go to stderr as `{"error": {code, message, status, request_id, ...}}`.
87
149
 
@@ -94,8 +156,8 @@ otherwise, or if no workspace has that id, it is matched as an exact workspace k
94
156
  | 2 | usage error: bad arguments, or input the API rejected as `validation_failed` |
95
157
  | 3 | not found: workspace, operation or route |
96
158
  | 4 | authentication or authorization: missing, malformed, revoked or insufficient key |
97
- | 5 | timeout: waiting gave up; the operation continues server side (`shard operations wait <id>`) |
98
- | 6 | the awaited operation ended `failed` or `canceled` |
159
+ | 5 | timeout: waiting gave up (`--timeout`, or `--wake-timeout` while waking a workspace); the operation continues server side (`shard operations wait <id>`) |
160
+ | 6 | the awaited operation ended `failed` or `canceled` (also a resume or open that failed while waking a workspace) |
99
161
  | 130 | interrupted (Ctrl-C). Waits stop at once, and exec sends a cancel to the session. |
100
162
  | exec | the command's own code: 124 if it timed out, 128+N if killed by signal N. Failures before the command ran use the codes above; use `--json` to tell them apart. |
101
163
 
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.1.0";
1
+ export declare const CLI_VERSION = "0.2.0";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
@@ -32,8 +32,15 @@ export declare function parsePositiveInt(text: string, option: string, max?: num
32
32
  /** ["A=1", "B=x=y"] -> { A: "1", B: "x=y" }. */
33
33
  export declare function parseEnvPairs(pairs: readonly string[]): Record<string, string>;
34
34
  export declare const UUID: RegExp;
35
+ /** Secret names (environment-variable safe; the API's SECRET_NAME_PATTERN). */
36
+ export declare const SECRET_NAME: RegExp;
35
37
  /** Validates the API base URL: https, or http only to a loopback address (the key is a bearer credential). */
36
38
  export declare function resolveApiUrl(flag: string | undefined, env: string | undefined): string;
39
+ export declare const DEFAULT_WAKE_TIMEOUT_MS = 120000;
40
+ /** Wake on use: --wake / --no-wake, else SHARDFLUX_NO_WAKE (1/true/yes/on disables), else on. */
41
+ export declare function resolveWake(flag: boolean | undefined, noWakeEnv: string | undefined): boolean;
42
+ /** The wake/transition budget per call in ms: --wake-timeout, else SHARDFLUX_WAKE_TIMEOUT_MS, else 120000. */
43
+ export declare function resolveWakeTimeout(flag: string | undefined, env: string | undefined): number;
37
44
  export type Values = Record<string, string | boolean | string[] | undefined>;
38
45
  export type Parsed = {
39
46
  kind: 'help';
@@ -49,6 +56,8 @@ export type Parsed = {
49
56
  apiUrl: string | undefined;
50
57
  json: boolean;
51
58
  agentLabel: string | undefined;
59
+ wake: boolean | undefined;
60
+ wakeTimeout: string | undefined;
52
61
  };
53
62
  };
54
63
  /**
package/dist/args.js CHANGED
@@ -10,11 +10,13 @@
10
10
  */
11
11
  import { parseArgs } from 'node:util';
12
12
  import { UsageError } from "./errors.js";
13
- export const CLI_VERSION = '0.1.0';
13
+ export const CLI_VERSION = '0.2.0';
14
14
  export const GLOBAL_OPTIONS = {
15
15
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
16
16
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
17
17
  'agent-label': { type: 'string', value: '<label>', description: 'Attribution label for workspace tool tokens (default $SHARDFLUX_AGENT_LABEL, else "cli").' },
18
+ wake: { type: 'boolean', description: 'Resume a suspended workspace when exec/files/changes use it (default; --no-wake or SHARDFLUX_NO_WAKE=1 fails with workspace_not_running instead).' },
19
+ 'wake-timeout': { type: 'string', value: '<ms>', description: 'Longest wait in milliseconds for the workspace to wake or finish a transition (default $SHARDFLUX_WAKE_TIMEOUT_MS, else 120000); then exit 5.' },
18
20
  help: { type: 'boolean', short: 'h', description: 'Show help.' },
19
21
  version: { type: 'boolean', short: 'V', description: 'Print the version.' },
20
22
  };
@@ -28,6 +30,39 @@ const WAIT = {
28
30
  timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (e.g. 90s, 5m; default 5m). The operation continues server side.' },
29
31
  };
30
32
  const REF = { name: 'id|key', required: true };
33
+ const SLUG = { name: 'slug', required: true };
34
+ const PAGE = {
35
+ all: { type: 'boolean', description: 'Fetch every page.' },
36
+ limit: { type: 'string', value: '<n>', description: 'Page size.' },
37
+ cursor: { type: 'string', value: '<cursor>', description: 'Continue from a previous page.' },
38
+ };
39
+ /** Options of a version produced by save-as-template or a draft publish (contracts §19.8). */
40
+ const SAVE = {
41
+ description: { type: 'string', value: '<text>', description: 'Version description (default: the source version’s).' },
42
+ 'default-lifetime': { type: 'string', value: '<persistent|session>', description: 'Default lifetime of workspaces opened from the new version.' },
43
+ 'default-idle-timeout': { type: 'string', value: '<duration>', description: 'Idle timeout of its session workspaces (60s-24h; default: the platform’s 10m).' },
44
+ acknowledge: { type: 'string', multiple: true, value: '<path>', description: 'Absolute path the credential scan may report without failing the build (repeatable, up to 200).' },
45
+ 'auto-publish': { type: 'boolean', description: 'Publish the version once registered (default; --no-auto-publish leaves it for an owner/admin).' },
46
+ wait: { type: 'boolean', description: 'Wait until the build is published and registered (or fails).' },
47
+ timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 30m). The build continues server side.' },
48
+ };
49
+ const LIFETIME_FILTER = {
50
+ lifetime: { type: 'string', value: '<persistent|session|any>', description: 'Lifetime filter (default persistent: sessions are hidden).' },
51
+ purpose: { type: 'string', value: '<standard|template_draft|template_test|any>', description: 'Purpose filter (default standard: template drafts and test instances are hidden).' },
52
+ };
53
+ const SECRET_REF = { name: 'id|name', required: true };
54
+ const SECRET_SCOPE = {
55
+ 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.' },
56
+ };
57
+ const SECRET_VALUE = {
58
+ 'from-file': { type: 'string', value: '<file>', description: 'Read the value from this file. Default: standard input (pipe it in). One trailing newline is removed. Values are never accepted as arguments.' },
59
+ };
60
+ const SECRET_PERMISSIONS = {
61
+ tool: { type: 'string', multiple: true, value: '<tool>', description: 'Tool that receives the value at session start (repeatable; exec, pty, files, process, git, browser). Default exec and pty.' },
62
+ 'allow-workspace': { type: 'string', multiple: true, value: '<workspace-id>', description: 'Only these workspaces may use it (repeatable). Default: any workspace of the allowed projects.' },
63
+ 'allow-project': { type: 'string', multiple: true, value: '<project-id>', description: 'Organization secrets: projects whose workspaces may use it (repeatable). Default: none until granted.' },
64
+ 'all-projects': { type: 'boolean', description: 'Organization secrets: every project of the organization may use it.' },
65
+ };
31
66
  export const COMMANDS = [
32
67
  {
33
68
  path: ['login'],
@@ -51,11 +86,27 @@ export const COMMANDS = [
51
86
  positionals: [{ name: 'key', required: true }],
52
87
  options: {
53
88
  template: { type: 'string', value: '<slug>', description: 'Template slug (required), e.g. python-node-browser.' },
89
+ secret: {
90
+ type: 'string',
91
+ multiple: true,
92
+ value: '<NAME>',
93
+ description: 'Bind this secret name (repeatable): injected as an environment variable into every exec and terminal. Replaces the binding of an existing workspace; omit to leave it unchanged.',
94
+ },
95
+ lifetime: {
96
+ type: 'string',
97
+ value: '<persistent|session>',
98
+ description: 'session: the workspace is discarded when the session ends ("shard ws close" or the idle timeout) and the key then opens a new one. Default: the template’s default, else persistent.',
99
+ },
54
100
  ...CAPS,
55
101
  wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once).' },
56
102
  timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m). The start continues server side.' },
57
103
  },
58
- examples: ['shard workspaces open acme/demo --template python-node-browser', 'shard ws open acme/demo --template python-node-browser --no-wait --json'],
104
+ examples: [
105
+ 'shard workspaces open acme/demo --template python-node-browser',
106
+ 'shard ws open job-42 --template python-node-browser --lifetime session',
107
+ 'shard ws open acme/demo --template python-node-browser --secret OPENAI_API_KEY --secret DATABASE_URL',
108
+ 'shard ws open acme/demo --template python-node-browser --no-wait --json',
109
+ ],
59
110
  },
60
111
  {
61
112
  path: ['workspaces', 'list'],
@@ -64,7 +115,8 @@ export const COMMANDS = [
64
115
  options: {
65
116
  state: { type: 'string', value: '<state>', description: 'Observed state filter (running, suspended, creating, ...).' },
66
117
  prefix: { type: 'string', value: '<key-prefix>', description: 'Only keys starting with this prefix.' },
67
- 'include-deleted': { type: 'boolean', description: 'Include deleted workspaces (tombstones).' },
118
+ ...LIFETIME_FILTER,
119
+ 'include-deleted': { type: 'boolean', description: 'Include deleted workspaces and ended sessions (tombstones).' },
68
120
  all: { type: 'boolean', description: 'Fetch every page.' },
69
121
  limit: { type: 'string', value: '<n>', description: 'Page size, 1-200 (default 50).' },
70
122
  cursor: { type: 'string', value: '<cursor>', description: 'Continue from a previous page.' },
@@ -98,7 +150,58 @@ export const COMMANDS = [
98
150
  positionals: [REF],
99
151
  options: { yes: { type: 'boolean', description: 'Required: confirm the deletion.' }, ...WAIT },
100
152
  },
153
+ {
154
+ path: ['workspaces', 'close'],
155
+ summary: 'End a session workspace now (it is deleted; the key then opens a new workspace)',
156
+ description: 'Sessions only: a persistent workspace is refused with conflict (not_session); suspend or delete it instead. Repeating returns the same delete operation.',
157
+ positionals: [REF],
158
+ options: { ...WAIT },
159
+ },
160
+ {
161
+ path: ['workspaces', 'reset'],
162
+ summary: 'Wipe every change in a layered workspace and restart it on its template',
163
+ description: 'Keeps the key, id, template version, caps, secret bindings and volume attachments; processes are gone. A suspended workspace stays suspended and boots blank on its next resume. The previous state stays restorable for 7 days (the operation result names the recovery checkpoint).',
164
+ positionals: [REF],
165
+ options: { yes: { type: 'boolean', description: 'Required: confirm that every change is wiped.' }, ...WAIT },
166
+ },
167
+ {
168
+ path: ['workspaces', 'save-as-template'],
169
+ summary: 'Save a layered workspace as the next version of an organization template',
170
+ description: 'Everything in the workspace becomes template content (minus the sf-scrub.v1 list: machine identity, histories, credentials, .env files, caches), stored as one new layer on the workspace’s template. A running workspace is captured briefly and keeps running.',
171
+ positionals: [REF],
172
+ options: {
173
+ template: { type: 'string', value: '<slug>', description: 'Organization template to save into (required; created when absent).' },
174
+ 'display-name': { type: 'string', value: '<name>', description: 'Template name when this save creates the template.' },
175
+ checkpoint: { type: 'string', value: '<checkpoint-id>', description: 'Save this committed checkpoint of the workspace instead of its current state.' },
176
+ ...SAVE,
177
+ },
178
+ examples: ['shard ws save-as-template acme/demo --template acme-dev --description "node 22 + repo deps" --wait'],
179
+ },
180
+ {
181
+ path: ['workspaces', 'changes'],
182
+ summary: 'List what a layered workspace changed against its template',
183
+ description: 'Served by the cell gateway from the running workspace (needs the files tool). added, modified, metadata (with --hash), deleted, replaced (opaque directory).',
184
+ positionals: [REF],
185
+ options: {
186
+ path: { type: 'string', value: '<prefix>', description: 'Only entries at or below this absolute path (default /).' },
187
+ hash: { type: 'boolean', description: 'Hash regular files up to 16 MiB (reports metadata-only changes).' },
188
+ summary: { type: 'boolean', description: 'Also print totals over everything under --path.' },
189
+ ...PAGE,
190
+ limit: { type: 'string', value: '<n>', description: 'Page size, 1-1000 (default 1000).' },
191
+ },
192
+ },
101
193
  { path: ['workspaces', 'sessions'], summary: 'List the attributed agent sessions (tool-token principals and labels) of a workspace', positionals: [REF], options: {} },
194
+ {
195
+ path: ['workspaces', 'secrets'],
196
+ summary: 'Show (or replace) the secret names bound to a workspace',
197
+ description: 'Bound secrets are injected as environment variables into every exec and terminal of the workspace. Status: available, not_allowed (starts are refused until the secret’s permissions allow this workspace) or deleted. Values are never shown.',
198
+ positionals: [REF],
199
+ options: {
200
+ set: { type: 'string', multiple: true, value: '<NAME>', description: 'Replace the binding with these names (repeatable).' },
201
+ clear: { type: 'boolean', description: 'Remove every bound name.' },
202
+ },
203
+ examples: ['shard ws secrets acme/demo', 'shard ws secrets acme/demo --set OPENAI_API_KEY --set DATABASE_URL', 'shard ws secrets acme/demo --clear'],
204
+ },
102
205
  {
103
206
  path: ['files', 'read'],
104
207
  summary: 'Print (or save) a file from a workspace',
@@ -132,6 +235,160 @@ export const COMMANDS = [
132
235
  },
133
236
  },
134
237
  { path: ['operations', 'get'], summary: 'Show one operation', positionals: [{ name: 'operation-id', required: true }], options: {} },
238
+ {
239
+ path: ['secrets', 'create'],
240
+ summary: 'Create a secret; the value is read from standard input or --from-file, never from arguments',
241
+ description: 'Project secret of the API key’s project by default, or an organization-wide secret with --org. The value (UTF-8 text, up to 64 KiB) is encrypted and never shown again. Bind it to workspaces with "shard ws open --secret NAME" or "shard ws secrets <ws> --set NAME".',
242
+ positionals: [{ name: 'name', required: true }],
243
+ options: { ...SECRET_SCOPE, ...SECRET_VALUE, description: { type: 'string', value: '<text>', description: 'Description (up to 500 characters).' }, ...SECRET_PERMISSIONS },
244
+ examples: ['printf %s "$OPENAI_API_KEY" | shard secrets create OPENAI_API_KEY', 'shard secrets create DATABASE_URL --from-file ./database-url.txt --description "Staging database"'],
245
+ },
246
+ {
247
+ path: ['secrets', 'list'],
248
+ summary: 'List secrets (metadata only; never values)',
249
+ positionals: [],
250
+ options: {
251
+ ...SECRET_SCOPE,
252
+ 'include-deleted': { type: 'boolean', description: 'Include deleted secrets.' },
253
+ all: { type: 'boolean', description: 'Fetch every page.' },
254
+ limit: { type: 'string', value: '<n>', description: 'Page size, 1-200 (default 50).' },
255
+ cursor: { type: 'string', value: '<cursor>', description: 'Continue from a previous page.' },
256
+ },
257
+ },
258
+ { path: ['secrets', 'get'], summary: 'Show one secret’s metadata (never the value)', positionals: [SECRET_REF], options: { ...SECRET_SCOPE } },
259
+ {
260
+ path: ['secrets', 'update'],
261
+ summary: 'Change a secret’s description or usage permissions (applies from the next session start)',
262
+ positionals: [SECRET_REF],
263
+ options: {
264
+ ...SECRET_SCOPE,
265
+ description: { type: 'string', value: '<text>', description: 'New description.' },
266
+ ...SECRET_PERMISSIONS,
267
+ 'any-workspace': { type: 'boolean', description: 'Remove the workspace restriction (any workspace of the allowed projects).' },
268
+ 'clear-projects': { type: 'boolean', description: 'Organization secrets: no project may use it (until granted again).' },
269
+ },
270
+ },
271
+ {
272
+ path: ['secrets', 'rotate'],
273
+ summary: 'Store a new value as the next version (standard input or --from-file); older values are erased',
274
+ positionals: [SECRET_REF],
275
+ options: { ...SECRET_SCOPE, ...SECRET_VALUE },
276
+ examples: ['printf %s "$NEW_TOKEN" | shard secrets rotate API_TOKEN'],
277
+ },
278
+ {
279
+ path: ['secrets', 'versions'],
280
+ summary: 'List a secret’s versions, newest first (metadata only)',
281
+ positionals: [SECRET_REF],
282
+ options: { ...SECRET_SCOPE, limit: { type: 'string', value: '<n>', description: 'Page size, 1-200 (default 50).' } },
283
+ },
284
+ {
285
+ path: ['secrets', 'delete'],
286
+ summary: 'Delete a secret: its values are erased and it is removed from every workspace binding',
287
+ positionals: [SECRET_REF],
288
+ options: { ...SECRET_SCOPE, yes: { type: 'boolean', description: 'Required: confirm the deletion.' } },
289
+ },
290
+ {
291
+ path: ['secrets', 'access-log'],
292
+ summary: 'Show which sessions resolved a secret (owners/admins; project API keys get 403)',
293
+ positionals: [SECRET_REF],
294
+ options: {
295
+ ...SECRET_SCOPE,
296
+ outcome: { type: 'string', value: '<outcome>', description: 'granted, denied or failed.' },
297
+ workspace: { type: 'string', value: '<workspace-id>', description: 'Only this workspace.' },
298
+ limit: { type: 'string', value: '<n>', description: 'Page size, 1-200 (default 50).' },
299
+ },
300
+ },
301
+ {
302
+ path: ['templates', 'list'],
303
+ summary: 'List the templates this project can open (platform and organization)',
304
+ positionals: [],
305
+ options: {
306
+ owner: { type: 'string', value: '<platform|organization>', description: 'Only platform or only organization templates.' },
307
+ 'include-archived': { type: 'boolean', description: 'Include archived templates.' },
308
+ ...PAGE,
309
+ limit: { type: 'string', value: '<n>', description: 'Page size, 1-200 (default 50).' },
310
+ },
311
+ },
312
+ {
313
+ path: ['templates', 'files'],
314
+ summary: 'List one directory of a template version’s file tree (or show one entry with --stat)',
315
+ description: 'Needs a version with a file list (versions from before file lists answer conflict file_list_unavailable). File contents are not served: open a draft or test instance for that.',
316
+ positionals: [SLUG, { name: 'version', required: true }, { name: 'path' }],
317
+ options: {
318
+ stat: { type: 'boolean', description: 'Show the entry at <path> itself instead of listing it.' },
319
+ owner: { type: 'string', value: '<platform|organization>', description: 'Pick the platform template even when an organization template shadows its slug.' },
320
+ ...PAGE,
321
+ limit: { type: 'string', value: '<n>', description: 'Page size, 1-1000 (default 200).' },
322
+ },
323
+ examples: ['shard templates files python-node-browser 5 /usr/local/bin', 'shard templates files acme-dev 3 /etc/hostname --stat'],
324
+ },
325
+ {
326
+ path: ['templates', 'diff'],
327
+ summary: 'Diff two versions of a template (added, removed, changed, type_changed, metadata)',
328
+ positionals: [SLUG],
329
+ options: {
330
+ from: { type: 'string', value: '<version|base>', description: 'Older version, or "base" (the --to version’s build base). Required.' },
331
+ to: { type: 'string', value: '<version>', description: 'Newer version (required).' },
332
+ prefix: { type: 'string', value: '<path>', description: 'Only paths starting with this absolute prefix.' },
333
+ change: { type: 'string', value: '<kind>', description: 'Only added, removed, changed, type_changed or metadata.' },
334
+ owner: { type: 'string', value: '<platform|organization>', description: 'Pick the platform template even when an organization template shadows its slug.' },
335
+ ...PAGE,
336
+ limit: { type: 'string', value: '<n>', description: 'Page size, 1-1000 (default 200).' },
337
+ },
338
+ examples: ['shard templates diff acme-dev --from 2 --to 3', 'shard templates diff acme-dev --from base --to 3 --prefix /home/user'],
339
+ },
340
+ {
341
+ path: ['templates', 'draft', 'open'],
342
+ summary: 'Open the draft of an organization template (a layered workspace to edit live)',
343
+ description: 'One live draft per template. Edit it with "shard ws exec|files ... <draft key>", capture states, open test instances, then publish it as the next version.',
344
+ positionals: [SLUG],
345
+ options: {
346
+ base: { type: 'string', value: '<slug@version>', description: 'Version to start from (default: the template’s latest published version; required for a new template).' },
347
+ ...CAPS,
348
+ wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once).' },
349
+ timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m).' },
350
+ },
351
+ examples: ['shard templates draft open acme-dev --base python-node-browser@5'],
352
+ },
353
+ { path: ['templates', 'draft', 'status'], summary: 'Show a template’s draft: workspace, base version, states and live test instances', positionals: [SLUG], options: {} },
354
+ {
355
+ path: ['templates', 'draft', 'state'],
356
+ summary: 'Capture a state of the running draft (or list its states with --list)',
357
+ positionals: [SLUG],
358
+ options: {
359
+ label: { type: 'string', value: '<text>', description: 'Label of the captured state.' },
360
+ list: { type: 'boolean', description: 'List the draft’s states instead of capturing one.' },
361
+ ...WAIT,
362
+ },
363
+ },
364
+ {
365
+ path: ['templates', 'draft', 'test'],
366
+ summary: 'Open a test instance of a draft state (a disposable session workspace), or list them with --list',
367
+ description: 'Its writes never reach the draft. It ends with "shard ws close <key>", when idle, or when the draft is discarded.',
368
+ positionals: [SLUG],
369
+ options: {
370
+ state: { type: 'string', value: '<state-id>', description: 'Draft state to test (default: a fresh capture of the running draft).' },
371
+ 'instance-key': { type: 'string', value: '<key>', description: 'Workspace key of the test instance (default sf:test:<slug>:<random>).' },
372
+ list: { type: 'boolean', description: 'List the draft’s test instances instead of opening one.' },
373
+ 'include-ended': { type: 'boolean', description: 'With --list: include ended test instances.' },
374
+ ...CAPS,
375
+ wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once).' },
376
+ timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m).' },
377
+ },
378
+ },
379
+ {
380
+ path: ['templates', 'draft', 'publish'],
381
+ summary: 'Publish the draft as the template’s next version',
382
+ description: 'Refused (conflict draft_stale) when the template got a version from elsewhere since the draft was opened. The draft stays open afterwards.',
383
+ positionals: [SLUG],
384
+ options: { state: { type: 'string', value: '<state-id>', description: 'State to publish (default: the draft’s current state).' }, ...SAVE },
385
+ },
386
+ {
387
+ path: ['templates', 'draft', 'discard'],
388
+ summary: 'Discard the draft (it is deleted and its live test instances end)',
389
+ positionals: [SLUG],
390
+ options: { yes: { type: 'boolean', description: 'Required: confirm the discard.' }, ...WAIT },
391
+ },
135
392
  {
136
393
  path: ['operations', 'wait'],
137
394
  summary: 'Wait for an operation to finish (exit 0 succeeded, 6 failed/canceled, 5 timeout)',
@@ -143,8 +400,10 @@ const GROUPS = {
143
400
  workspaces: 'Open, inspect and manage workspaces',
144
401
  files: 'Read, write and list files in a workspace',
145
402
  operations: 'Inspect and wait for lifecycle operations',
403
+ secrets: 'Manage secrets (values are write-only) and bind them to workspaces',
404
+ templates: 'Browse templates (file tree, diff) and develop organization templates in a draft',
146
405
  };
147
- const ALIASES = { ws: 'workspaces', workspace: 'workspaces', ops: 'operations', operation: 'operations', file: 'files' };
406
+ const ALIASES = { ws: 'workspaces', workspace: 'workspaces', ops: 'operations', operation: 'operations', file: 'files', secret: 'secrets', template: 'templates' };
148
407
  /** Canonical command words: aliases resolved, `workspaces files|operations` folded to the top-level group. */
149
408
  export function normalizeWords(words) {
150
409
  const w = words.map((x) => ALIASES[x] ?? x);
@@ -202,6 +461,8 @@ export function parseEnvPairs(pairs) {
202
461
  return out;
203
462
  }
204
463
  export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
464
+ /** Secret names (environment-variable safe; the API's SECRET_NAME_PATTERN). */
465
+ export const SECRET_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/;
205
466
  const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
206
467
  /** Validates the API base URL: https, or http only to a loopback address (the key is a bearer credential). */
207
468
  export function resolveApiUrl(flag, env) {
@@ -220,8 +481,37 @@ export function resolveApiUrl(flag, env) {
220
481
  throw new UsageError('API URL must not contain credentials, a query or a fragment');
221
482
  return u.toString().replace(/\/+$/, '');
222
483
  }
223
- const FORBIDDEN_KEY_FLAG = /^--?(api-?key|apikey|key|token|secret)(=.*)?$/i;
224
- const GLOBAL_VALUE_FLAGS = new Set(['--api-url', '--agent-label']);
484
+ export const DEFAULT_WAKE_TIMEOUT_MS = 120_000;
485
+ const MAX_WAKE_TIMEOUT_MS = 7 * 24 * 3600 * 1000;
486
+ const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
487
+ const FALSY = new Set(['', '0', 'false', 'no', 'off']);
488
+ /** Wake on use: --wake / --no-wake, else SHARDFLUX_NO_WAKE (1/true/yes/on disables), else on. */
489
+ export function resolveWake(flag, noWakeEnv) {
490
+ if (flag !== undefined)
491
+ return flag;
492
+ const v = (noWakeEnv ?? '').trim().toLowerCase();
493
+ if (TRUTHY.has(v))
494
+ return false;
495
+ if (FALSY.has(v))
496
+ return true;
497
+ throw new UsageError(`SHARDFLUX_NO_WAKE must be 1 (or true) to turn wake on use off, or 0/empty (got "${noWakeEnv}")`);
498
+ }
499
+ /** The wake/transition budget per call in ms: --wake-timeout, else SHARDFLUX_WAKE_TIMEOUT_MS, else 120000. */
500
+ export function resolveWakeTimeout(flag, env) {
501
+ if (flag !== undefined)
502
+ return parsePositiveInt(flag, 'wake-timeout', MAX_WAKE_TIMEOUT_MS);
503
+ const v = env?.trim();
504
+ if (!v)
505
+ return DEFAULT_WAKE_TIMEOUT_MS;
506
+ if (!/^\d+$/.test(v) || Number(v) < 1 || Number(v) > MAX_WAKE_TIMEOUT_MS) {
507
+ throw new UsageError(`SHARDFLUX_WAKE_TIMEOUT_MS must be an integer number of milliseconds between 1 and ${MAX_WAKE_TIMEOUT_MS} (got "${env}")`);
508
+ }
509
+ return Number(v);
510
+ }
511
+ const FORBIDDEN_KEY_FLAG = /^--?(api-?key|apikey|key|token|secret|value|secret-value|password)(=.*)?$/i;
512
+ /** `--secret NAME` binds a secret by name on `workspaces open`; anything else shaped like --secret is refused. */
513
+ const SECRET_NAME_FLAG = /^--secret(?:=(.*))?$/;
514
+ const GLOBAL_VALUE_FLAGS = new Set(['--api-url', '--agent-label', '--wake-timeout']);
225
515
  function toParseArgsOptions(opts) {
226
516
  const out = {};
227
517
  for (const [name, o] of Object.entries(opts))
@@ -235,10 +525,30 @@ function toParseArgsOptions(opts) {
235
525
  export function parseCommandLine(argv) {
236
526
  const terminator = argv.indexOf('--');
237
527
  const head = terminator < 0 ? argv : argv.slice(0, terminator);
238
- for (const a of head) {
239
- if (FORBIDDEN_KEY_FLAG.test(a)) {
240
- throw new UsageError('shard reads the API key only from the SHARDFLUX_API_KEY environment variable; never pass it on the command line (shell history and process listings would expose it)');
528
+ const leading = [];
529
+ for (let i = 0; i < head.length && leading.length < 2; i += 1) {
530
+ const a = head[i];
531
+ if (!a.startsWith('-'))
532
+ leading.push(a);
533
+ else if (GLOBAL_VALUE_FLAGS.has(a))
534
+ i += 1;
535
+ }
536
+ const opensWorkspace = key(normalizeWords(leading)) === 'workspaces open';
537
+ for (let i = 0; i < head.length; i += 1) {
538
+ const a = head[i];
539
+ if (!FORBIDDEN_KEY_FLAG.test(a))
540
+ continue;
541
+ const named = SECRET_NAME_FLAG.exec(a);
542
+ if (named && opensWorkspace) {
543
+ const name = named[1] ?? head[i + 1];
544
+ if (name !== undefined && SECRET_NAME.test(name))
545
+ continue;
546
+ throw new UsageError('--secret takes a secret NAME (^[A-Z_][A-Z0-9_]*$) to bind, never a value; create the secret with "shard secrets create NAME" (value on standard input)');
547
+ }
548
+ if (/^--?(value|secret-value|password)(=.*)?$/i.test(a)) {
549
+ throw new UsageError('shard never accepts secret values on the command line (shell history and process listings would expose them); pipe the value on standard input or use --from-file');
241
550
  }
551
+ throw new UsageError('shard reads the API key only from the SHARDFLUX_API_KEY environment variable; never pass it on the command line (shell history and process listings would expose it)');
242
552
  }
243
553
  // Pick the command words (the leading non-option words that form a known command path).
244
554
  const words = [];
@@ -323,6 +633,8 @@ export function parseCommandLine(argv) {
323
633
  apiUrl: typeof values['api-url'] === 'string' ? values['api-url'] : undefined,
324
634
  json: values.json === true,
325
635
  agentLabel: typeof values['agent-label'] === 'string' ? values['agent-label'] : undefined,
636
+ wake: typeof values.wake === 'boolean' ? values.wake : undefined,
637
+ wakeTimeout: typeof values['wake-timeout'] === 'string' ? values['wake-timeout'] : undefined,
326
638
  },
327
639
  };
328
640
  }
@@ -380,9 +692,11 @@ export function topHelp() {
380
692
  ...optionLines(GLOBAL_OPTIONS),
381
693
  '',
382
694
  'Environment:',
383
- ' SHARDFLUX_API_KEY project API key (sfk_...), required; never pass it as an argument',
384
- ' SHARDFLUX_API_URL API base URL (default https://api.shardflux.dev)',
385
- ' SHARDFLUX_AGENT_LABEL attribution label for tool tokens (default cli)',
695
+ ' SHARDFLUX_API_KEY project API key (sfk_...), required; never pass it as an argument',
696
+ ' SHARDFLUX_API_URL API base URL (default https://api.shardflux.dev)',
697
+ ' SHARDFLUX_AGENT_LABEL attribution label for tool tokens (default cli)',
698
+ ' SHARDFLUX_NO_WAKE 1: do not resume a suspended workspace on use (same as --no-wake)',
699
+ ' SHARDFLUX_WAKE_TIMEOUT_MS longest wait in ms for a workspace to wake or finish a transition (default 120000; --wake-timeout overrides)',
386
700
  '',
387
701
  'Exit codes:',
388
702
  ...EXIT_CODE_HELP,
@@ -16,11 +16,19 @@ export interface Ctx {
16
16
  json: boolean;
17
17
  apiUrl: string;
18
18
  agentLabel: string;
19
+ /** Wake a suspended workspace on use (--no-wake / SHARDFLUX_NO_WAKE=1 turn it off). */
20
+ wake: boolean;
21
+ /** Bound on lifecycle waits per cell call, wakes included (--wake-timeout / SHARDFLUX_WAKE_TIMEOUT_MS). */
22
+ wakeTimeoutMs: number;
19
23
  signal: AbortSignal;
20
24
  cloud(): Shardflux;
21
25
  }
22
26
  export type Handler = (ctx: Ctx, values: Values, positionals: string[]) => Promise<number>;
23
- /** `<id|key>`: a UUID is tried as an id first; anything else (or an unknown UUID) as an exact workspace key. */
27
+ /**
28
+ * `<id|key>`: a UUID is tried as an id first; anything else (or an unknown UUID) as an exact workspace key, across every
29
+ * lifetime and purpose (sessions, drafts and test instances are hidden from the default list), preferring the live
30
+ * workspace over tombstones of ended sessions that had the same key (contracts §19.11).
31
+ */
24
32
  export declare function resolveWorkspace(ctx: Ctx, ref: string): Promise<Workspace>;
25
33
  /** The exit code `shard exec` returns for a finished command. */
26
34
  export declare function execExitCode(r: Pick<RunResult, 'exitCode' | 'termSignal' | 'timedOut'>): number;