@astrale-os/cli 0.8.1-alpha.2 → 0.8.1-alpha.4

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.
@@ -66,4 +66,27 @@ describe('acceptJournalPage', () => {
66
66
  expect(() => acceptJournalPage({ records: [{}] })).toThrow('record 0')
67
67
  expect(() => acceptJournalPage({ records: [], cursor: 7 })).toThrow('cursor')
68
68
  })
69
+
70
+ test('admits journal v2 records that use occurredAt instead of timestamp', () => {
71
+ const page = acceptJournalPage({
72
+ records: [
73
+ {
74
+ sequence: 10241,
75
+ topic: 'function.invoke',
76
+ occurredAt: '2026-08-19T16:51:10.049Z',
77
+ committedAt: '2026-08-19T16:51:10.070Z',
78
+ payload: { outcome: 'rejected' },
79
+ correlation: { invocationId: 'cf862a64-3aa1-4343-ba86-f9b516c4ff95' },
80
+ },
81
+ ],
82
+ })
83
+ expect(page.records[0]).toMatchObject({
84
+ sequence: 10241,
85
+ topic: 'function.invoke',
86
+ timestamp: '2026-08-19T16:51:10.049Z',
87
+ occurredAt: '2026-08-19T16:51:10.049Z',
88
+ committedAt: '2026-08-19T16:51:10.070Z',
89
+ correlationId: 'cf862a64-3aa1-4343-ba86-f9b516c4ff95',
90
+ })
91
+ })
69
92
  })
@@ -218,18 +218,6 @@ describe('get command', () => {
218
218
  })
219
219
  })
220
220
 
