@astrale-os/cli 0.4.0-alpha.13 → 0.6.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 (121) 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 +24396 -9632
  5. package/package.json +24 -22
  6. package/src/command.ts +2 -0
  7. package/src/commands/__tests__/admin-instance.test.ts +3 -2
  8. package/src/commands/__tests__/domain-list.test.ts +6 -2
  9. package/src/commands/__tests__/help-contract.test.ts +27 -14
  10. package/src/commands/__tests__/install-identity-override.test.ts +2 -2
  11. package/src/commands/__tests__/ls.test.ts +1 -1
  12. package/src/commands/__tests__/read-commands.test.ts +201 -0
  13. package/src/commands/__tests__/view.test.ts +100 -0
  14. package/src/commands/call.ts +27 -44
  15. package/src/commands/describe.ts +57 -58
  16. package/src/commands/domain/install.ts +9 -9
  17. package/src/commands/domain/list.ts +2 -2
  18. package/src/commands/domain/publish.ts +3 -3
  19. package/src/commands/get.ts +48 -23
  20. package/src/commands/identity/register.ts +27 -33
  21. package/src/commands/instance/active.ts +2 -2
  22. package/src/commands/instance/create.ts +1 -1
  23. package/src/commands/instance/delete.ts +3 -3
  24. package/src/commands/instance/list.ts +8 -3
  25. package/src/commands/instance/status.ts +4 -3
  26. package/src/commands/instance/use.ts +2 -2
  27. package/src/commands/logs.ts +8 -8
  28. package/src/commands/ls.ts +77 -55
  29. package/src/commands/mutate.ts +191 -0
  30. package/src/commands/query.ts +307 -20
  31. package/src/commands/session/analyze.ts +50 -0
  32. package/src/commands/session/list.ts +36 -0
  33. package/src/commands/token.ts +4 -7
  34. package/src/commands/view-serve.ts +26 -0
  35. package/src/commands/view.ts +614 -0
  36. package/src/connect-core.test.ts +42 -0
  37. package/src/connect-core.ts +53 -0
  38. package/src/kernel/__tests__/auth.test.ts +1 -0
  39. package/src/kernel/__tests__/expand.test.ts +123 -0
  40. package/src/kernel/client.ts +33 -37
  41. package/src/kernel/expand.ts +55 -61
  42. package/src/kernel/graph.ts +96 -0
  43. package/src/kernel/index.ts +18 -3
  44. package/src/kernel/options.ts +1 -1
  45. package/src/kernel/run.ts +0 -13
  46. package/src/lib/__tests__/instance-target.test.ts +34 -0
  47. package/src/lib/__tests__/table.test.ts +1 -1
  48. package/src/lib/__tests__/view-open-intent.test.ts +308 -0
  49. package/src/lib/__tests__/view-snapshot.test.ts +77 -0
  50. package/src/lib/admin-domain.ts +8 -4
  51. package/src/lib/admin-instance.ts +5 -1
  52. package/src/lib/config.ts +1 -0
  53. package/src/lib/domain-identity.ts +1 -1
  54. package/src/lib/instance-target.ts +5 -0
  55. package/src/lib/instance.ts +11 -0
  56. package/src/lib/log.ts +12 -2
  57. package/src/lib/login-flow.ts +41 -4
  58. package/src/lib/provision-instance.ts +2 -2
  59. package/src/lib/self.ts +1 -3
  60. package/src/lib/view/open-intent.ts +97 -0
  61. package/src/lib/view/resolve.ts +104 -0
  62. package/src/lib/view/server.ts +294 -0
  63. package/src/lib/view/session.ts +123 -0
  64. package/src/lib/view/snapshot.ts +101 -0
  65. package/src/program.ts +16 -3
  66. package/src/registry.ts +1 -1
  67. package/src/setup/render.ts +1 -3
  68. package/src/setup/steps/instance.ts +2 -2
  69. package/src/telemetry/__tests__/gate.test.ts +63 -0
  70. package/src/telemetry/__tests__/recorder.test.ts +110 -0
  71. package/src/telemetry/__tests__/redact.test.ts +79 -0
  72. package/src/telemetry/__tests__/session.test.ts +166 -0
  73. package/src/telemetry/__tests__/trigger.test.ts +49 -0
  74. package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
  75. package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
  76. package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
  77. package/src/telemetry/adapters/claude-code.ts +90 -0
  78. package/src/telemetry/adapters/codex.ts +160 -0
  79. package/src/telemetry/adapters/index.ts +45 -0
  80. package/src/telemetry/adapters/types.ts +21 -0
  81. package/src/telemetry/analyze.ts +227 -0
  82. package/src/telemetry/gate.ts +79 -0
  83. package/src/telemetry/recorder.ts +57 -0
  84. package/src/telemetry/redact.ts +53 -0
  85. package/src/telemetry/session.ts +88 -0
  86. package/src/telemetry/settings.ts +26 -0
  87. package/src/telemetry/store.ts +91 -0
  88. package/src/telemetry/trigger.ts +119 -0
  89. package/src/telemetry/types.ts +64 -0
  90. package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
  91. package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
  92. package/studio/client/dist/index.html +2 -2
  93. package/studio/server/agent/ask.ts +7 -2
  94. package/studio/server/agent/claude.ts +15 -3
  95. package/studio/server/agent/runner.ts +4 -1
  96. package/studio/server/agent/session-id.ts +13 -0
  97. package/studio/server/api.ts +50 -32
  98. package/studio/server/cache.ts +17 -6
  99. package/studio/server/client-package.test.ts +147 -0
  100. package/studio/server/client-package.ts +242 -0
  101. package/studio/server/index.ts +13 -0
  102. package/studio/server/introspect/anatomy-extras.test.ts +91 -0
  103. package/studio/server/introspect/anatomy-extras.ts +48 -49
  104. package/studio/server/introspect/anatomy.ts +7 -7
  105. package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
  106. package/studio/server/introspect/overlay-tsmorph.ts +230 -72
  107. package/studio/server/state/harness-gateway.ts +12 -3
  108. package/studio/server/state/harness-token.ts +0 -0
  109. package/studio/server/state/views.test.ts +90 -0
  110. package/studio/server/state/views.ts +396 -99
  111. package/studio/server/state/visibility.ts +10 -6
  112. package/studio/server/view-dev-server.test.ts +111 -0
  113. package/studio/server/view-dev-server.ts +372 -0
  114. package/studio/shared/types.ts +57 -10
  115. package/viewer/dist/index.html +93 -0
  116. package/viewer/dist/main.js +71 -0
  117. package/src/kernel/__tests__/remote-routing.test.ts +0 -70
  118. package/src/kernel/remote-routing.ts +0 -88
  119. package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
  120. package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
  121. package/studio/tsconfig.json +0 -23
