@astrale-os/cli 1.0.0-beta.26 → 1.0.0-beta.28

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 (73) hide show
  1. package/README.md +31 -5
  2. package/dist/astrale.js +1201 -358
  3. package/package.json +3 -2
  4. package/src/commands/__tests__/domain-install-operation.test.ts +23 -0
  5. package/src/commands/__tests__/install-identity-override.test.ts +3 -3
  6. package/src/commands/domain/install.ts +19 -11
  7. package/src/commands/get.ts +1 -1
  8. package/src/commands/identity/register.ts +3 -4
  9. package/src/commands/logs.ts +2 -5
  10. package/src/commands/session/analyze.ts +26 -4
  11. package/src/commands/update.ts +61 -40
  12. package/src/lib/__tests__/skills.test.ts +809 -0
  13. package/src/lib/skills/lock.ts +117 -0
  14. package/src/lib/skills/sync.ts +814 -0
  15. package/src/lib/skills.ts +1 -52
  16. package/src/program/__tests__/program.test.ts +1 -1
  17. package/src/setup/steps/skills.ts +40 -38
  18. package/src/telemetry/__tests__/retention.test.ts +180 -0
  19. package/src/telemetry/__tests__/settings.test.ts +102 -0
  20. package/src/telemetry/__tests__/store-scan.test.ts +128 -0
  21. package/src/telemetry/__tests__/trigger.test.ts +33 -4
  22. package/src/telemetry/recorder.ts +6 -3
  23. package/src/telemetry/retention.ts +129 -0
  24. package/src/telemetry/session.ts +18 -12
  25. package/src/telemetry/settings.ts +64 -11
  26. package/src/telemetry/store.ts +87 -9
  27. package/src/telemetry/trigger.ts +16 -28
  28. package/studio/client/dist/assets/index-BFVFs_8x.js +81 -0
  29. package/studio/client/dist/assets/index-DuB9iUdu.css +2 -0
  30. package/studio/client/dist/assets/schema-studio--a0kX3QU.css +1 -0
  31. package/studio/client/dist/assets/schema-studio-DH6oEWME.js +8 -0
  32. package/studio/client/dist/index.html +3 -3
  33. package/studio/server/agent/prompts/anchors.test.ts +17 -4
  34. package/studio/server/agent/prompts/anchors.ts +3 -2
  35. package/studio/server/agent/prompts/system.test.ts +2 -3
  36. package/studio/server/agent/prompts/system.ts +3 -3
  37. package/studio/server/agent/run/preparation.ts +1 -1
  38. package/studio/server/api/context.ts +1 -1
  39. package/studio/server/api/deployment.ts +1 -1
  40. package/studio/server/api/views.ts +2 -2
  41. package/studio/server/api/workspace.ts +1 -1
  42. package/studio/server/cache.test.ts +3 -8
  43. package/studio/server/cache.ts +4 -45
  44. package/studio/server/cli-consumers.test.ts +31 -2
  45. package/studio/server/handoff/copy.ts +1 -1
  46. package/studio/server/introspect/canonical-schema.test.ts +154 -128
  47. package/studio/server/introspect/canonical-schema.ts +183 -302
  48. package/studio/server/introspect/core.ts +14 -21
  49. package/studio/server/introspect/extractor.ts +11 -15
  50. package/studio/server/introspect/overlay-tsmorph.test.ts +1 -5
  51. package/studio/server/introspect/overlay-tsmorph.ts +0 -1
  52. package/studio/server/introspect/overlay.ts +3 -24
  53. package/studio/server/introspect/revision.test.ts +24 -28
  54. package/studio/server/introspect/revision.ts +10 -21
  55. package/studio/server/introspect/runtime.test.ts +14 -14
  56. package/studio/server/introspect/runtime.ts +1 -46
  57. package/studio/server/lifecycle.ts +4 -1
  58. package/studio/server/state/documents.test.ts +69 -0
  59. package/studio/server/state/documents.ts +57 -10
  60. package/studio/server/workspace/updates.ts +23 -1
  61. package/studio/shared/contracts/runtime.ts +4 -0
  62. package/studio/shared/contracts/schema.ts +10 -26
  63. package/studio/shared/contracts/surface.test.ts +2 -2
  64. package/studio/shared/contracts/workspace.ts +3 -0
  65. package/studio/shared/schema/identity.ts +0 -1
  66. package/tsconfig.json +1 -1
  67. package/studio/client/dist/assets/index-C0FAnwNw.css +0 -2
  68. package/studio/client/dist/assets/index-OeJK1TjG.js +0 -81
  69. package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +0 -1
  70. package/studio/client/dist/assets/schema-studio-DRdfu_cQ.js +0 -8
  71. package/studio/server/introspect/core-extractor.ts +0 -30
  72. package/studio/server/introspect/overlay.test.ts +0 -44
  73. package/studio/server/introspect/source-overlay/annotations.ts +0 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "1.0.0-beta.26",