221
- describe('ls command', () => {
222
- /** @evidence TEST-CLI-LS-REQUIRES-EXACT-EDGE-BEFORE-CONNECTION */
223
- test('rejects a generic child listing before opening the command connection', async () => {
224
- const { lsCommand } = await import('../ls')
225
-
226
- await expect(lsCommand('@node', { json: true })).rejects.toEqual(new ExitError(1))
227
-
228
- expect(runKernelCommandMock).not.toHaveBeenCalled()
229
- expect(errors.join('\n')).toContain('Kernel V2 has no universal child relation')
230
- })
231
- })
232
-
233
221
  describe('mutate command', () => {
234
222
  /** @evidence TEST-CLI-MUTATE-DISPATCHES-CANONICAL-V3 */
235
223
  test('admits authoring input and dispatches one canonical Mutation V3 document', async () => {
@@ -0,0 +1,131 @@
1
+ import type { Path } from '@astrale-os/sdk/graph/path'
2
+
3
+ import { AstraleError } from '../errors'
4
+
5
+ type JsonRecord = Record<string, unknown>
6
+
7
+ export interface CallableDescription {
8
+ readonly path: string
9
+ readonly origin: string
10
+ readonly method?: string
11
+ readonly function?: string
12
+ readonly class?: string
13
+ readonly dispatch?: 'static' | 'instance'
14
+ readonly description?: string
15
+ readonly auth?: unknown
16
+ readonly input?: unknown
17
+ readonly output?: unknown
18
+ readonly candidates?: readonly CallableDescription[]
19
+ }
20
+
21
+ /** Resolve one callable's input/output from an installed Domain schema document. */
22
+ export function describeCallableFromSchema(
23
+ path: Path,
24
+ schema: unknown,
25
+ ): CallableDescription | undefined {
26
+ if (path.ast.anchor.kind !== 'domain') return undefined
27
+ const origin = path.ast.anchor.origin
28
+ const last = path.ast.steps.at(-1)
29
+ const document = asRecord(schema)
30
+ if (document === undefined || last === undefined) return undefined
31
+
32
+ if (last.kind === 'method') {
33
+ const owner = path.ast.steps.at(-2)
34
+ if (
35
+ owner?.kind === 'projection' &&
36
+ (owner.projection.kind === 'class' || owner.projection.kind === 'interface')
37
+ ) {
38
+ const bag = asRecord(
39
+ owner.projection.kind === 'class' ? document.classes : document.interfaces,
40
+ )
41
+ const definition = asRecord(bag?.[owner.projection.name])
42
+ const method = asRecord(asRecord(definition?.methods)?.[last.name])
43
+ if (method !== undefined) {
44
+ return Object.freeze({
45
+ path: path.raw,
46
+ origin,
47
+ class: owner.projection.name,
48
+ method: last.name,
49
+ dispatch: last.dispatch,
50
+ ...callableFields(method),
51
+ })
52
+ }
53
+ }
54
+ const matches = findNamedMethods(document, origin, path.raw, last.name, last.dispatch)
55
+ if (matches.length === 1) return matches[0]
56
+ if (matches.length > 1) {
57
+ return Object.freeze({
58
+ path: path.raw,
59
+ origin,
60
+ method: last.name,
61
+ dispatch: last.dispatch,
62
+ candidates: Object.freeze(matches),
63
+ })
64
+ }
65
+ return undefined
66
+ }
67
+
68
+ if (last.kind === 'projection' && last.projection.kind === 'function') {
69
+ const fn = asRecord(asRecord(document.functions)?.[last.projection.name])
70
+ if (fn === undefined) return undefined
71
+ return Object.freeze({
72
+ path: path.raw,
73
+ origin,
74
+ function: last.projection.name,
75
+ ...callableFields(fn),
76
+ })
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ export function missingCallableDescription(path: string): AstraleError {
82
+ return new AstraleError(
83
+ 'CALL_DESCRIBE_UNAVAILABLE',
84
+ `No callable schema is installed for ${path}.`,
85
+ 'Use a Domain-rooted Path such as /:host.astrale.ai:class.Manager:createInstance. Method Paths are not Function nodes.',
86
+ )
87
+ }
88
+
89
+ function findNamedMethods(
90
+ schema: JsonRecord,
91
+ origin: string,
92
+ path: string,
93
+ method: string,
94
+ dispatch: 'static' | 'instance',
95
+ ): CallableDescription[] {
96
+ const matches: CallableDescription[] = []
97
+ for (const bag of [schema.classes, schema.interfaces]) {
98
+ const definitions = asRecord(bag)
99
+ if (definitions === undefined) continue
100
+ for (const [className, definition] of Object.entries(definitions)) {
101
+ const methodDef = asRecord(asRecord(asRecord(definition)?.methods)?.[method])
102
+ if (methodDef === undefined) continue
103
+ matches.push(
104
+ Object.freeze({
105
+ path,
106
+ origin,
107
+ class: className,
108
+ method,
109
+ dispatch,
110
+ ...callableFields(methodDef),
111
+ }),
112
+ )
113
+ }
114
+ }
115
+ return matches
116
+ }
117
+
118
+ function callableFields(value: JsonRecord): Partial<CallableDescription> {
119
+ return {
120
+ ...(typeof value.description === 'string' ? { description: value.description } : {}),
121
+ ...(value.auth === undefined ? {} : { auth: value.auth }),
122
+ ...(value.input === undefined ? {} : { input: value.input }),
123
+ ...(value.output === undefined ? {} : { output: value.output }),
124
+ }
125
+ }
126
+
127
+ function asRecord(input: unknown): JsonRecord | undefined {
128
+ return input !== null && typeof input === 'object' && !Array.isArray(input)
129
+ ? (input as JsonRecord)
130
+ : undefined
131
+ }
@@ -8,6 +8,7 @@ import { nodeProperty } from '../graph/index'
8
8
  import { presentBinary } from '../lib/binary'
9
9
  import { log } from '../lib/log'
10
10
  import { output, present } from '../lib/output'
11
+ import { describeCallableFromSchema, missingCallableDescription } from './call-describe'
11
12
 
12
13
  type CallOpts = KernelCommandOpts & {
13
14
  data?: string
@@ -97,28 +98,20 @@ async function describeOperation(path: string, opts: CallOpts): Promise<void> {
97
98
  await runKernelCommand({
98
99
  opts,
99
100
  label: `Schema for ${path}`,
100
- // The Function node carries the schemas as props (function.get depth:0).
101
- fn: async ({ graph }) => await graph.get(Path.parse(path)),
102
- format: (node, fmtOpts) => {
103
- const input = nodeProperty(node, 'inputSchema')
104
- const outputSchema = nodeProperty(node, 'outputSchema')
105
- const schema: Record<string, unknown> = {}
106
- if (input) schema.input = tryParseJson(input)
107
- if (outputSchema) schema.output = tryParseJson(outputSchema)
108
- output(Object.keys(schema).length > 0 ? schema : node, fmtOpts)
101
+ fn: async ({ graph }) => {
102
+ const parsed = Path.parse(path)
103
+ if (parsed.ast.anchor.kind !== 'domain') {
104
+ throw missingCallableDescription(path)
105
+ }
106
+ const domain = await graph.getOrThrow(Path.parse(`/:${parsed.ast.anchor.origin}`))
107
+ const described = describeCallableFromSchema(parsed, nodeProperty(domain, 'schema'))
108
+ if (described === undefined) throw missingCallableDescription(path)
109
+ return described
109
110
  },
111
+ format: (schema, fmtOpts) => output(schema, fmtOpts),
110
112
  })
111
113
  }
112
114
 
113
- function tryParseJson(value: unknown): unknown {
114
- if (typeof value !== 'string') return value
115
- try {
116
- return JSON.parse(value)
117
- } catch {
118
- return value
119
- }
120
- }
121
-
122
115
  // ── Param parsing ───────────────────────────────────────────
123
116
 
124
117
  export async function parseParams(
@@ -221,17 +214,20 @@ Self-reference:
221
214
  (e.g. via 'astrale get @self --json'). Resolution authenticates to
222
215
  the selected Kernel and never trusts a local registration or JWT sub.
223
216
 
217
+ --describe reads the callable's input/output from the installed Domain
218
+ schema. Method Paths are not Function nodes.
219
+
224
220
  Examples:
225
- $ astrale call /:host.astrale.ai:class.KernelInstance:list
221
+ $ astrale call /:host.astrale.ai:class.Manager:createInstance --describe
226
222
  $ astrale call /:blog.acme.com:class.Author:list limit=10
227
223
  $ astrale call '@self::deactivate'
228
- $ astrale call /:shell.astrale.ai:function.search-domains --json
224
+ $ astrale call /:kernel.astrale.ai:function.journal --data '{"limit":5}' --json
229
225
  `,
230
226
  arguments: [
231
227
  {
232
228
  name: 'path',
233
229
  description:
234
- 'Operation path (e.g., /:host.astrale.ai:class.KernelInstance:list or /node::method)',
230
+ 'Operation path (e.g., /:host.astrale.ai:class.Manager:createInstance or /node::method)',
235
231
  },
236
232
  { name: 'params...', description: 'Params as key=value pairs', required: false },
237
233
  ],
@@ -20,12 +20,35 @@ import { isMachine, output } from '../../lib/output'
20
20
  import { confirmWithInput, promptText, selectFrom } from '../../lib/prompt'
21
21
  import { isHttpUrl } from '../../lib/validation'
22
22
 
23
- /** Exact output of the public Kernel install syscall for one requested root. */
24
- type DirectInstallResult = readonly {
25
- origin: string
26
- revision: string
27
- etag?: string
28
- }[]
23
+ /** Public Kernel install syscall input for one remote URL. */
24
+ export function directInstallCallInput(
25
+ url: string,
26
+ token?: string,
27
+ operation: string = crypto.randomUUID(),
28
+ ) {
29
+ return Object.freeze({
30
+ operation,
31
+ domains: [
32
+ Object.freeze({
33
+ source: Object.freeze({
34
+ kind: 'remote' as const,
35
+ url,
36
+ ...(token === undefined ? {} : { token }),
37
+ }),
38
+ }),
39
+ ],
40
+ })
41
+ }
42
+
43
+ type DirectInstallResult = {
44
+ readonly operation: string
45
+ readonly transitions: readonly {
46
+ readonly intent: {
47
+ readonly origin: string
48
+ readonly target?: { readonly schemaRevision?: string } | null
49
+ }
50
+ }[]
51
+ }
29
52
 
30
53
  type InstallOpts = KernelCommandOpts &
31
54
  AdminTargetCommandOpts & {
@@ -330,19 +353,22 @@ async function installDirect(target: string | undefined, opts: InstallOpts): Pro
330
353
  label: `Installing domain from ${url}`,
331
354
  fn: async ({ session }) =>
332
355
  (await session.call(
333
- createPathCall(Path.project(syscalls.install.ref).raw, {
334
- domains: [{ url, ...(opts.token ? { token: opts.token } : {}) }],
335
- }),
356
+ createPathCall(
357
+ Path.project(syscalls.install.ref).raw,
358
+ directInstallCallInput(url, opts.token),
359
+ ),
336
360
  )) as DirectInstallResult,
337
361
  format: (result, fmtOpts, isRaw) => {
338
362
  if (isRaw) {
339
363
  output(result, fmtOpts)
340
364
  return
341
365
  }
342
- const installed = result[0]
343
- if (!installed) throw new Error('Kernel install returned no installed Domain receipt.')
344
- log.success(`Domain installed: ${installed.origin}@${installed.revision}`)
345
- if (installed.etag) log.dim(` publication: ${installed.etag}`)
366
+ const installed = result.transitions[0]?.intent
367
+ if (installed === undefined) {
368
+ throw new Error('Kernel install returned no committed Domain transition.')
369
+ }
370
+ const revision = installed.target?.schemaRevision ?? result.operation
371
+ log.success(`Domain installed: ${installed.origin}@${revision}`)
346
372
  // Belt-and-braces: the kernel-confirmed origin is authoritative. If it
347
373
  // aliases the host and the pre-install gate never consented to THAT
348
374
  // origin (lying or absent `/meta`), say so loudly after the fact.
@@ -66,7 +66,7 @@ Behavior:
66
66
  Reads the admin catalog — every domain that has been \`publish\`ed
67
67
  (origin → published worker URL). Listing only shows what is INSTALLABLE;
68
68
  what is actually mounted where lives on each instance's own graph
69
- (\`astrale ls /\` against that instance).
69
+ (\`astrale query\` against that instance).
70
70
 
71
71
  Default output is a NAME/ORIGIN/URL/DEFAULT table on a TTY, JSON when piped
72
72
  or with --json/--raw (agent-friendly — full DomainInfo objects). -q prints
@@ -4,8 +4,8 @@ import type { KernelCommandOpts } from '../connection'
4
4
  import type { CommandDefinition } from '../program/index'
5
5
 
6
6
  import { expandSelfInPath, runKernelCommand, withSelfHint } from '../connection'
7
- import { log } from '../lib/log'
8
- import { output } from '../lib/output'
7
+ import { formatKernelError } from '../connection/errors'
8
+ import { isMachine, output } from '../lib/output'
9
9
 
10
10
  type GetOpts = KernelCommandOpts
11
11
 
@@ -16,7 +16,7 @@ export async function getCommand(target: string, opts: GetOpts): Promise<void> {
16
16
  try {
17
17
  ;({ path, meta } = await expandSelfInPath(target, opts))
18
18
  } catch (error) {
19
- log.error(error instanceof Error ? error.message : 'Invalid target')
19
+ await formatKernelError(error, isMachine(opts), undefined, opts.debug)
20
20
  process.exit(1)
21
21
  }
22
22
 
@@ -4,6 +4,7 @@ import type { KernelCommandOpts } from '../../connection'
4
4
  import type { Column } from '../../lib/output'
5
5
  import type { CommandDefinition } from '../../program/index'
6
6
 
7
+ import { AstraleError } from '../../errors'
7
8
  import { listOwnedInstances } from '../../lib/admin-instance'
8
9
  import {
9
10
  formatInstanceLocation,
@@ -61,9 +62,13 @@ export default {
61
62
 
62
63
  let managed: OwnedInstanceInfo[] = []
63
64
  if (!opts.bookmarked) {
64
- managed = await withSpinner('Fetching instances', !isMachine(opts), () =>
65
- listOwnedInstances(opts),
66
- )
65
+ try {
66
+ managed = await withSpinner('Fetching instances', !isMachine(opts), () =>
67
+ listOwnedInstances(opts),
68
+ )
69
+ } catch (error) {
70
+ throw adminInventoryUnavailable(error)
71
+ }
67
72
  }
68
73
  if (isMachine(opts)) {
69
74
  output(
@@ -148,3 +153,31 @@ export function buildInstanceRows(
148
153
 
149
154
  return rows
150
155
  }
156
+
157
+ const ADMIN_INVENTORY_CODES = new Set([
158
+ 'TOKEN_EXCHANGE_SOURCE_INVALID',
159
+ 'TOKEN_EXCHANGE_SOURCE_EXPIRED',
160
+ 'TOKEN_EXCHANGE_UNSUPPORTED',
161
+ 'TOKEN_EXCHANGE_DISCOVERY_FAILED',
162
+ 'TOKEN_EXCHANGE_PROTOCOL_ERROR',
163
+ 'TOKEN_EXCHANGE_INSECURE',
164
+ 'ADMIN_DOMAIN_ISSUER_MISSING',
165
+ ])
166
+
167
+ /** Admin-managed inventory is not available without a deployed Admin Domain + IdP identity. */
168
+ export function adminInventoryUnavailable(cause: unknown): AstraleError {
169
+ const code = cause instanceof AstraleError ? cause.code : undefined
170
+ if (code !== undefined && ADMIN_INVENTORY_CODES.has(code)) {
171
+ return new AstraleError(
172
+ 'ADMIN_INVENTORY_UNAVAILABLE',
173
+ 'Managed instance listing needs the Admin Domain and an IdP-backed identity. Admin is not deployed in this environment.',
174
+ 'Use `astrale instance list --bookmarked` for local kernel bookmarks. Key-backed identities cannot mint an Admin Domain token.',
175
+ )
176
+ }
177
+ if (cause instanceof AstraleError) return cause
178
+ return new AstraleError(
179
+ 'ADMIN_INVENTORY_UNAVAILABLE',
180
+ cause instanceof Error ? cause.message : String(cause),
181
+ 'Use `astrale instance list --bookmarked` for local kernel bookmarks.',
182
+ )
183
+ }
@@ -29,6 +29,8 @@ export interface JournalRecord {
29
29
  readonly timestamp: string
30
30
  readonly topic: string
31
31
  readonly payload: unknown
32
+ readonly occurredAt?: string
33
+ readonly committedAt?: string
32
34
  readonly principal?: string
33
35
  readonly correlationId?: string
34
36
  readonly causationId?: string
@@ -146,27 +148,45 @@ function acceptRecord(input: unknown, index: number): JournalRecord {
146
148
  if (
147
149
  !isRecord(input) ||
148
150
  !Number.isSafeInteger(input.sequence) ||
149
- typeof input.timestamp !== 'string' ||
150
151
  typeof input.topic !== 'string'
151
152
  ) {
152
153
  throw new TypeError(`Kernel journal record ${index} is invalid`)
153
154
  }
154
- for (const key of ['principal', 'correlationId', 'causationId'] as const) {
155
- if (input[key] !== undefined && typeof input[key] !== 'string') {
156
- throw new TypeError(`Kernel journal record ${index}.${key} must be text`)
157
- }
155
+ const occurredAt = optionalText(input.occurredAt, index, 'occurredAt')
156
+ const timestamp = optionalText(input.timestamp, index, 'timestamp') ?? occurredAt
157
+ if (timestamp === undefined) {
158
+ throw new TypeError(`Kernel journal record ${index} is missing occurredAt/timestamp`)
158
159
  }
160
+ const correlation = isRecord(input.correlation) ? input.correlation : undefined
161
+ const correlationId =
162
+ optionalText(input.correlationId, index, 'correlationId') ??
163
+ optionalText(correlation?.invocationId, index, 'correlation.invocationId')
164
+ const principal = optionalText(input.principal, index, 'principal')
159
165
  return Object.freeze({
160
166
  sequence: input.sequence as number,
161
- timestamp: input.timestamp,
167
+ timestamp,
162
168
  topic: input.topic,
163
169
  payload: input.payload,
164
- ...(input.principal === undefined ? {} : { principal: input.principal as string }),
165
- ...(input.correlationId === undefined ? {} : { correlationId: input.correlationId as string }),
166
- ...(input.causationId === undefined ? {} : { causationId: input.causationId as string }),
170
+ ...(occurredAt === undefined ? {} : { occurredAt }),
171
+ ...(optionalText(input.committedAt, index, 'committedAt') === undefined
172
+ ? {}
173
+ : { committedAt: input.committedAt as string }),
174
+ ...(principal === undefined ? {} : { principal }),
175
+ ...(correlationId === undefined ? {} : { correlationId }),
176
+ ...(optionalText(input.causationId, index, 'causationId') === undefined
177
+ ? {}
178
+ : { causationId: input.causationId as string }),
167
179
  })
168
180
  }
169
181
 
182
+ function optionalText(input: unknown, index: number, field: string): string | undefined {
183
+ if (input === undefined) return undefined
184
+ if (typeof input !== 'string') {
185
+ throw new TypeError(`Kernel journal record ${index}.${field} must be text`)
186
+ }
187
+ return input
188
+ }
189
+
170
190
  function positiveInteger(flag: string, raw: string): number {
171
191
  if (!/^\d+$/.test(raw)) throw new TypeError(`${flag} must be a positive integer`)
172
192
  const value = Number.parseInt(raw, 10)
@@ -38,6 +38,8 @@ describe('prepareMutation', () => {
38
38
 
39
39
  /** @evidence TEST-CLI-GRAPH-REJECTS-PATCH-DATA */
40
40
  test('rejects legacy PatchData instead of narrowing it', () => {
41
- expect(() => prepareMutation({ nodes: { create: [] }, edges: { create: [] } })).toThrow()
41
+ expect(() => prepareMutation({ nodes: { create: [] }, edges: { create: [] } })).toThrow(
42
+ /Legacy PatchData/,
43
+ )
42
44
  })
43
45
  })
@@ -4,6 +4,11 @@ import { MutationAST } from '@astrale-os/sdk/mutation'
4
4
 
5
5
  /** Admit the canonical document or Core's exact rich authoring input at the CLI JSON boundary. */
6
6
  export function prepareMutation(input: unknown): MutationASTValue {
7
+ if (isLegacyPatchData(input)) {
8
+ throw new TypeError(
9
+ 'Legacy PatchData { nodes, edges } is not Mutation V3. Author { preconditions, operations } or a canonical astrale.graph.mutation/v3 document.',
10
+ )
11
+ }
7
12
  if (isCanonicalCandidate(input)) return MutationAST.decode(input)
8
13
  return MutationAST.create(input as MutationInput)
9
14
  }
@@ -16,3 +21,13 @@ function isCanonicalCandidate(input: unknown): boolean {
16
21
  (Object.hasOwn(input, 'format') || Object.hasOwn(input, 'version'))
17
22
  )
18
23
  }
24
+
25
+ function isLegacyPatchData(input: unknown): boolean {
26
+ return (
27
+ input !== null &&
28
+ typeof input === 'object' &&
29
+ !Array.isArray(input) &&
30
+ !Object.hasOwn(input, 'operations') &&
31
+ (Object.hasOwn(input, 'nodes') || Object.hasOwn(input, 'edges'))
32
+ )
33
+ }
@@ -22,8 +22,9 @@ describe('command DX suggestions', () => {
22
22
  expect(usages).toContain('admin use [bookmark]')
23
23
  expect(usages).toContain('use <name>')
24
24
  expect(usages).toContain('update')
25
- expect(usages).toContain('ls <source>')
25
+ expect(usages).toContain('query [sources...]')
26
26
  expect(usages).toContain('status')
27
+ expect(usages).not.toContain('ls <source>')
27
28
  })
28
29
 
29
30
  test('explains shared verbs as namespaced commands, not arity errors', async () => {
@@ -42,6 +43,17 @@ describe('command DX suggestions', () => {
42
43
  expect(rendered).not.toContain('too many arguments')
43
44
  })
44
45
 
46
+ test('points retired ls at query', async () => {
47
+ const program = await buildProgram()
48
+ const error = new CommanderError(1, 'commander.unknownCommand', "error: unknown command 'ls'")
49
+ const rendered = stripAnsi(renderCommanderError(program, error, ['ls', '@note']))
50
+
51
+ expect(rendered).toContain('Unknown command: astrale ls @note')
52
+ expect(rendered).toContain('astrale ls` was removed')
53
+ expect(rendered).toContain('astrale query <source> --edge <class>')
54
+ expect(rendered).not.toContain('Did you mean:')
55
+ })
56
+
45
57
  test('suggests nearest command for typo paths', async () => {
46
58
  const program = await buildProgram()
47
59
  const error = new CommanderError(
@@ -72,9 +72,21 @@ export function renderCommanderError(
72
72
  ].join('\n')
73
73
  }
74
74
 
75
+ const RETIRED_COMMANDS: Record<string, string> = {
76
+ ls: 'astrale query <source> --edge <class>',
77
+ }
78
+
75
79
  function renderUnknownCommand(tokens: string[], catalog: CommandCatalogEntry[]): string {
76
80
  const command = tokens.join(' ')
77
81
  const first = tokens[0]
82
+ const retired = first === undefined ? undefined : RETIRED_COMMANDS[first]
83
+ if (retired !== undefined) {
84
+ return [
85
+ `Unknown command: ${chalk.bold(`astrale ${command}`)}`,
86
+ '',
87
+ `\`astrale ${first}\` was removed. Use ${chalk.bold(retired)} instead.`,
88
+ ].join('\n')
89
+ }
78
90
  const namespaceMatches = catalog.filter((entry) => entry.path.at(-1) === first)
79
91
  if (namespaceMatches.length > 0) {
80
92
  return [
@@ -171,7 +171,6 @@ describe('program composition', () => {
171
171
  'instance status',
172
172
  'instance use',
173
173
  'logs',
174
- 'ls',
175
174
  'mutate',
176
175
  'query',
177
176
  'session',
@@ -187,7 +186,7 @@ describe('program composition', () => {
187
186
  'whoami',
188
187
  ])
189
188
  expect(createHash('sha256').update(JSON.stringify(surface)).digest('hex')).toBe(
190
- 'e030c6f672dea850a90bef16cf4222ea6af45e60f50cfc829c85bd90d9543632',
189
+ '4b053fe80827aeb40aecc13cd17c293b8701e59cb04f5ccb8185e9e255376dd1',
191
190
  )
192
191
  })
193
192
 
@@ -267,7 +266,7 @@ describe('help contract — connect-only command surface', () => {
267
266
  const program = await buildProgram()
268
267
  const names = allCommands(program).map((command) => command.name())
269
268
 
270
- // `logs` and `domain` have managed/admin meanings, so only retired local-runtime verbs are absent.
269
+ // `logs` and `domain` have managed/admin meanings, so only retired verbs are absent.
271
270
  for (const removed of [
272
271
  'init',
273
272
  'start',
@@ -279,6 +278,7 @@ describe('help contract — connect-only command surface', () => {
279
278
  'graph',
280
279
  'server',
281
280
  'env',
281
+ 'ls',
282
282
  ]) {
283
283
  expect(names).not.toContain(removed)
284
284
  }
@@ -61,7 +61,6 @@ export async function buildProgram(): Promise<Command> {
61
61
  registerCommand(program, withKernelOptions((await import('../commands/token')).default))
62
62
  registerCommand(program, withKernelOptions((await import('../commands/get')).default))
63
63
  registerCommand(program, withKernelOptions((await import('../commands/mutate')).default))
64
- registerCommand(program, withKernelOptions((await import('../commands/ls')).default))
65
64
  registerCommand(program, withKernelOptions((await import('../commands/describe')).default))
66
65
  registerCommand(program, withKernelOptions((await import('../commands/query')).default))
67
66
  registerCommand(program, withKernelOptions((await import('../commands/logs')).default))
@@ -159,7 +158,7 @@ export async function buildProgram(): Promise<Command> {
159
158
  `
160
159
  Command groups:
161
160
  Getting started setup (sign in, pick an instance, equip your workspace)
162
- Kernel ls, get, mutate, call, query, describe, token
161
+ Kernel get, mutate, call, query, describe, token
163
162
  Management admin, instance, domain, identity, auth, idp, update
164
163
  Agent browser (drive the GUI via agent-browser)
165
164
  Studio studio (launch the local Domain Studio GUI for a workspace)
@@ -173,7 +172,6 @@ Path syntax:
173
172
  @nodeId::method Instance method on a node by UID
174
173
 
175
174
  Examples:
176
- $ astrale ls @note --edge /:notes.example.dev:class.references
177
175
  $ astrale studio
178
176
  $ astrale admin status
179
177
  $ astrale update --check
@@ -49,7 +49,7 @@ describe('extractSignals / hasSignals', () => {
49
49
  })
50
50
 
51
51
  test('quiet session has no signals until a transcript is attached', () => {
52
- const s = extractSignals([ev(['status'], 0), ev(['ls', '/'], 0)])
52
+ const s = extractSignals([ev(['status'], 0), ev(['query', '/'], 0)])
53
53
  expect(hasSignals(s)).toBe(false)
54
54
  s.harnessSessions = [
55
55
  {
@@ -1,29 +0,0 @@
1
- import type { Node } from '@astrale-os/sdk/graph/node'
2
-
3
- import { ClassPath } from '@astrale-os/sdk/graph/class'
4
- import { NodeId } from '@astrale-os/sdk/graph/node'
5
- import { normalizeProperties } from '@astrale-os/sdk/graph/properties'
6
- import { describe, expect, test } from 'bun:test'
7
-
8
- import { displayName, listProjection } from '../ls'
9
-
10
- describe('ls display projection', () => {
11
- const node = {
12
- id: NodeId('note-1'),
13
- class: ClassPath.parse('/:notes.example.dev:class.Note'),
14
- props: normalizeProperties({ 'notes.example.dev:class.Note.property.title': 'Hello' }),
15
- } satisfies Node
16
-
17
- /** @evidence TEST-CLI-LS-PROJECTS-CANONICAL-NODES */
18
- test('uses canonical properties for display and @id for pipeable output', () => {
19
- expect(displayName(node)).toBe('Hello')
20
- expect(listProjection([node])).toMatchObject({
21
- rows: [{ name: 'Hello', class: 'Note', id: 'note-1' }],
22
- paths: ['@note-1'],
23
- })
24
- })
25
-
26
- test('falls back to the canonical Node ID when no display property exists', () => {
27
- expect(displayName({ ...node, props: normalizeProperties({}) })).toBe('@note-1')
28
- })
29
- })