@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
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.6.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,33 +23,23 @@
23
23
  "studio/server",
24
24
  "studio/shared",
25
25
  "studio/client/dist",
26
- "studio/package.json"
26
+ "studio/package.json",
27
+ "viewer/dist",
28
+ "THIRD-PARTY-NOTICES.md"
27
29
  ],
28
30
  "type": "module",
29
31
  "exports": {
30
32
  "./keys": "./src/lib/keys.ts",
31
- "./paths": "./src/lib/paths.ts"
33
+ "./paths": "./src/lib/paths.ts",
34
+ "./connect-core": "./src/connect-core.ts"
32
35
  },
33
36
  "publishConfig": {
34
37
  "access": "public",
35
38
  "registry": "https://registry.npmjs.org"
36
39
  },
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
40
  "dependencies": {
51
- "@astrale-os/kernel-client": ">=0.2.0 <1.0.0",
52
- "@astrale-os/kernel-core": ">=0.4.1 <1.0.0",
41
+ "@astrale-os/kernel-client": ">=0.3.1 <1.0.0",
42
+ "@astrale-os/kernel-core": ">=0.7.0 <1.0.0",
53
43
  "@inquirer/prompts": "^8.5.2",
54
44
  "chalk": "^5.6.2",
55
45
  "chokidar": "^4.0.3",
@@ -62,7 +52,8 @@
62
52
  },