@@ -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
@@ -1,32 +1,319 @@
1
- import { K } from '@astrale-os/kernel-core'
1
+ import { getInputSchema, K } from '@astrale-os/kernel-core'
2
+ import chalk from 'chalk'
2
3
 
3
4
  import type { CommandDefinition } from '../command'
4
- import type { KernelCommandOpts } from '../kernel'
5
+ import type { GetResultWire, KernelCommandOpts, QueryASTInput, SelfExpansionMeta } from '../kernel'
5
6
 
6
- import { runKernelCommand } from '../kernel'
7
+ import { bindGraph, expandSelfInPath, runKernelCommand, withSelfHint } from '../kernel'
8
+ import { log } from '../lib/log'
9
+ import { isMachine, output } from '../lib/output'
7
10
 
8
- export async function queryCommand(cypher: string, opts: KernelCommandOpts): Promise<void> {
9
- await runKernelCommand({
11
+ type QueryOpts = KernelCommandOpts & {
12
+ depth?: string
13
+ children?: string
14
+ edges?: string
15
+ ast?: string
16
+ cypher?: string
17
+ }
18
+
19
+ type QueryMode =
20
+ | { kind: 'cypher'; cypher: string }
21
+ | { kind: 'ast'; ast: QueryASTInput }
22
+ | { kind: 'roots'; roots: string[]; meta: SelfExpansionMeta | undefined; query: BuiltQuery }
23
+
24
+ export async function queryCommand(paths: string[], opts: QueryOpts): Promise<void> {
25
+ let mode: QueryMode
26
+ try {
27
+ mode = await parseMode(paths, opts)
28
+ } catch (e) {
29
+ log.error(e instanceof Error ? e.message : 'Invalid arguments')
30
+ process.exit(1)
31
+ return
32
+ }
33
+
34
+ if (mode.kind === 'cypher') {
35
+ await runKernelCommand({
36
+ opts,
37
+ label: 'Query',
38
+ fn: (ctx) => ctx.client.call(K.$.f('query').path.domain.raw, { cypher: mode.cypher }),
39
+ format: (result, fmtOpts) => output(result, fmtOpts),
40
+ })
41
+ return
42
+ }
43
+
44
+ await runKernelCommand<GetResultWire>({
10
45
  opts,
11
- label: 'Query',
12
- fn: (ctx) => ctx.client.call(K.Root.query.path.method.raw, { cypher }),
46
+ label: mode.kind === 'roots' ? 'Query ' + mode.roots.join(' ') : 'Query',
47
+ fn: async (ctx) => {
48
+ const read = () => bindGraph(ctx).query(mode.kind === 'roots' ? mode.query.ast : mode.ast)
49
+ const result = mode.kind === 'roots' ? await withSelfHint(read, mode.meta) : await read()
50
+ return result.wire
51
+ },
52
+ format: (result, fmtOpts) => {
53
+ output(result, fmtOpts)
54
+ if (result.next && !isMachine(fmtOpts)) printCursorFooter(result.next)
55
+ },
56
+ })
57
+ }
58
+
59
+ async function parseMode(paths: string[], opts: QueryOpts): Promise<QueryMode> {
60
+ const rootsInput = paths ?? []
61
+ const hasRoots = rootsInput.length > 0
62
+ const hasAst = opts.ast !== undefined
63
+ const hasCypher = opts.cypher !== undefined
64
+ const hasSelectors =
65
+ opts.depth !== undefined || opts.children !== undefined || opts.edges !== undefined
66
+
67
+ if (hasCypher) {
68
+ if (hasAst || hasRoots || hasSelectors) {
69
+ throw new Error('--cypher cannot be used with roots, --ast, --depth, --children, or --edges')
70
+ }
71
+ return { kind: 'cypher', cypher: opts.cypher as string }
72
+ }
73
+
74
+ if (hasAst) {
75
+ if (hasRoots || hasSelectors) {
76
+ throw new Error('--ast cannot be used with positional roots or --depth/--children/--edges')
77
+ }
78
+ return { kind: 'ast', ast: parseAst(opts.ast as string) }
79
+ }
80
+
81
+ if (!hasRoots) {
82
+ throw new Error(
83
+ 'Usage: astrale query <paths...> [--depth <n>] [--children <json>] [--edges <json>] | --ast <json> | --cypher <query>',
84
+ )
85
+ }
86
+
87
+ const { roots, meta } = await expandRoots(rootsInput, opts)
88
+ return { kind: 'roots', roots, meta, query: buildQuery(roots, opts) }
89
+ }
90
+
91
+ function parseAst(raw: string): QueryASTInput {
92
+ let parsed: unknown
93
+ try {
94
+ parsed = JSON.parse(raw)
95
+ } catch {
96
+ throw new Error('--ast must be JSON: ' + raw)
97
+ }
98
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
99
+ throw new Error('--ast must be a JSON object')
100
+ }
101
+ return parsed as QueryASTInput
102
+ }
103
+
104
+ async function expandRoots(
105
+ paths: string[],
106
+ opts: QueryOpts,
107
+ ): Promise<{ roots: string[]; meta: SelfExpansionMeta | undefined }> {
108
+ const roots: string[] = []
109
+ let meta: SelfExpansionMeta | undefined
110
+ for (const p of paths) {
111
+ const expanded = await expandSelfInPath(p, opts)
112
+ roots.push(expanded.path)
113
+ if (!meta && expanded.meta) meta = expanded.meta
114
+ }
115
+ return { roots, meta }
116
+ }
117
+
118
+ type QueryDir = 'in' | 'out' | 'both'
119
+ type QueryOrder = { by: string; dir: 'asc' | 'desc' }
120
+ type ChildrenSelector = { classes?: string[]; limit?: number; cursor?: string; order?: QueryOrder }
121
+ type EdgeSelector = {
122
+ as?: string
123
+ classes?: string[]
124
+ direction?: QueryDir
125
+ limit?: number
126
+ cursor?: string
127
+ order?: QueryOrder
128
+ }
129
+ type BuiltQuery = { ast: QueryASTInput; depth: number; hasEdges: boolean }
130
+
131
+ function buildQuery(roots: string[], opts: QueryOpts): BuiltQuery {
132
+ const depth = opts.depth !== undefined ? parseRange('--depth', opts.depth, 0, 5) : 0
133
+ const children =
134
+ opts.children !== undefined
135
+ ? parseSelector<ChildrenSelector>('--children', opts.children, getInputSchema.shape.children)
136
+ : undefined
137
+ const edges =
138
+ opts.edges !== undefined
139
+ ? parseSelector<EdgeSelector | EdgeSelector[]>(
140
+ '--edges',
141
+ opts.edges,
142
+ getInputSchema.shape.edges,
143
+ )
144
+ : undefined
145
+
146
+ const steps: NonNullable<QueryASTInput['steps']> = []
147
+ if (depth > 0) steps.push({ expand: childExpand(depth, children) })
148
+ edgeSelectors(edges).forEach((selector, index) => {
149
+ steps.push({ expand: edgeExpand(selector, index) })
13
150
  })
151
+
152
+ return {
153
+ ast: { version: 1, from: roots, ...(steps.length > 0 ? { steps } : {}) },
154
+ depth,
155
+ hasEdges: edges !== undefined,
156
+ }
157
+ }
158
+
159
+ function childExpand(depth: number, children: ChildrenSelector | undefined) {
160
+ return {
161
+ edge: 'has_parent',
162
+ dir: 'in' as const,
163
+ depth,
164
+ ...(children?.classes !== undefined ? { filter: { class: children.classes } } : {}),
165
+ ...pageFields(children),
166
+ ...(children?.order !== undefined ? { order: children.order } : {}),
167
+ }
168
+ }
169
+
170
+ function edgeExpand(selector: EdgeSelector, index: number) {
171
+ return {
172
+ ...(selector.classes !== undefined ? { edge: selector.classes } : {}),
173
+ dir: selector.direction ?? 'both',
174
+ as: selector.as ?? 'e' + index,
175
+ ...pageFields(selector),
176
+ ...(selector.order !== undefined ? { order: selector.order } : {}),
177
+ }
178
+ }
179
+
180
+ function pageFields(selector: { limit?: number; cursor?: string } | undefined) {
181
+ if (selector?.limit === undefined && selector?.cursor === undefined) return {}
182
+ return {
183
+ page: {
184
+ ...(selector.limit !== undefined ? { limit: selector.limit } : {}),
185
+ ...(selector.cursor !== undefined ? { cursor: selector.cursor } : {}),
186
+ },
187
+ }
188
+ }
189
+
190
+ function edgeSelectors(edges: EdgeSelector | EdgeSelector[] | undefined): EdgeSelector[] {
191
+ if (edges === undefined) return []
192
+ return Array.isArray(edges) ? edges : [edges]
193
+ }
194
+
195
+ function parseRange(flag: string, raw: string, min: number, max: number): number {
196
+ const n = Number(raw)
197
+ if (!Number.isInteger(n) || n < min || n > max) {
198
+ throw new Error(flag + ' needs an integer in [' + min + ', ' + max + '], got "' + raw + '"')
199
+ }
200
+ return n
201
+ }
202
+
203
+ type SelectorIssue = { readonly path: readonly PropertyKey[]; readonly message: string }
204
+ type SelectorSchema = {
205
+ safeParse(
206
+ value: unknown,
207
+ ): { success: true } | { success: false; error: { issues: readonly SelectorIssue[] } }
208
+ }
209
+
210
+ function parseSelector<T>(flag: string, raw: string, schema: SelectorSchema): T {
211
+ let parsed: unknown
212
+ try {
213
+ parsed = JSON.parse(raw)
214
+ } catch {
215
+ throw new Error(flag + ' must be JSON: ' + raw)
216
+ }
217
+ if (parsed === null || typeof parsed !== 'object') {
218
+ throw new Error(
219
+ flag + ' must be a JSON selector object' + (flag === '--edges' ? ' or array' : ''),
220
+ )
221
+ }
222
+ const check = schema.safeParse(parsed)
223
+ if (!check.success) {
224
+ const detail = check.error.issues
225
+ .map((i) => (i.path.join('.') || '(root)') + ': ' + i.message)
226
+ .join('; ')
227
+ throw new Error(flag + ' invalid selector: ' + detail)
228
+ }
229
+ return parsed as T
230
+ }
231
+
232
+ function printCursorFooter(next: NonNullable<GetResultWire['next']>): void {
233
+ const entries = Object.entries(next)
234
+ if (entries.length === 1) {
235
+ const cursors = entries[0]?.[1]
236
+ if (cursors?.children) {
237
+ process.stdout.write(
238
+ chalk.dim(' more: --children \'{"cursor":"' + cursors.children + '"}\'\n'),
239
+ )
240
+ }
241
+ for (const [alias, cursor] of Object.entries(cursors?.edges ?? {})) {
242
+ process.stdout.write(
243
+ chalk.dim(
244
+ ' more edges[' +
245
+ alias +
246
+ ']: --edges \'{"as":"' +
247
+ alias +
248
+ '","cursor":"' +
249
+ cursor +
250
+ '"}\'\n',
251
+ ),
252
+ )
253
+ }
254
+ return
255
+ }
256
+ process.stdout.write(
257
+ chalk.dim(' more results - per-root cursors in .next (page each root by --cursor)\n'),
258
+ )
14
259
  }
15
260
 
16
261
  export default {
17
262
  name: 'query',
18
- description: 'Run a read-only Cypher query against the kernel graph',
19
- afterHelpText: `
20
- Behavior:
21
- Read-only. The kernel rejects write keywords (CREATE, DELETE, SET,
22
- MERGE, REMOVE, DETACH); enforcement is kernel-side.
23
-
24
- Examples:
25
- $ astrale query 'MATCH (n) RETURN count(n) AS total'
26
- $ astrale query 'MATCH (n:Domain) RETURN n.slug, n.id'
27
- `,
28
- arguments: [{ name: 'cypher', description: 'Cypher query string' }],
29
- action: async (cypher, opts) => {
30
- await queryCommand(cypher as string, opts as KernelCommandOpts)
263
+ description: 'Run a structured graph read',
264
+ afterHelpText: [
265
+ '',
266
+ 'Behavior:',
267
+ ' Structured read door for the query AST. Positional roots build a v1 AST',
268
+ ' with optional child and edge expansion, then lower through function.get',
269
+ ' today. A true query syscall may back this command later.',
270
+ '',
271
+ ' Output is always the full GraphData envelope { nodes, edges, aliases }',
272
+ ' with .roots and .next when returned. On a TTY, cursor footers are printed',
273
+ ' when .next has more pages.',
274
+ '',
275
+ ' --children takes { classes?, limit?, cursor?, order? } and shapes the',
276
+ ' depth-1 children page (needs --depth >= 1 to bite). --edges takes an edge',
277
+ ' selector, or a JSON array of selectors.',
278
+ '',
279
+ ' --ast (experimental) accepts a raw QueryASTInput JSON object. The AST shape',
280
+ ' is not a stable contract yet — prefer the flags above. @self is not expanded',
281
+ ' inside --ast JSON.',
282
+ '',
283
+ ' --cypher is a read-only escape hatch. The kernel rejects write keywords',
284
+ ' such as CREATE, DELETE, SET, MERGE, REMOVE, and DETACH.',
285
+ '',
286
+ 'Examples:',
287
+ ' $ astrale query / --depth 1',
288
+ ' $ astrale query /a /b --edges \'{"direction":"both"}\'',
289
+ ' $ astrale query /kernel.astrale.ai --depth 2 --children \'{"classes":["/:kernel.astrale.ai:class.Folder"]}\'',
290
+ " $ astrale query --cypher 'MATCH (n) RETURN count(n) AS total'",
291
+ '',
292
+ ].join('\n'),
293
+ arguments: [
294
+ {
295
+ name: 'paths...',
296
+ description: 'One or more root paths (/domain/Class) or IDs (@nodeId)',
297
+ required: false,
298
+ },
299
+ ],
300
+ options: [
301
+ { flags: '--depth <n>', description: 'Subtree depth to fetch (0-5, default 0)' },
302
+ {
303
+ flags: '--children <json>',
304
+ description: 'Children selector { classes?, limit?, cursor?, order? } (needs --depth >= 1)',
305
+ },
306
+ {
307
+ flags: '--edges <json>',
308
+ description: 'Edge selector (or JSON array of selectors) to include',
309
+ },
310
+ {
311
+ flags: '--ast <json>',
312
+ description: 'Raw QueryASTInput JSON object (experimental, unstable shape)',
313
+ },
314
+ { flags: '--cypher <query>', description: 'Read-only Cypher escape hatch' },
315
+ ],
316
+ action: async (paths, opts) => {
317
+ await queryCommand(Array.isArray(paths) ? paths : [], opts as QueryOpts)
31
318
  },
32
319
  } satisfies CommandDefinition
@@ -0,0 +1,50 @@
1
+ import chalk from 'chalk'
2
+
3
+ import type { CommandDefinition } from '../../command'
4
+
5
+ import { log } from '../../lib/log'
6
+ import { analyzeSession } from '../../telemetry/analyze'
7
+ import { listSessions } from '../../telemetry/store'
8
+ import { restampLock, releaseLock } from '../../telemetry/trigger'
9
+
10
+ export default {
11
+ name: 'analyze',
12
+ 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
+ options: [
15
+ { flags: '--file', description: 'File cleared findings as issues on the admin tracker' },
16
+ { flags: '--model <model>', description: 'Model for the analyzer pass' },
17
+ { flags: '--force', description: 'Re-analyze even if already analyzed' },
18
+ { flags: '--auto', description: 'Triggered mode: quiet, used by the opportunistic trigger' },
19
+ ],
20
+ action: async (
21
+ id: string | undefined,
22
+ opts: { file?: boolean; model?: string; force?: boolean; auto?: boolean },
23
+ ) => {
24
+ if (opts.auto) restampLock()
25
+ try {
26
+ let target = id
27
+ if (!target) {
28
+ const candidate = listSessions().find((s) => s.closed && (opts.force || !s.analyzed))
29
+ if (!candidate) {
30
+ if (!opts.auto) log.dim('Nothing to analyze — no closed, unanalyzed session.')
31
+ return
32
+ }
33
+ target = candidate.id
34
+ }
35
+ if (!opts.auto) log.step(`analyzing session ${target} …`)
36
+ const outcome = await analyzeSession(target, opts)
37
+ if (opts.auto) return
38
+ if (outcome.outcome === 'skipped-quiet') {
39
+ log.dim(`quiet session (${outcome.note}) — nothing to analyze`)
40
+ } else if (outcome.outcome === 'error') {
41
+ log.error(`analyzer failed: ${outcome.note ?? 'unknown'}`)
42
+ } else {
43
+ log.success(`${outcome.outcome} (${outcome.note ?? ''})`)
44
+ if (outcome.reportPath) console.log(chalk.dim(` ${outcome.reportPath}`))
45
+ }
46
+ } finally {
47
+ if (opts.auto) releaseLock()
48
+ }
49
+ },
50
+ } satisfies CommandDefinition
@@ -0,0 +1,36 @@
1
+ import chalk from 'chalk'
2
+
3
+ import type { CommandDefinition } from '../../command'
4
+
5
+ import { log } from '../../lib/log'
6
+ import { isMachine, output, RAW_OUTPUT_OPTIONS } from '../../lib/output'
7
+ import { listSessions } from '../../telemetry/store'
8
+
9
+ export default {
10
+ name: 'list',
11
+ description: 'List locally recorded work sessions and their analysis state',
12
+ options: [...RAW_OUTPUT_OPTIONS],
13
+ action: async (opts: { raw?: boolean; json?: boolean }) => {
14
+ const sessions = listSessions()
15
+ if (isMachine(opts)) {
16
+ output(sessions, opts)
17
+ return
18
+ }
19
+ if (sessions.length === 0) {
20
+ log.dim('No sessions recorded yet. Run any astrale command in a workspace to start one.')
21
+ return
22
+ }
23
+ for (const s of sessions) {
24
+ const state = s.analyzed
25
+ ? s.analyzed.outcome === 'error'
26
+ ? chalk.red(s.analyzed.outcome)
27
+ : chalk.green(s.analyzed.outcome)
28
+ : s.closed
29
+ ? chalk.yellow('closed, unanalyzed')
30
+ : chalk.cyan('open')
31
+ const when = s.lastEventAt ? s.lastEventAt.toISOString().slice(0, 16) : 'no events'
32
+ console.log(`${chalk.bold(s.id)} ${state}`)
33
+ console.log(chalk.dim(` ${s.meta?.root ?? '(unknown root)'} — last activity ${when}`))
34
+ }
35
+ },
36
+ } satisfies CommandDefinition
@@ -2,12 +2,11 @@ import type { CommandDefinition } from '../command'
2
2
  import type { KernelCommandOpts } from '../kernel'
3
3
 
4
4
  import { runKernelCommand } from '../kernel'
5
- import { mintDelegationPath } from '../kernel/remote-routing'
6
5
  import { log } from '../lib/log'
7
6
 
8
7
  /**
9
8
  * `astrale token` — mint a fresh delegation token for the active instance
10
- * + active identity (§2.5). Shortcut over the mintDelegationCredential call.
9
+ * + active identity through the bound AuthApi.
11
10
  */
12
11
  export type TokenOpts = KernelCommandOpts & {
13
12
  audience?: string
@@ -26,12 +25,10 @@ export async function tokenCommand(opts: TokenOpts): Promise<void> {
26
25
  const audience = opts.audience ?? ''
27
26
  const parsedTtl = Number(opts.ttl)
28
27
  const ttl = Number.isFinite(parsedTtl) && parsedTtl > 0 ? parsedTtl : 3600
29
- const mintPath = await mintDelegationPath(ctx.client, ctx.credential)
30
- const result = (await ctx.client.call(mintPath, {
28
+ const result = await ctx.client.as(ctx.credential).auth.delegate({
31
29
  audience,
32
- delegation: { kind: 'identity', self: true },
33
30
  ttl,
34
- })) as string
31
+ })
35
32
  return result
36
33
  },
37
34
  format: (token, fmtOpts, isRaw) => {
@@ -63,7 +60,7 @@ What this token is FOR — worker-direct HTTP calls:
63
60
  use 'astrale call' (which signs per-call) instead.
64
61
 
65
62
  Examples:
66
- $ export TOKEN=$(astrale token --audience dist.astrale.ai --raw)
63
+ $ export TOKEN=$(astrale token --audience shell.astrale.ai --raw)
67
64
  $ astrale token --audience worker.example.com --for alice -i staging
68
65
  `,
69
66
  options: [
@@ -0,0 +1,26 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ import type { CommandDefinition } from '../command'
4
+ import type { ViewServeConfig } from '../lib/view/session'
5
+
6
+ import { startViewServer } from '../lib/view/server'
7
+
8
+ /**
9
+ * Internal (hidden) entry for the detached view-session server: `astrale view`
10
+ * re-invokes the CLI with `__view-serve --config <file>` so the server runs
11
+ * under the same runtime and codebase as the CLI itself.
12
+ */
13
+ export default {
14
+ name: '__view-serve',
15
+ hidden: true,
16
+ description: 'Internal: run a view-session server from a config file',
17
+ options: [{ flags: '--config <path>', description: 'ViewServeConfig JSON path' }],
18
+ action: async (opts: { config?: string }) => {
19
+ if (!opts.config) throw new Error('--config is required')
20
+ const config = JSON.parse(await readFile(opts.config, 'utf8')) as ViewServeConfig
21
+ startViewServer(config)
22
+ console.log(`view session ${config.session.id} listening on ${config.session.pageUrl}`)
23
+ // Keep the process alive; startViewServer owns shutdown (signals + idle).
24
+ await new Promise(() => {})
25
+ },
26
+ } satisfies CommandDefinition