@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
@@ -1,14 +1,17 @@
1
+ import { rawOf } from '@astrale-os/kernel-client/graph'
1
2
  import chalk from 'chalk'
2
3
 
3
4
  import type { CommandDefinition } from '../command'
4
- import type { KernelCommandOpts, ClientContext, SelfExpansionMeta } from '../kernel'
5
+ import type { GraphNode, KernelCommandOpts, SelfExpansionMeta } from '../kernel'
5
6
  import type { ListProjection } from '../lib/output'
6
7
 
7
8
  import {
9
+ bindGraph,
10
+ childrenCursor,
8
11
  expandSelfInPath,
9
- extractItems,
10
12
  formatKernelError,
11
13
  runKernelCommand,
14
+ splitRoot,
12
15
  withKernelClient,
13
16
  withSelfHint,
14
17
  } from '../kernel'
@@ -23,25 +26,19 @@ type LsOpts = KernelCommandOpts & {
23
26
  filter?: string
24
27
  }
25
28
 
26
- /** A child node as returned by `::listChildren`. */
27
- type Item = {
28
- id?: string
29
- class?: string
30
- path?: string
31
- props?: Record<string, unknown>
32
- __labels?: string[]
33
- }
29
+ /** A child node row the shared `function.get` node shape. */
30
+ type Item = GraphNode
34
31
 
35
32
  // ── Display projection ──────────────────────────────────────
36
33
 
37
- /** `/dist.astrale.ai` → `dist.astrale.ai`; `/` stays `/`. */
34
+ /** `/example.astrale.ai` → `example.astrale.ai`; `/` stays `/`. */
38
35
  export function basename(path?: string): string {
39
36
  if (!path || path === '/') return path ?? ''
40
37
  return path.slice(path.lastIndexOf('/') + 1)
41
38
  }
42
39
 
43
40
  /** `/:kernel.astrale.ai:class.Domain` → `Domain`; falls back to the most specific label. */
