@astrale-os/cli 0.4.0-alpha.13 → 0.5.0-alpha.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.
Files changed (46) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +2 -2
  3. package/THIRD-PARTY-NOTICES.md +27 -0
  4. package/dist/astrale.js +8687 -6398
  5. package/package.json +19 -21
  6. package/src/commands/__tests__/help-contract.test.ts +27 -14
  7. package/src/commands/__tests__/install-identity-override.test.ts +2 -2
  8. package/src/commands/__tests__/ls.test.ts +1 -1
  9. package/src/commands/__tests__/read-commands.test.ts +201 -0
  10. package/src/commands/call.ts +27 -44
  11. package/src/commands/describe.ts +57 -58
  12. package/src/commands/domain/install.ts +4 -4
  13. package/src/commands/get.ts +48 -23
  14. package/src/commands/identity/register.ts +27 -33
  15. package/src/commands/logs.ts +8 -8
  16. package/src/commands/ls.ts +77 -55
  17. package/src/commands/mutate.ts +191 -0
  18. package/src/commands/query.ts +288 -20
  19. package/src/commands/token.ts +4 -7
  20. package/src/kernel/__tests__/expand.test.ts +123 -0
  21. package/src/kernel/client.ts +3 -13
  22. package/src/kernel/expand.ts +52 -59
  23. package/src/kernel/graph.ts +96 -0
  24. package/src/kernel/index.ts +11 -2
  25. package/src/kernel/run.ts +0 -13
  26. package/src/lib/__tests__/table.test.ts +1 -1
  27. package/src/lib/admin-domain.ts +3 -3
  28. package/src/lib/domain-identity.ts +1 -1
  29. package/src/lib/self.ts +1 -3
  30. package/src/program.ts +5 -3
  31. package/src/setup/render.ts +1 -3
  32. package/studio/client/dist/assets/index-BcejyJpa.css +1 -0
  33. package/studio/client/dist/assets/index-Cqz3Oy_B.js +179 -0
  34. package/studio/client/dist/index.html +2 -2
  35. package/studio/server/agent/ask.ts +5 -1
  36. package/studio/server/agent/claude.ts +15 -3
  37. package/studio/server/api.ts +7 -7
  38. package/studio/server/introspect/overlay-tsmorph.ts +108 -50
  39. package/studio/server/state/harness-gateway.ts +12 -3
  40. package/studio/server/state/harness-token.ts +0 -0
  41. package/studio/server/state/visibility.ts +5 -1
  42. package/src/kernel/__tests__/remote-routing.test.ts +0 -70
  43. package/src/kernel/remote-routing.ts +0 -88
  44. package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
  45. package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
  46. package/studio/tsconfig.json +0 -23
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@astrale-os/cli",
3
- "version": "0.4.0-alpha.13",
3
+ "version": "0.5.0-alpha.0",
4
4
  "description": "Astrale CLI — connect to existing Astrale kernels",
5
5
  "keywords": [
6
6
  "astrale",
7
7
  "cli"
8
8
  ],
9
- "license": "MIT",
9
+ "license": "Apache-2.0",
10
10
  "author": "Astrale",
11
11
  "repository": {
12
12
  "type": "git",
@@ -23,7 +23,8 @@
23
23
  "studio/server",
24
24
  "studio/shared",
25
25
  "studio/client/dist",
26
- "studio/package.json"
26
+ "studio/package.json",
27
+ "THIRD-PARTY-NOTICES.md"
27
28
  ],
28
29
  "type": "module",
29
30
  "exports": {
@@ -34,22 +35,9 @@
34
35
  "access": "public",
35
36
  "registry": "https://registry.npmjs.org"
36
37
  },