3
+ "version": "1.0.0-beta.28",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
@@ -68,8 +68,8 @@
68
68
  "@astrale/commitlint-config": "npm:@jsr/astrale__commitlint-config@~2.0.1",
69
69
  "@commitlint/cli": "21.2.2",
70
70
  "@commitlint/config-conventional": "21.2.2",
71
- "@types/bun": "^1.1.16",
72
71
  "@types/node": "^22.0.0",
72
+ "bun-types": "1.4.0",
73
73
  "husky": "~9.1.7",
74
74
  "lint-staged": "17.3.0",
75
75
  "msgpackr": "^2.0.5",
@@ -105,6 +105,7 @@
105
105
  "format": "pnpm exec oxfmt --write .",
106
106
  "format:check": "pnpm exec oxfmt --check .",
107
107
  "test": "node scripts/qualification/source-boundary.mjs --target && bun test src studio/client/src studio/server studio/shared && node --test scripts/*.test.mjs",
108
+ "test:skills-e2e": "node scripts/qualification/skills-update-e2e.mjs",
108
109
  "test:studio": "pnpm --dir studio test",
109
110
  "test:watch": "bun test --watch src"
110
111
  }
@@ -93,6 +93,29 @@ describe('direct domain install operation identity', () => {
93
93
  ])
94
94
  })
95
95
 