44
- export function classNameOf(item: Item): string {
41
+ export function classNameOf(item: { class?: string; __labels?: string[] }): string {
45
42
  const tail = item.class?.split(/[/:.]/).pop()
46
43
  return tail || item.__labels?.[item.__labels.length - 1] || '?'
47
44
  }
@@ -92,14 +89,36 @@ export async function lsCommand(path: string, opts: LsOpts): Promise<void> {
92
89
  await runKernelCommand({
93
90
  opts,
94
91
  label: `Children of ${expandedPath}`,
95
- fn: (ctx) => withSelfHint(() => ctx.client.call(`${expandedPath}::listChildren`, {}), meta),
96
- format: (result, fmtOpts) => {
97
- const items = applyFilter(extractItems<Item>(result), opts.filter)
92
+ // Flat listing = ONE function.get depth:1; the root is dropped, the rest are
93
+ // the direct children (replaces the removed `::listChildren` syscall).
94
+ fn: async (ctx) => {
95
+ const result = await withSelfHint(
96
+ () => bindGraph(ctx).query((q) => q.from(expandedPath).children()),
97
+ meta,
98
+ )
99
+ return {
100
+ children: splitRoot(result.wire.nodes, expandedPath).children,
101
+ next: childrenCursor(result.wire),
102
+ }
103
+ },
104
+ format: ({ children, next }, fmtOpts) => {
105
+ const items = applyFilter(children, opts.filter)
98
106
  presentList(
99
107
  items,
100
108
  { ...fmtOpts, quiet: opts.quiet, count: opts.count, long: opts.long },
101
109
  lsProjection,
102
110
  )
111
+ if (next && !isMachine(fmtOpts) && !opts.quiet && !opts.count) {
112
+ process.stdout.write(
113
+ chalk.dim(
114
+ ' more children truncated - page with `query ' +
115
+ expandedPath +
116
+ ' --depth 1 --children \'{"cursor":"' +
117
+ next +
118
+ '"}\'`\n',
119
+ ),
120
+ )
121
+ }
103
122
  },
104
123
  })
105
124
  }
@@ -107,9 +126,8 @@ export async function lsCommand(path: string, opts: LsOpts): Promise<void> {
107
126
  // ── Recursive tree ──────────────────────────────────────────
108
127
 
109
128
  const MAX_DEPTH = 5
110
- const MAX_NODES = 200
111
129
 
112
- type TreeNode = Item & { children?: TreeNode[] }
130
+ type TreeNode = Item & { children: TreeNode[] }
113
131
 
114
132
  async function recursiveLs(
115
133
  path: string,
@@ -121,8 +139,13 @@ async function recursiveLs(
121
139
 
122
140
  try {
123
141
  await withKernelClient(opts, async (ctx) => {
124
- const counter = { count: 0 }
125
- const tree = await withSelfHint(() => buildTree(ctx, path, 0, counter), meta)
142
+ // The recursive walk is now ONE function.get to the depth cap; the tree is
143
+ // reassembled client-side from the flat node page (was an N+1 buildTree).
144
+ const result = await withSelfHint(
145
+ () => bindGraph(ctx).query((q) => q.from(path).descend(MAX_DEPTH)),
146
+ meta,
147
+ )
148
+ const tree = buildTree(result.wire.nodes, path)
126
149
  spin?.succeed(`Tree of ${path}`)
127
150
  if (!machine) console.log('')
128
151
 
@@ -141,32 +164,30 @@ async function recursiveLs(
141
164
  }
142
165
  }
143
166
 
144
- async function buildTree(
145
- ctx: ClientContext,
146
- path: string,
147
- depth: number,
148
- counter: { count: number },
149
- ): Promise<TreeNode[]> {
150
- if (depth >= MAX_DEPTH || counter.count >= MAX_NODES) return []
151
- try {
152
- const result = await ctx.client.call(`${path}::listChildren`, {})
153
- const items = extractItems<Item>(result)
154
-
155
- const nodes: TreeNode[] = []
156
- for (const item of items) {
157
- if (counter.count >= MAX_NODES) break
158
- counter.count++
159
- const node: TreeNode = { ...item }
160
- // Descend by the child's absolute path (the kernel returns `path`, not `slug`).
161
- if (item.path && item.path !== '/') {
162
- node.children = await buildTree(ctx, item.path, depth + 1, counter)
163
- }
164
- nodes.push(node)
165
- }
166
- return nodes
167
- } catch {
168
- return []
167
+ /** Parent absolute path of an absolute node path (`/a/b` → `/a`; `/a` → `/`). */
168
+ function parentPathOf(path: string): string {
169
+ const i = path.lastIndexOf('/')
170
+ return i <= 0 ? '/' : path.slice(0, i)
171
+ }
172
+
173
+ /** Reassemble the flat `function.get` node page into a tree rooted at `rootPath`. */
174
+ function buildTree(nodes: readonly GraphNode[], rootPath: string): TreeNode[] {
175
+ const rootRaw = rawOf(rootPath)
176
+ const byPath = new Map<string, TreeNode>()
177
+ for (const n of nodes) {
178
+ const raw = rawOf(n.path)
179
+ if (raw === rootRaw) continue
180
+ byPath.set(raw, { ...n, children: [] })
181
+ }
182
+
183
+ const roots: TreeNode[] = []
184
+ for (const [raw, node] of byPath) {
185
+ const parent = parentPathOf(raw)
186
+ const parentNode = parent === rootRaw ? undefined : byPath.get(parent)
187
+ if (parentNode) parentNode.children.push(node)
188
+ else roots.push(node)
169
189
  }
190
+ return roots
170
191
  }
171
192
 
172
193
  function printTree(nodes: TreeNode[], prefix: string): void {
@@ -179,7 +200,7 @@ function printTree(nodes: TreeNode[], prefix: string): void {
179
200
  const name = basename(node.path) || node.id || '?'
180
201
  console.log(`${prefix}${connector}${chalk.cyan(name)} ${chalk.dim(classNameOf(node))}`)
181
202
 
182
- if (node.children && node.children.length > 0) {
203
+ if (node.children.length > 0) {
183
204
  printTree(node.children, prefix + childPrefix)
184
205
  }
185
206
  }
@@ -188,7 +209,7 @@ function printTree(nodes: TreeNode[], prefix: string): void {
188
209
  function printTreeQuiet(nodes: TreeNode[]): void {
189
210
  for (const node of nodes) {
190
211
  process.stdout.write(itemPath(node) + '\n')
191
- if (node.children) printTreeQuiet(node.children)
212
+ if (node.children.length > 0) printTreeQuiet(node.children)
192
213
  }
193
214
  }
194
215
 
@@ -197,13 +218,14 @@ export default {
197
218
  description: 'List children of a node',
198
219
  afterHelpText: `
199
220
  Behavior:
200
- Default output is a NAME/KIND/ID table on a TTY, JSON when piped. --filter
201
- matches a node KIND or label: Folder, Method, Domain. At a domain's tree
202
- position the children are Folder nodes (class.X), not Class so --filter
203
- Class returns nothing; use --filter Folder, or descend into class.<X> and
204
- --filter Method. -R tree view is TTY-only (raw/JSON emits the nested tree).
205
- -q prints one absolute path per line (pipeable). Note: ls /<domain> may
206
- report NOT_FOUND even when it exists use describe, or ls one of its children.
221
+ Default output is a NAME/KIND/ID table on a TTY, JSON when piped. One
222
+ function.get depth:1 fetches the direct children; -R fetches the whole
223
+ subtree (to depth ${MAX_DEPTH}) in a SINGLE call and renders it as a tree.
224
+ --filter matches a child KIND or label (post-filter): Folder, Function,
225
+ Domain. At a domain's tree position the children are Folder nodes
226
+ (class.X), not Class so --filter Class returns nothing; use --filter
227
+ Folder, or descend into class.<X>. -q prints one absolute path per line
228
+ (pipeable).
207
229
 
208
230
  Examples:
209
231
  $ astrale ls /
@@ -220,10 +242,10 @@ Examples:
220
242
  { flags: '--count', description: 'Print only the number of children' },
221
243
  {
222
244
  flags: '--filter <kind>',
223
- description: 'Filter children by kind or label (e.g., Folder, Method, Domain)',
245
+ description: 'Filter children by kind or label (e.g., Folder, Function, Domain)',
224
246
  },
225
247
  ],
226
248
  action: async (path, opts) => {
227
- await lsCommand((path as string | undefined) ?? '/', opts as Parameters<typeof lsCommand>[1])
249
+ await lsCommand((path as string | undefined) ?? '/', opts as LsOpts)
228
250
  },
229
251
  } satisfies CommandDefinition
@@ -0,0 +1,191 @@
1
+ import type { PatchInput } from '@astrale-os/kernel-client/graph'
2
+
3
+ import { patchDataSchema } from '@astrale-os/kernel-core'
4
+ import chalk from 'chalk'
5
+ import { readFile } from 'node:fs/promises'
6
+
7
+ import type { CommandDefinition } from '../command'
8
+ import type { KernelCommandOpts } from '../kernel'
9
+ import type { MutationResultWire } from '../kernel'
10
+
11
+ import { bindGraph, runKernelCommand } from '../kernel'
12
+ import { log } from '../lib/log'
13
+ import { output } from '../lib/output'
14
+ import { renderTable } from '../lib/table'
15
+
16
+ type MutateOpts = KernelCommandOpts & { data?: string; file?: string; dry?: boolean }
17
+
18
+ export async function mutateCommand(opts: MutateOpts): Promise<void> {
19
+ let raw: unknown
20
+ try {
21
+ raw = await readPatch(opts)
22
+ } catch (e) {
23
+ log.error(e instanceof Error ? e.message : 'Invalid patch')
24
+ process.exit(1)
25
+ return
26
+ }
27
+
28
+ // --dry: validate the patch locally against the kernel's own schema and print
29
+ // the normalized form (every arm defaulted to []). No kernel round-trip.
30
+ if (opts.dry) {
31
+ const parsed = patchDataSchema.safeParse(raw)
32
+ if (!parsed.success) {
33
+ log.error('Patch failed local validation:')
34
+ for (const issue of parsed.error.issues) {
35
+ log.dim(` ${issue.path.join('.') || '(root)'}: ${issue.message}`)
36
+ }
37
+ process.exit(1)
38
+ }
39
+ output(parsed.data, opts)
40
+ return
41
+ }
42
+
43
+ await runKernelCommand<MutationResultWire>({
44
+ opts,
45
+ label: 'Mutate',
46
+ fn: (ctx) => bindGraph(ctx).mutate(raw as PatchInput),
47
+ format: (result, fmtOpts, isRaw) => {
48
+ if (isRaw) {
49
+ output(result, fmtOpts)
50
+ return
51
+ }
52
+ printResult(result)
53
+ },
54
+ })
55
+ }
56
+
57
+ /** Patch source ladder (highest wins): --data > --file > stdin. */
58
+ async function readPatch(opts: MutateOpts): Promise<unknown> {
59
+ if (opts.data) {
60
+ try {
61
+ return JSON.parse(opts.data)
62
+ } catch {
63
+ throw new Error(`Invalid JSON in --data: ${opts.data}`)
64
+ }
65
+ }
66
+ if (opts.file) {
67
+ let text: string
68
+ try {
69
+ text = await readFile(opts.file, 'utf-8')
70
+ } catch (e) {
71
+ throw new Error(`Cannot read --file ${opts.file}: ${e instanceof Error ? e.message : e}`)
72
+ }
73
+ try {
74
+ return JSON.parse(text)
75
+ } catch {
76
+ throw new Error(`Invalid JSON in ${opts.file}`)
77
+ }
78
+ }
79
+ const stdin = await readStdin()
80
+ if (stdin) {
81
+ try {
82
+ return JSON.parse(stdin)
83
+ } catch {
84
+ throw new Error('Invalid JSON from stdin')
85
+ }
86
+ }
87
+ throw new Error(
88
+ 'No patch provided — pass --data <json>, --file <path>, or pipe a PatchData JSON on stdin',
89
+ )
90
+ }
91
+
92
+ async function readStdin(): Promise<string | null> {
93
+ if (process.stdin.isTTY) return null
94
+ const chunks: Buffer[] = []
95
+ for await (const chunk of process.stdin) {
96
+ chunks.push(chunk as Buffer)
97
+ }
98
+ const text = Buffer.concat(chunks).toString('utf-8').trim()
99
+ return text || null
100
+ }
101
+
102
+ function printResult(result: MutationResultWire): void {
103
+ const nodeRows = Object.entries(result.createdNodes ?? {})
104
+ const edgeRows = Object.entries(result.createdEdges ?? {})
105
+
106
+ if (nodeRows.length === 0 && edgeRows.length === 0) {
107
+ console.log(chalk.dim(' applied — no nodes or edges minted (updates/deletes only)'))
108
+ return
109
+ }
110
+
111
+ if (nodeRows.length > 0) {
112
+ console.log(` ${chalk.bold('Created nodes:')}`)
113
+ console.log(
114
+ renderTable(
115
+ nodeRows.map(([at, id]) => ({ at, id })),
116
+ {
117
+ columns: [
118
+ { key: 'at', header: 'AT', color: chalk.cyan },
119
+ { key: 'id', header: 'ID', color: chalk.dim },
120
+ ],
121
+ showHeader: true,
122
+ },
123
+ ),
124
+ )
125
+ console.log('')
126
+ }
127
+
128
+ if (edgeRows.length > 0) {
129
+ console.log(` ${chalk.bold('Created edges:')}`)
130
+ console.log(
131
+ renderTable(
132
+ edgeRows.map(([tuple, id]) => ({ tuple, id })),
133
+ {
134
+ columns: [
135
+ { key: 'tuple', header: 'CLASS|SOURCE|SLUG|TARGET', color: chalk.cyan },
136
+ { key: 'id', header: 'ID', color: chalk.dim },
137
+ ],
138
+ showHeader: true,
139
+ },
140
+ ),
141
+ )
142
+ console.log('')
143
+ }
144
+ }
145
+
146
+ export default {
147
+ name: 'mutate',
148
+ description: 'Apply a batch graph write (create/update/delete nodes & edges) via function.mutate',
149
+ afterHelpText: `
150
+ Behavior:
151
+ Sends a PatchData patch through the kernel's function.mutate door — a
152
+ single all-or-nothing write. Patch source (highest wins): --data > --file
153
+ > stdin. Prints the minted id maps (createdNodes: at→id, createdEdges:
154
+ class|source|slug|target → id); --json emits the raw MutationResult.
155
+
156
+ Authorization is per-arm: a create needs USE on the class and EDIT on the
157
+ parent, an update/delete needs EDIT on the target. A denied arm fails the
158
+ whole patch. --dry validates the patch locally (kernel patchDataSchema) and
159
+ prints the normalized form without touching the kernel.
160
+
161
+ PatchData shape:
162
+ {
163
+ "nodes": {
164
+ "create": [{ "class": "/:d:class.X", "at": "/d/x", "props": {} }],
165
+ "update": [{ "class": "/:d:class.X", "path": "/d/x", "props": {} }],
166
+ "delete": [{ "class": "/:d:class.X", "path": "/d/x" }]
167
+ },
168
+ "edges": {
169
+ "create": [{ "class": "/:d:class.e", "source": "/a", "target": "/b", "props": {} }],
170
+ "delete": [{ "class": "/:d:class.e", "source": "/a", "target": "/b" }]
171
+ }
172
+ }
173
+
174
+ Examples:
175
+ $ astrale mutate --data '{"nodes":{"create":[{"class":"/:blog.acme.com:class.Author","at":"/blog.acme.com/authors/ada","props":{}}]}}'
176
+ $ astrale mutate --file patch.json
177
+ $ echo '{"nodes":{"delete":[{"class":"/:d:class.X","path":"/d/x"}]}}' | astrale mutate
178
+ $ astrale mutate --file patch.json --dry
179
+ `,
180
+ options: [
181
+ { flags: '-d, --data <json>', description: 'PatchData as a JSON string' },
182
+ { flags: '-f, --file <path>', description: 'Read PatchData JSON from a file' },
183
+ {
184
+ flags: '--dry',
185
+ description: 'Validate locally (no kernel call) and print the normalized patch',
186
+ },
187
+ ],
188
+ action: async (opts) => {
189
+ await mutateCommand(opts as MutateOpts)
190
+ },
191
+ } satisfies CommandDefinition