37
- "scripts": {
38
- "preinstall": "node .check-workspace.cjs",
39
- "prepare": "husky",
40
- "build": "bun scripts/build.ts",
41
- "prepack": "bun scripts/build.ts",
42
- "typecheck": "tsgo --noEmit",
43
- "lint": "oxlint .",
44
- "lint:fix": "oxlint --fix .",
45
- "format": "pnpm exec oxfmt --write .",
46
- "format:check": "pnpm exec oxfmt --check .",
47
- "test": "bun test src",
48
- "test:watch": "bun test --watch src"
49
- },
50
38
  "dependencies": {
51
- "@astrale-os/kernel-client": ">=0.2.0 <1.0.0",
52
- "@astrale-os/kernel-core": ">=0.4.1 <1.0.0",
39
+ "@astrale-os/kernel-client": ">=0.3.1 <1.0.0",
40
+ "@astrale-os/kernel-core": ">=0.7.0 <1.0.0",
53
41
  "@inquirer/prompts": "^8.5.2",
54
42
  "chalk": "^5.6.2",
55
43
  "chokidar": "^4.0.3",
@@ -62,7 +50,7 @@
62
50
  },
63
51
  "devDependencies": {
64
52
  "@astrale-os/ox": ">=0.1.0 <1.0.0",
65
- "@astrale/commitlint-config": "jsr:~2.0.1",
53
+ "@astrale/commitlint-config": "npm:@jsr/astrale__commitlint-config@~2.0.1",
66
54
  "@commitlint/cli": "~20.3.1",
67
55
  "@commitlint/config-conventional": "~20.3.1",
68
56
  "@types/bun": "^1.1.16",
@@ -86,5 +74,15 @@
86
74
  "engines": {
87
75
  "node": ">=20"
88
76
  },
89
- "packageManager": "pnpm@10.28.0"
90
- }
77
+ "scripts": {
78
+ "preinstall": "node .check-workspace.cjs",
79
+ "build": "bun scripts/build.ts",
80
+ "typecheck": "tsgo --noEmit",
81
+ "lint": "oxlint .",
82
+ "lint:fix": "oxlint --fix .",
83
+ "format": "pnpm exec oxfmt --write .",
84
+ "format:check": "pnpm exec oxfmt --check .",
85
+ "test": "bun test src",
86
+ "test:watch": "bun test --watch src"
87
+ }
88
+ }
@@ -6,10 +6,7 @@ import { join } from 'node:path'
6
6
 
7
7
  import { buildProgram } from '../../program'
8
8
 
9
- // The `astrale-cli` skill claims `astrale --help` is the source of truth and
10
- // "never drifts". These tests hold that claim to account: the version is
11
- // single-sourced from package.json, no internal `§` spec anchors leak into
12
- // rendered help, and the workspace skill mirror stays byte-identical.
9
+ // Help output is the public CLI contract: version, spec anchors, and skill mirror stay in sync.
13
10
 
14
11
  const cliRoot = join(import.meta.dir, '../../..')
15
12
 
@@ -28,9 +25,7 @@ describe('help contract — version is single-sourced', () => {
28
25
  readFileSync(join(cliRoot, '.release-please-manifest.json'), 'utf8'),
29
26
  ) as Record<string, string>
30
27
 
31
- // `bin/astrale.ts` -> buildProgram -> .version(pkg.version): asserting the
32
- // rendered version equals BOTH JSON sources catches a re-hardcoded literal
33
- // and a manifest/package.json divergence (release-please bumps both).
28
+ // Program version must follow both package.json and release-please metadata.
34
29
  expect(program.version()).toBe(pkg.version)
35
30
  expect(manifest['.']).toBe(pkg.version)
36
31
  })