63
53
  "devDependencies": {
64
54
  "@astrale-os/ox": ">=0.1.0 <1.0.0",
65
- "@astrale/commitlint-config": "jsr:~2.0.1",
55
+ "@astrale-os/shell": ">=0.3.5 <1.0.0",
56
+ "@astrale/commitlint-config": "npm:@jsr/astrale__commitlint-config@~2.0.1",
66
57
  "@commitlint/cli": "~20.3.1",
67
58
  "@commitlint/config-conventional": "~20.3.1",
68
59
  "@types/bun": "^1.1.16",
@@ -70,6 +61,7 @@
70
61
  "@typescript/native-preview": "latest",
71
62
  "husky": "~9.1.7",
72
63
  "lint-staged": "~16.2.7",
64
+ "msgpackr": "^1.12.1",
73
65
  "oxfmt": "0.52.0",
74
66
  "oxlint": "latest",
75
67
  "typescript": "~6.0.1-rc"
@@ -86,5 +78,15 @@
86
78
  "engines": {
87
79
  "node": ">=20"
88
80
  },
89
- "packageManager": "pnpm@10.28.0"
90
- }
81
+ "scripts": {
82
+ "preinstall": "node .check-workspace.cjs",
83
+ "build": "bun scripts/build.ts",
84
+ "typecheck": "tsgo --noEmit && tsgo --noEmit -p viewer",
85
+ "lint": "oxlint .",
86
+ "lint:fix": "oxlint --fix .",
87
+ "format": "pnpm exec oxfmt --write .",
88
+ "format:check": "pnpm exec oxfmt --check .",
89
+ "test": "bun test src",
90
+ "test:watch": "bun test --watch src"
91
+ }
92
+ }
package/src/command.ts CHANGED
@@ -16,6 +16,8 @@ export type CommandDefinition = {
16
16
  description: string
17
17
  /** One-line summary shown in the parent's command list (Commander `.summary()`). */
18
18
  summary?: string
19
+ /** Hide from help listings (internal entries, e.g. `__view-serve`). */
20
+ hidden?: boolean
19
21
  aliases?: string[]
20
22
  arguments?: CommandArgument[]
21
23
  options?: CommandOption[]
@@ -3,12 +3,13 @@ import { describe, expect, test } from 'bun:test'
3
3
  import type { IdentityStore } from '../../lib/identity'
4
4
 
5
5
  import { AuthError } from '../../errors'
6
- import { ADMIN_INSTANCE } from '../../lib/admin-instance'
6
+ import { ADMIN_INSTANCE, adminInstanceMethod } from '../../lib/admin-instance'
7
7
  import { assertAlphaCreateIdentity } from '../instance/create'
8
8
 
9
9
  describe('admin-backed instance commands', () => {
10
10
  test('target the merged Instance class', () => {
11
- expect(ADMIN_INSTANCE).toBe('/admin.astrale.ai/class.Instance')
11
+ expect(ADMIN_INSTANCE).toBe('/:admin.astrale.ai:class.Instance')
12
+ expect(adminInstanceMethod('list')).toBe('/:admin.astrale.ai:class.Instance:list')
12
13
  })
13
14
 
14
15
  test('instance create preflight points fresh installs at WorkOS login', () => {
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
2
 
3
- import type { DomainInfo } from '../../lib/admin-domain'
4
-
3
+ import { ADMIN_DOMAIN, adminDomainMethod, type DomainInfo } from '../../lib/admin-domain'
5
4
  import { byDefaultThenName, domainProjection, type DomainRow } from '../domain/list'
6
5
 
7
6
  const strip = (s: string): string => s.replace(/\[[0-9;]*m/g, '')
@@ -18,6 +17,11 @@ function entry(over: Partial<DomainInfo>): DomainInfo {
18
17
  }
19
18
 
20
19
  describe('domain list — ordering', () => {
20
+ test('uses the semantic static method path', () => {
21
+ expect(ADMIN_DOMAIN).toBe('/:admin.astrale.ai:class.DomainEntry')
22
+ expect(adminDomainMethod('list')).toBe('/:admin.astrale.ai:class.DomainEntry:list')
23
+ })
24
+
21
25
  test('install-by-default sorts first, then alphabetical by origin', () => {
22
26
  const rows = [
23
27
  entry({ origin: 'zeta.astrale.ai' }),
@@ -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('shell.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
+ })
@@ -0,0 +1,100 @@
1
+ import { beforeEach, describe, expect, mock, test } from 'bun:test'
2
+
3
+ const getMock = mock(async (_path: string) => ({ id: 'issue-id' }))
4
+ const clientCallMock = mock(async (): Promise<unknown> => {
5
+ throw new Error('View:resolve must not run for a bare --view-url')
6
+ })
7
+ const abMock = mock(async (_args: string[]) => ({
8
+ ok: true,
9
+ data: { snapshot: '- button "Ready" [ref=e1]' },
10
+ error: null,
11
+ }))
12
+
13
+ mock.module('../../kernel', () => ({
14
+ bindGraph: () => ({ get: getMock }),
15
+ expandSelfInPath: async (path: string) => ({ path }),
16
+ resolveKernelTarget: mock(),
17
+ withKernelClient: async (
18
+ _opts: unknown,
19
+ run: (ctx: { client: { call: typeof clientCallMock } }) => Promise<unknown>,
20
+ ) => run({ client: { call: clientCallMock } }),
21
+ }))
22
+
23
+ mock.module('../../lib/browser', () => ({
24
+ ab: abMock,
25
+ AGENT_BROWSER_REPO: 'vercel-labs/agent-browser',
26
+ BROWSER_DIR: '/tmp/astrale-browser',
27
+ findAgentBrowser: mock(async () => '/usr/bin/agent-browser'),
28
+ }))
29
+
30
+ beforeEach(() => {
31
+ getMock.mockClear()
32
+ clientCallMock.mockClear()
33
+ abMock.mockClear()
34
+ })
35
+
36
+ describe('view session resolution', () => {
37
+ test('uses a bare --view-url target only as shell context', async () => {
38
+ const { resolveSession } = await import('../view')
39
+ const targetPath = '/users/user-1/issue-1'
40
+
41
+ const result = await resolveSession(undefined, {
42
+ viewUrl: 'http://127.0.0.1:5173/ui/issues',
43
+ handshake: 'shell',
44
+ target: targetPath,
45
+ })
46
+
47
+ expect(getMock).toHaveBeenCalledWith(targetPath)
48
+ expect(clientCallMock).not.toHaveBeenCalled()
49
+ expect(result).toEqual({
50
+ view: {
51
+ url: 'http://127.0.0.1:5173/ui/issues',
52
+ functionId: 'dev-view',
53
+ handshake: 'shell',
54
+ name: 'dev',
55
+ },
56
+ target: { id: 'issue-id', path: targetPath },
57
+ candidates: [],
58
+ })
59
+ })
60
+
61
+ test('lists every candidate without requiring a view selection', async () => {
62
+ const { resolveSession } = await import('../view')
63
+ const targetPath = '/domains/ai-gateway.astrale.ai/core/gemini-3-5-flash'
64
+ const candidates = [
65
+ {
66
+ id: 'chat-id',
67
+ path: '/domains/ai-gateway.astrale.ai/views/chat',
68
+ url: 'https://ai-gateway.astrale.ai/ui/chat',
69
+ origin: 'class' as const,
70
+ },
71
+ {
72
+ id: 'model-id',
73
+ path: '/domains/ai-gateway.astrale.ai/views/model',
74
+ url: 'https://ai-gateway.astrale.ai/ui/model',
75
+ origin: 'class' as const,
76
+ },
77
+ ]
78
+ clientCallMock.mockImplementationOnce(async () => candidates)
79
+
80
+ const result = await resolveSession(targetPath, { list: true })
81
+
82
+ expect(result).toEqual({
83
+ target: { id: 'issue-id', path: targetPath },
84
+ candidates,
85
+ })
86
+ })
87
+ })
88
+
89
+ describe('view capture timing', () => {
90
+ test('settles the view before taking a screenshot', async () => {
91
+ const { runSnapshotExtras } = await import('../view')
92
+
93
+ await runSnapshotExtras({ screenshot: '/tmp/view.png' })
94
+
95
+ const commands = abMock.mock.calls.map(([args]) => args[0])
96
+ expect(commands.at(-1)).toBe('screenshot')
97
+ expect(commands.slice(0, -1).every((command) => command === 'snapshot')).toBe(true)
98
+ expect(commands.length).toBeGreaterThan(2)
99
+ })
100
+ })