96
+ test('keeps identity-override consent warnings out of machine output', async () => {
97
+ globalThis.fetch = mock(async () => publicationResponse()) as unknown as typeof fetch
98
+ const originalError = console.error
99
+ const warnings: unknown[][] = []
100
+ console.error = (...args: unknown[]) => {
101
+ warnings.push(args)
102
+ }
103
+ try {
104
+ await installDirect(
105
+ 'https://alias.test',
106
+ { direct: true, json: true, allowIdentityOverride: true },
107
+ {
108
+ createOperationId: () => GENERATED,
109
+ runKernelCommand: async () => undefined,
110
+ },
111
+ )
112
+ } finally {
113
+ console.error = originalError
114
+ }
115
+
116
+ expect(warnings).toEqual([])
117
+ })
118
+
96
119
  test('rejects a non-UUID operation before metadata or Kernel transport', async () => {
97
120
  const originalExit = process.exit
98
121
  const originalWrite = process.stderr.write
@@ -54,11 +54,11 @@ describe('declared-origin Publication probe', () => {
54
54
  expect(await probeDeclaredOrigin(url)).toBe('crm.acme.dev')
55
55
  })
56
56
 
57
- test('does not accept the removed pre-Kernel-V2 domainName shape', async () => {
58
- const legacy = servePublication(() =>
57
+ test('does not accept a Publication without the canonical origin field', async () => {
58
+ const invalid = servePublication(() =>
59
59
  Response.json({ iss: 'https://x', domainName: 'crm.acme.dev' }),
60
60
  )
61
- expect(await probeDeclaredOrigin(legacy)).toBeUndefined()
61
+ expect(await probeDeclaredOrigin(invalid)).toBeUndefined()
62
62
  })
63
63
 
64
64
  test('degrades to undefined on invalid Publication, non-200, bad JSON, or dead host', async () => {
@@ -426,6 +426,7 @@ export async function installDirect(
426
426
  target,
427
427
  host,
428
428
  opts.allowIdentityOverride ?? false,
429
+ isMachine(opts),
429
430
  )
430
431
  } catch (e) {
431
432
  fatal(e, opts)
@@ -451,11 +452,13 @@ export async function installDirect(
451
452
  // aliases the host and the pre-install gate never consented to THAT
452
453
  // origin (lying or unavailable Publication), say so loudly after the fact.
453
454
  if (isIdentityOverride(installed.origin, host) && installed.origin !== consentedOrigin) {
454
- log.warn(
455
- `Installed origin "${installed.origin}" differs from the serving host "${host}" ` +
456
- `and was not confirmed before install (the worker Publication was unavailable). ` +
457
- `Every ${installed.origin}/* call on this instance now routes to ${host}.`,
458
- )
455
+ if (!isMachine(fmtOpts)) {
456
+ log.warn(
457
+ `Installed origin "${installed.origin}" differs from the serving host "${host}" ` +
458
+ `and was not confirmed before install (the worker Publication was unavailable). ` +
459
+ `Every ${installed.origin}/* call on this instance now routes to ${host}.`,
460
+ )
461
+ }
459
462
  }
460
463
  },
461
464
  })
@@ -512,20 +515,25 @@ async function ensureIdentityOverrideConsent(
512
515
  url: string,
513
516
  host: string,
514
517
  allow: boolean,
518
+ machine: boolean,
515
519
  ): Promise<string | undefined> {
516
520
  const origin = await probeDeclaredOrigin(url)
517
521
  if (origin === undefined) {
518
- log.warn(
519
- `Could not read a declared origin from ` +
520
- `${new URL('/.well-known/astrale/domain.json', url).href} ` +
521
- `skipping the pre-install identity check (the installed origin is verified after install).`,
522
- )
522
+ if (!machine) {
523
+ log.warn(
524
+ `Could not read a declared origin from ` +
525
+ `${new URL('/.well-known/astrale/domain.json', url).href} ` +
526
+ `skipping the pre-install identity check (the installed origin is verified after install).`,
527
+ )
528
+ }
523
529
  return undefined
524
530
  }
525
531
  if (!isIdentityOverride(origin, host)) return undefined
526
532
 
527
533
  if (allow) {
528
- log.warn(`Identity override consented via --allow-identity-override: ${origin} ← ${host}`)
534
+ if (!machine) {
535
+ log.warn(`Identity override consented via --allow-identity-override: ${origin} ← ${host}`)
536
+ }
529
537
  return origin
530
538
  }
531
539
 
@@ -46,7 +46,7 @@ export default {
46
46
  description: 'Get one canonical node by Path or ID',
47
47
  afterHelpText: `
48
48
  Behavior:
49
- Resolves one exact Kernel V2 Path and prints the canonical Node
49
+ Resolves one exact Kernel Path and prints the canonical Node
50
50
  { id, class, props }. Missing and authorization-masked nodes remain
51
51
  intentionally indistinguishable. @self is expanded before dispatch.
52
52
 
@@ -45,10 +45,9 @@ Behavior:
45
45
  its result and stores the same target-bound registration. The callable owns
46
46
  authorization; the CLI never receives installed Domain authority.
47
47
 
48
- Kernel V2 Nodes have opaque identity and no caller-assigned storage path, so
49
- the historical --path option no longer exists. The CLI binds the key proof
50
- to the exact provision fingerprint and target Kernel audience, then caches
51
- the returned (issuer, subject) for subsequent calls.
48
+ The Kernel assigns each Node ID; callers do not choose a storage path. The
49
+ CLI binds the key proof to the exact provision fingerprint and target Kernel
50
+ audience, then caches the returned (issuer, subject) for subsequent calls.
52
51
 
53
52
  Example:
54
53
  $ astrale identity register alice --class /:accounts.example:class.User \
@@ -341,11 +341,8 @@ Behavior:
341
341
  Calls the public Kernel journal syscall and emits its { records, cursor }
342
342
  page. Topic selection is exact or prefix-based; cursors and timestamps are
343
343
  opaque strings owned by the journal backend. --follow reuses one Client Session
344
- and advances only with the returned cursor. Machine follow output is NDJSON:
345
- one complete admitted record per line; YAML follow is unsupported.
346
-
347
- Historical event-glob lowering and the application-specific services-domain
348
- log buffer are not part of the Kernel V2 journal contract.
344
+ and advances only with the returned cursor. With --json, follow output is
345
+ NDJSON with one complete admitted record per line; YAML follow is unsupported.
349
346
 
350
347
  Examples:
351
348
  $ astrale logs -i staging --limit 50
@@ -4,13 +4,23 @@ import type { CommandDefinition } from '../../program/index'
4
4
 
5
5
  import { log } from '../../lib/log'
6
6
  import { analyzeSession } from '../../telemetry/analyze'
7
- import { listSessions } from '../../telemetry/store'
7
+ import { sweepStore } from '../../telemetry/retention'
8
+ import { scanSessions } from '../../telemetry/store'
8
9
  import { restampLock, releaseLock } from '../../telemetry/trigger'
9
10
 
10
11
  export default {
11
12
  name: 'analyze',
12
13
  description: 'Analyze a recorded session for DX frictions (dry-run: writes report.md)',
13
- arguments: [{ name: 'id', description: 'Session id (default: most recent closed, unanalyzed)' }],
14
+ arguments: [
15
+ // Optional: the action resolves the most recent closed, unanalyzed session
16
+ // when no id is given, which is how a human is meant to invoke this. The
17
+ // opportunistic trigger always passes an explicit id.
18
+ {
19
+ name: 'id',
20
+ description: 'Session id (default: most recent closed, unanalyzed)',
21
+ required: false,
22
+ },
23
+ ],
14
24
  options: [
15
25
  { flags: '--file', description: 'File cleared findings as issues on the admin tracker' },
16
26
  { flags: '--model <model>', description: 'Model for the analyzer pass' },
@@ -22,10 +32,10 @@ export default {
22
32
  opts: { file?: boolean; model?: string; force?: boolean; auto?: boolean },
23
33
  ) => {
24
34
  if (opts.auto) restampLock()
35
+ let target = id
25
36
  try {
26
- let target = id
27
37
  if (!target) {
28
- const candidate = listSessions().find((s) => s.closed && (opts.force || !s.analyzed))
38
+ const candidate = scanSessions().find((s) => s.closed && (opts.force || !s.analyzed))
29
39
  if (!candidate) {
30
40
  if (!opts.auto) log.dim('Nothing to analyze — no closed, unanalyzed session.')
31
41
  return
@@ -45,6 +55,18 @@ export default {
45
55
  }
46
56
  } finally {
47
57
  if (opts.auto) releaseLock()
58
+ // The size bound is enforced here, not on the CLI's critical path:
59
+ // measuring every file of every session is only affordable in this
60
+ // process, and this is precisely where the store just grew. The session
61
+ // just analyzed is protected — it is the freshest evidence on disk.
62
+ try {
63
+ const swept = sweepStore({ protect: new Set(target === undefined ? [] : [target]) })
64
+ if (!opts.auto && swept.removed.length > 0) {
65
+ log.dim(`retention: removed ${swept.removed.length} session(s)`)
66
+ }
67
+ } catch {
68
+ /* retention must never fail the command that triggered it */
69
+ }
48
70
  }
49
71
  },
50
72
  } satisfies CommandDefinition
@@ -11,7 +11,12 @@ import {
11
11
  inDomainProject,
12
12
  type SdkOutdated,
13
13
  } from '../lib/sdk-deps'
14
- import { ASTRALE_CLI_SKILL, detectSkill, installSkills, SKILL_INSTALL_HINT } from '../lib/skills'
14
+ import {
15
+ checkAstraleSkills,
16
+ type SkillApplyResult,
17
+ type SkillCheckResult,
18
+ syncAstraleSkills,
19
+ } from '../lib/skills'
15
20
  import { DEFAULT_UPDATE_CHANNEL, packageManagedUpdateError, updateAstrale } from '../lib/update'
16
21
 
17
22
  type UpdateOpts = RawOutputOpts & {
@@ -23,26 +28,26 @@ type UpdateOpts = RawOutputOpts & {
23
28
  yes?: boolean
24
29
  }
25
30
 
26
- /**
27
- * Refresh the astrale agent skills alongside the binary. Delegates to the same
28
- * `npx skills add` installer `astrale setup` uses; re-running it updates an
29
- * existing install to the latest published SKILL.md.
30
- *
31
- * Only refreshes skills the user already has — `astrale update` keeps an existing
32
- * install current, it does not foist skills on a non-agent user; fresh installs
33
- * go through `astrale setup`. Best-effort: a failure here never fails the update
34
- * (the binary is already swapped), so we warn and move on.
35
- */
36
- async function refreshSkills(): Promise<void> {
37
- if (!detectSkill(ASTRALE_CLI_SKILL).installed) {
38
- log.dim(` Agent skills not installed — get them with: ${SKILL_INSTALL_HINT}`)
39
- return
40
- }
41
- log.step(`Refreshing the astrale agent skills — ${SKILL_INSTALL_HINT}`)
42
- if (await installSkills()) {
43
- log.success('astrale agent skills up to date')
44
- } else {
45
- log.warn(`Skill refresh did not complete run it later: ${SKILL_INSTALL_HINT}`)
31
+ async function refreshSkills(): Promise<SkillApplyResult> {
32
+ log.step('Ensuring Astrale agent skills are current and healthy')
33
+ const result = await syncAstraleSkills()
34
+ if (result.status === 'unchanged') log.success('Astrale skills already up to date')
35
+ else if (result.status === 'installed') log.success('Astrale skills installed')
36
+ else if (result.status === 'updated') log.success('Astrale skills updated')
37
+ else if (result.status === 'repaired') log.success('Astrale skills repaired and updated')
38
+ return result
39
+ }
40
+
41
+ function skillCheckStale(skills: SkillCheckResult): boolean {
42
+ return skills.status === 'update-available' || skills.status === 'repair-needed'
43
+ }
44
+
45
+ function printSkillCheck(skills: SkillCheckResult): void {
46
+ if (skills.status === 'current') log.success('Astrale skills are up to date')
47
+ else if (skills.status === 'update-available') log.info('Astrale skills update available')
48
+ else if (skills.status === 'repair-needed') log.warn('Astrale skills need repair')
49
+ else if (skills.status === 'unavailable') {
50
+ log.warn(`Could not verify Astrale skills${skills.error ? `: ${skills.error}` : ''}`)
46
51
  }
47
52
  }
48
53
 
@@ -106,9 +111,8 @@ async function refreshSdkDeps(check: boolean, assumeYes = false): Promise<boolea
106
111
  * domain-studio polls this on load to drive its "update available" badge. It is
107
112
  * unified and NON-THROWING: an explicit package-managed result uses npm release
108
113
  * identity, while script-install failures remain script failures in `error`
109
- * instead of being recategorized. The SDK axis is already best-effort. Skills are
110
- * intentionally absent they ride along with `astrale update`, so a stale CLI or
111
- * SDK is the only signal worth surfacing.
114
+ * instead of being recategorized. The SDK axis is already best-effort. Skills
115
+ * report meaningful health/freshness states without exposing installer metadata.
112
116
  */
113
117
  export type StaleReport = {
114
118
  stale: boolean
@@ -120,6 +124,7 @@ export type StaleReport = {
120
124
  channel?: string
121
125
  error?: string
122
126
  }
127
+ skills: SkillCheckResult
123
128
  sdk: { stale: boolean; inProject: boolean; outdated: SdkOutdated[] }
124
129
  }
125
130
 
@@ -218,7 +223,7 @@ export default {
218
223
  default: DEFAULT_UPDATE_CHANNEL,
219
224
  },
220
225
  { flags: '--version <version>', description: 'Update to an exact version tag' },
221
- { flags: '--no-skills', description: 'Skip refreshing the astrale agent skills' },
226
+ { flags: '--no-skills', description: 'Skip ensuring the Astrale agent skills' },
222
227
  { flags: '--no-deps', description: 'Skip checking @astrale-os SDK dependency versions' },
223
228
  {
224
229
  flags: '--yes',
@@ -231,23 +236,22 @@ Behavior:
231
236
  Keeps three things current, in order. (1) The CLI binary: updates official
232
237
  script installs only — if Astrale was installed by another package manager this
233
238
  command refuses so that manager stays in charge; downloads are checksum-verified
234
- before the binary is replaced. (2) The agent skills: if the astrale skills (cli +
235
- domain) are already installed, refreshes them to the latest by delegating to
236
- "npx skills add astrale-os/cli -g" the same installer "astrale setup" uses;
237
- fresh installs go through "astrale setup". (3) SDK deps: inside a pnpm domain
239
+ before the binary is replaced. (2) The Astrale agent skills: installs every
240
+ top-level skill published from astrale-os/cli main, updates healthy older
241
+ installs, repairs inconsistent installs, and verifies the result before
242
+ reporting success. (3) SDK deps: inside a pnpm domain
238
243
  project, proposes any @astrale-os/* dependency with a newer release and, on
239
244
  confirm, runs "pnpm update --latest --lockfile-only" (updates package.json AND
240
245
  the lockfile, honoring your registry + supply-chain age policy; run "pnpm
241
246
  install" to materialize).
242
247
 
243
248
  The default release channel is beta; --channel overrides it for one run.
244
- --check is a dry run (binary + SDK deps; exit 10 if anything is available) and
249
+ --check is a dry run (binary + skills + SDK deps; exit 10 if anything is available) and
245
250
  never writes. With --json it emits a unified staleness report
246
- ({ stale, cli, sdk }) for tooling non-throwing, skills omitted (they ride
247
- along with an update). --yes applies all three non-interactively (no prompts) and
251
+ ({ stale, cli, skills, sdk }) for tooling. --yes applies all three non-interactively and
248
252
  is resilient — a binary that can't self-update (package-managed) warns but never
249
- blocks the skills/deps steps; this is what domain-studio's "Update now" runs.
250
- --no-skills / --no-deps skip those steps in a real run.
253
+ blocks the skills/deps steps; a skill failure fails the command rather than
254
+ claiming a partial success. --no-skills / --no-deps explicitly skip those axes.
251
255
 
252
256
  Examples:
253
257
  $ astrale update
@@ -262,11 +266,18 @@ Examples:
262
266
  try {
263
267
  // Tooling path: a machine-readable `--check` (e.g. domain-studio's update
264
268
  // badge polling `astrale update --check --json`) gets a unified,
265
- // non-throwing staleness report — CLI + SDK only — and exits.
269
+ // non-throwing staleness report and exits.
266
270
  if (opts.check && isMachine(opts)) {
267
271
  const cli = await cliStale(opts)
272
+ const skills: SkillCheckResult =
273
+ opts.skills === false ? { status: 'skipped' } : await checkAstraleSkills()
268
274
  const sdk = await sdkStale()
269
- const report: StaleReport = { stale: cli.stale || sdk.stale, cli, sdk }
275
+ const report: StaleReport = {
276
+ stale: cli.stale || skillCheckStale(skills) || sdk.stale,
277
+ cli,
278
+ skills,
279
+ sdk,
280
+ }
270
281
  output(report, opts)
271
282
  if (report.stale) process.exitCode = 10
272
283
  return
@@ -316,9 +327,19 @@ Examples:
316
327
  log.warn(`CLI self-update skipped: ${error.message}`)
317
328
  }
318
329
 
319
- // Axis B — agent skills. Keep an existing install current with the CLI.
320
- // Skip on --check (a dry run) and when the user opted out with --no-skills.
321
- if (!opts.check && opts.skills !== false) await refreshSkills()
330
+ // Axis B — agent skills. A successful real update guarantees a verified
331
+ // latest cohort; --check remains read-only and reports its status.
332
+ if (opts.skills !== false) {
333
+ if (opts.check) {
334
+ const skills = await checkAstraleSkills()
335
+ printSkillCheck(skills)
336
+ if (skillCheckStale(skills)) anyAvailable = true
337
+ } else {
338
+ await refreshSkills()
339
+ }
340
+ } else if (!opts.check) {
341
+ log.dim(' Astrale skills skipped (--no-skills)')
342
+ }
322
343
 
323
344
  // Axis C — first-party @astrale-os/* deps in the current domain project.
324
345
  // Runs on --check too (reports availability); --yes applies without a prompt.
@@ -328,7 +349,7 @@ Examples:
328
349
 
329
350
  if (opts.check && anyAvailable) process.exitCode = 10
330
351
  } catch (e) {
331
- fatal(e)
352
+ fatal(e, opts)
332
353
  }
333
354
  },
334
355
  } satisfies CommandDefinition