@@ -92,13 +87,7 @@ describe('help contract — connect-only command surface', () => {
92
87
  const program = await buildProgram()
93
88
  const names = allCommands(program).map((command) => command.name())
94
89
 
95
- // 'logs' and 'domain' are NOT in this list anymore: each was once LOCAL
96
- // runtime management (the historical `astrale logs`, removed with
97
- // start/stop; the historical `astrale domain`, local domain wiring) and each
98
- // has since been RECLAIMED for a connect-side meaning through the admin
99
- // control plane — `astrale logs <service>` tails a MANAGED service's buffer;
100
- // `astrale domain publish` registers an installable domain in the admin's
101
- // catalog and `astrale domain install <url>` mounts one on an instance.
90
+ // `logs` and `domain` have managed/admin meanings, so only retired local-runtime verbs are absent.
102
91
  for (const removed of [
103
92
  'init',
104
93
  'start',
@@ -116,6 +105,30 @@ describe('help contract — connect-only command surface', () => {
116
105
  })
117
106
  })
118
107
 
108
+ describe('help contract — read command split', () => {
109
+ test('get is point-read only and query owns structured read flags', async () => {
110
+ const program = await buildProgram()
111
+ const get = allCommands(program).find((command) => command.name() === 'get')
112
+ const query = allCommands(program).find((command) => command.name() === 'query')
113
+ const getHelp = get?.helpInformation() ?? ''
114
+ const queryHelp = query?.helpInformation() ?? ''
115
+
116
+ expect(getHelp).toContain('Usage: astrale get [options] <path>')
117
+ expect(getHelp).toContain('-l, --long')
118
+ expect(getHelp).not.toContain('--depth')
119
+ expect(getHelp).not.toContain('--children')
120
+ expect(getHelp).not.toContain('--edges')
121
+ expect(getHelp).not.toContain('--graph')
122
+
123
+ expect(queryHelp).toContain('Usage: astrale query [options] [paths...]')
124
+ expect(queryHelp).toContain('--depth <n>')
125
+ expect(queryHelp).toContain('--children <json>')
126
+ expect(queryHelp).toContain('--edges <json>')
127
+ expect(queryHelp).toContain('--ast <json>')
128
+ expect(queryHelp).toContain('--cypher <query>')
129
+ })
130
+ })
131
+
119
132
  describe('help contract — skill is single-source, not duplicated', () => {
120
133
  const canonical = join(cliRoot, 'skills/astrale-cli/SKILL.md')
121
134
  // Workspace mirror lives in the superrepo, outside this submodule. Absent
@@ -11,7 +11,7 @@ describe('admin-path target classification', () => {
11
11
  test('a bare origin installs by catalog origin (the unique registry key)', () => {
12
12
  expect(domainRefFromTarget('crm.acme.dev')).toEqual({ origin: 'crm.acme.dev' })
13
13
  // A host:port that is not a url is still an origin, not a url.
14
- expect(domainRefFromTarget('dist.astrale.ai')).toEqual({ origin: 'dist.astrale.ai' })
14
+ expect(domainRefFromTarget('example.astrale.ai')).toEqual({ origin: 'example.astrale.ai' })
15
15
  })
16
16
  })
17
17
 
@@ -23,7 +23,7 @@ describe('identity-override detection', () => {
23
23
 
24
24
  test('origin differing from the serving host is an override', () => {
25
25
  // The spec §5 attack shape: a fork on workers.dev claiming a well-known origin.
26
- expect(isIdentityOverride('distribution.astrale.ai', 'crm.workers.dev')).toBe(true)
26
+ expect(isIdentityOverride('workspace.astrale.ai', 'crm.workers.dev')).toBe(true)
27
27
  // The scaffold default also aliases until the placeholder origin is edited.
28
28
  expect(isIdentityOverride('hldom.example.dev', 'hldom-example-dev.acme.workers.dev')).toBe(true)
29
29
  })
@@ -7,7 +7,7 @@ import { basename, classNameOf } from '../ls'
7
7
  // `path` (absolute) and `class` (serialized ClassPath).
8
8
  describe('ls — display projection (slug-bug regression)', () => {
9
9
  test('basename derives the display name from the absolute path', () => {
10
- expect(basename('/dist.astrale.ai')).toBe('dist.astrale.ai')
10
+ expect(basename('/example.astrale.ai')).toBe('example.astrale.ai')
11
11
  expect(basename('/kernel.astrale.ai')).toBe('kernel.astrale.ai')
12
12
  expect(basename('/')).toBe('/')
13
13
  expect(basename(undefined)).toBe('')
@@ -0,0 +1,201 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2
+
3
+ type Wire = { nodes: unknown[]; edges: unknown[]; roots: string[]; next?: Record<string, unknown> }
4
+ type RunOpts = {
5
+ opts: Record<string, unknown>
6
+ fn: (ctx: { client: { call: typeof clientCallMock } }) => Promise<unknown>
7
+ format?: (result: unknown, opts: Record<string, unknown>, isRaw: boolean) => void | Promise<void>
8
+ }
9
+
10
+ class ExitError extends Error {
11
+ constructor(readonly code: string | number | null | undefined) {
12
+ super('process.exit(' + String(code) + ')')
13
+ }
14
+ }
15
+
16
+ const emptyWire = (): Wire => ({ nodes: [], edges: [], roots: [] })
17
+
18
+ let stdout = ''
19
+ let errors: string[] = []
20
+ let originalStdoutWrite: typeof process.stdout.write
21
+ let originalConsoleError: typeof console.error
22
+ let originalExit: typeof process.exit
23
+ let queryAsts: unknown[] = []
24
+ let getPaths: string[] = []
25
+ let queryResult: { wire: Wire } = { wire: emptyWire() }
26
+ let getResult: unknown = null
27
+ let clientCallResult: unknown = { rows: [] }
28
+
29
+ const clientCallMock = mock(async () => clientCallResult)
30
+ const queryMock = mock(async (ast: unknown) => {
31
+ queryAsts.push(ast)
32
+ return queryResult
33
+ })
34
+ const getMock = mock(async (path: string) => {
35
+ getPaths.push(path)
36
+ return getResult
37
+ })
38
+ const bindGraphMock = mock(() => ({ query: queryMock, get: getMock }))
39
+ const expandSelfInPathMock = mock(async (path: string) => {
40
+ if (path === '@self') {
41
+ return {
42
+ path: '@expanded-self',
43
+ meta: { original: '@self', expanded: '@expanded-self', selfId: 'expanded-self' },
44
+ }
45
+ }
46
+ return { path, meta: undefined }
47
+ })
48
+ const withSelfHintMock = mock(async (fn: () => Promise<unknown>) => fn())
49
+ const runKernelCommandMock = mock(async (run: RunOpts) => {
50
+ const result = await run.fn({ client: { call: clientCallMock } })
51
+ if (run.format) await run.format(result, run.opts, true)
52
+ })
53
+
54
+ mock.module('../../kernel', () => ({
55
+ bindGraph: bindGraphMock,
56
+ expandSelfInPath: expandSelfInPathMock,
57
+ runKernelCommand: runKernelCommandMock,
58
+ withSelfHint: withSelfHintMock,
59
+ }))
60
+
61
+ beforeEach(() => {
62
+ stdout = ''
63
+ errors = []
64
+ queryAsts = []
65
+ getPaths = []
66
+ queryResult = { wire: emptyWire() }
67
+ getResult = null
68
+ clientCallResult = { rows: [] }
69
+ clientCallMock.mockClear()
70
+ queryMock.mockClear()
71
+ getMock.mockClear()
72
+ bindGraphMock.mockClear()
73
+ expandSelfInPathMock.mockClear()
74
+ withSelfHintMock.mockClear()
75
+ runKernelCommandMock.mockClear()
76
+
77
+ originalStdoutWrite = process.stdout.write.bind(process.stdout)
78
+ originalConsoleError = console.error
79
+ originalExit = process.exit
80
+ process.stdout.write = ((chunk: string | Uint8Array) => {
81
+ stdout += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
82
+ return true
83
+ }) as typeof process.stdout.write
84
+ console.error = ((...args: unknown[]) => {
85
+ errors.push(args.map(String).join(' '))
86
+ }) as typeof console.error
87
+ process.exit = ((code?: string | number | null) => {
88
+ throw new ExitError(code)
89
+ }) as typeof process.exit
90
+ })
91
+
92
+ afterEach(() => {
93
+ process.stdout.write = originalStdoutWrite
94
+ console.error = originalConsoleError
95
+ process.exit = originalExit
96
+ })
97
+
98
+ describe('query command', () => {
99
+ test('lowers roots and selectors to the expected QueryASTInput', async () => {
100
+ const { queryCommand } = await import('../query')
101
+
102
+ await queryCommand(['/root', '@self'], {
103
+ json: true,
104
+ depth: '2',
105
+ children:
106
+ '{"classes":["/:kernel.astrale.ai:class.Folder"],"limit":50,"cursor":"child-cursor","order":{"by":"id","dir":"asc"}}',
107
+ edges:
108
+ '[{"as":"perm","classes":["/:kernel.astrale.ai:class.has_perm"],"direction":"out","limit":10,"cursor":"edge-cursor","order":{"by":"id","dir":"desc"}},{"direction":"both"}]',
109
+ })
110
+
111
+ expect(queryAsts).toEqual([
112
+ {
113
+ version: 1,
114
+ from: ['/root', '@expanded-self'],
115
+ steps: [
116
+ {
117
+ expand: {
118
+ edge: 'has_parent',
119
+ dir: 'in',
120
+ depth: 2,
121
+ filter: { class: ['/:kernel.astrale.ai:class.Folder'] },
122
+ page: { limit: 50, cursor: 'child-cursor' },
123
+ order: { by: 'id', dir: 'asc' },
124
+ },
125
+ },
126
+ {
127
+ expand: {
128
+ edge: ['/:kernel.astrale.ai:class.has_perm'],
129
+ dir: 'out',
130
+ as: 'perm',
131
+ page: { limit: 10, cursor: 'edge-cursor' },
132
+ order: { by: 'id', dir: 'desc' },
133
+ },
134
+ },
135
+ { expand: { dir: 'both', as: 'e1' } },
136
+ ],
137
+ },
138
+ ])
139
+ expect(expandSelfInPathMock.mock.calls.map((call) => call[0])).toEqual(['/root', '@self'])
140
+ expect(withSelfHintMock).toHaveBeenCalledTimes(1)
141
+ })
142
+
143
+ test('rejects an invalid children selector before dispatch', async () => {
144
+ const { queryCommand } = await import('../query')
145
+
146
+ await expect(queryCommand(['/'], { json: true, children: '{"bogus":true}' })).rejects.toEqual(
147
+ new ExitError(1),
148
+ )
149
+
150
+ expect(errors.join('\n')).toContain('--children invalid selector')
151
+ expect(queryMock).not.toHaveBeenCalled()
152
+ })
153
+
154
+ test('rejects mixing --ast with positional roots', async () => {
155
+ const { queryCommand } = await import('../query')
156
+
157
+ await expect(
158
+ queryCommand(['/'], { json: true, ast: '{"version":1,"from":["/"]}' }),
159
+ ).rejects.toEqual(new ExitError(1))
160
+
161
+ expect(errors.join('\n')).toContain(
162
+ '--ast cannot be used with positional roots or --depth/--children/--edges',
163
+ )
164
+ expect(queryMock).not.toHaveBeenCalled()
165
+ })
166
+ })
167
+
168
+ describe('get command', () => {
169
+ test('dispatches a point read and prints the flat wire projection', async () => {
170
+ const { getCommand } = await import('../get')
171
+ const row = {
172
+ id: 'n1',
173
+ class: '/:demo:class.Widget',
174
+ path: '/demo/widget',
175
+ props: { name: 'Widget' },
176
+ __labels: ['Node', 'Widget'],
177
+ classId: 'class-1',
178
+ }
179
+ getResult = row
180
+
181
+ await getCommand('/demo/widget', { json: true })
182
+
183
+ expect(getPaths).toEqual(['/demo/widget'])
184
+ expect(JSON.parse(stdout)).toEqual({
185
+ id: 'n1',
186
+ class: '/:demo:class.Widget',
187
+ path: '/demo/widget',
188
+ props: { name: 'Widget' },
189
+ })
190
+ })
191
+
192
+ test('errors with exit 1 when no node resolves', async () => {
193
+ const { getCommand } = await import('../get')
194
+ getResult = null
195
+
196
+ await expect(getCommand('/missing', { json: true })).rejects.toEqual(new ExitError(1))
197
+
198
+ expect(getPaths).toEqual(['/missing'])
199
+ expect(errors.join('\n')).toContain('node "/missing" not found or not visible')
200
+ })
201
+ })
@@ -3,7 +3,14 @@ import { K } from '@astrale-os/kernel-core'
3
3
  import type { CommandDefinition } from '../command'
4
4
  import type { CallCommandOpts, ClientContext, SelfExpansionMeta } from '../kernel'
5
5
 
6
- import { buildSelfContext, resolveSelfIdLazy, runKernelCommand, withSelfHint } from '../kernel'
6
+ import {
7
+ bindGraph,
8
+ buildSelfContext,
9
+ nodeProp,
10
+ resolveSelfIdLazy,
11
+ runKernelCommand,
12
+ withSelfHint,
13
+ } from '../kernel'
7
14
  import { presentBinary, type BinaryLike } from '../lib/binary'
8
15
  import { log } from '../lib/log'
9
16
  import { output, present } from '../lib/output'
@@ -19,12 +26,8 @@ export async function callCommand(
19
26
  rawParams: string[],
20
27
  opts: CallOpts,
21
28
  ): Promise<void> {
22
- // ── Expand `@self` in path + raw param strings ──────────
23
- // Local resolution, with ONE lazy whoami round-trip for IdP identities
24
- // missing a cached registration (persisted, so it's local afterwards).
25
- // Throws a typed SelfRefusalError (manager, instance-signed, …) which we
26
- // surface as a fatal CLI error. Runs BEFORE `--describe` so users get the
27
- // typed refusal instead of a generic NotFoundError from the kernel.
29
+ // Expand @self before describe/execute so refusals are typed and stale IdP
30
+ // registrations can refresh through whoami.
28
31
  let expandedPath = path
29
32
  let expandedRaw = rawParams
30
33
  let selfMeta: SelfExpansionMeta | undefined
@@ -35,9 +38,7 @@ export async function callCommand(
35
38
  const selfId = await resolveSelfIdLazy(selfCtx, opts)
36
39
  expandedPath = expandSelfReferences(path, selfId)
37
40
  expandedRaw = rawParams.map((p) => expandSelfReferences(p, selfId))
38
- // Stamp metadata whenever ANY input mutated the stale-registration
39
- // hint in `formatKernelError` is just as useful when `@self` lived in
40
- // a param (`node=@self`) as when it was in the path head.
41
+ // Stale-registration hints apply when @self appears in either path or params.
41
42
  const rawMutated = expandedRaw.some((p, i) => p !== rawParams[i])
42
43
  if (expandedPath !== path || rawMutated) {
43
44
  selfMeta = {
@@ -80,14 +81,7 @@ export async function callCommand(
80
81
  opts,
81
82
  label: expandedPath,
82
83
  fn: async (ctx): Promise<CallResult> => {
83
- // Remote-bound functions live on an external worker. The kernel resolves
84
- // the call to a redirect carrying the worker's URL + `iss`; the session
85
- // follows it, minting a worker-scoped delegation for that `iss` (the
86
- // delegation cache wired in `client.ts`). No client-side binding lookup.
87
- //
88
- // The one thing the reactive path can't discover after dispatch is a
89
- // binary output mode (a JSON decode would corrupt the bytes), so detect
90
- // it up front and route to the binary transport — which also auto-follows.
84
+ // Binary outputs must be routed before dispatch; JSON decoding would corrupt bytes.
91
85
  if (await isBinaryOutput(ctx, expandedPath)) {
92
86
  const response = await withSelfHint(() => ctx.client.binary(expandedPath, params), selfMeta)
93
87
  return { kind: 'binary', response }
@@ -106,26 +100,15 @@ export async function callCommand(
106
100
  }
107
101
 
108
102
  /**
109
- * Best-effort pre-flight: does the target Function declare a binary output?
110
- * A binary method must use the binary transport (the value path would JSON-
111
- * decode and corrupt the bytes), and the client can't discover that after
112
- * dispatch. For a static path the path IS the Function node — read `output`
113
- * off it via `::get`. For an instance-method path (`<node>::method`) the
114
- * Function node isn't addressable that way: resolve the instance's class
115
- * first, then probe the class's method node (`<classPath>:method`). An
116
- * interface-hosted instance method still escapes the probe (its Function node
117
- * hangs off the interface, not the class) — that and any other failure returns
118
- * false and falls through to the value path, letting the kernel surface the
119
- * real error. `::get` is a kernel syscall (same origin) so it neither
120
- * redirects nor triggers delegation.
103
+ * Best-effort binary preflight. Static paths read the Function node directly;
104
+ * instance paths probe the class method node and fall back to value transport on miss.
121
105
  */
122
106
  async function isBinaryOutput(ctx: ClientContext, path: string): Promise<boolean> {
123
107
  try {
124
108
  const target = path.includes('::') ? await instanceMethodNodePath(ctx, path) : path
125
109
  if (!target) return false
126
- const node = (await ctx.client.call(`${target}::get`, {})) as {
127
- props?: Record<string, unknown>
128
- } | null
110
+ const node = await bindGraph(ctx).get(target)
111
+ // Props travel under fully-qualified keys; `output.key` IS that key.
129
112
  return node?.props?.[K.$.i('Function').output.key] === 'binary'
130
113
  } catch {
131
114
  return false
@@ -141,22 +124,23 @@ async function instanceMethodNodePath(
141
124
  const source = path.slice(0, sep)
142
125
  const method = path.slice(sep + 2)
143
126
  if (!source || !method) return undefined
144
- const node = (await ctx.client.call(`${source}::get`, {})) as { class?: string } | null
145
- // node.class is a ClassPath (`/:domain:class.Name`); appending `:<method>`
146
- // forms the MethodPath of the class-owned Function node.
147
- return node?.class ? `${node.class}:${method}` : undefined
127
+ const node = await bindGraph(ctx).get(source)
128
+ // ClassPath + method name forms the class-owned Function MethodPath.
129
+ return node?.class ? `${node.class.raw}:${method}` : undefined
148
130
  }
149
131
 
150
132
  async function describeOperation(path: string, opts: CallOpts): Promise<void> {
151
- await runKernelCommand<Record<string, unknown>>({
133
+ await runKernelCommand({
152
134
  opts,
153
135
  label: `Schema for ${path}`,
154
- fn: (ctx) => ctx.client.call(`${path}::get`, {}) as Promise<Record<string, unknown>>,
136
+ // The Function node carries the schemas as props (function.get depth:0).
137
+ fn: async (ctx) => await bindGraph(ctx).get(path),
155
138
  format: (node, fmtOpts) => {
156
- const props = (node.properties ?? node) as Record<string, unknown>
139
+ const input = nodeProp(node, 'inputSchema')
140
+ const outputSchema = nodeProp(node, 'outputSchema')
157
141
  const schema: Record<string, unknown> = {}
158
- if (props.inputSchema) schema.input = tryParseJson(props.inputSchema)
159
- if (props.outputSchema) schema.output = tryParseJson(props.outputSchema)
142
+ if (input) schema.input = tryParseJson(input)
143
+ if (outputSchema) schema.output = tryParseJson(outputSchema)
160
144
  output(Object.keys(schema).length > 0 ? schema : node, fmtOpts)
161
145
  },
162
146
  })
@@ -277,8 +261,7 @@ Examples:
277
261
  $ astrale call /:host.astrale.ai:class.KernelInstance:list
278
262
  $ astrale call /:blog.acme.com:class.Author:list limit=10
279
263
  $ astrale call '@self::deactivate'
280
- $ astrale call /:dist.astrale.ai:class.Domain:install --creds "$TOKEN" \\
281
- -d "$(cat spec.json)"
264
+ $ astrale call /:workspace.astrale.ai:class.App:available --json
282
265
  `,
283
266
  arguments: [
284
267
  {