@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,614 @@
1
+ import chalk from 'chalk'
2
+ import { randomBytes } from 'node:crypto'
3
+ import { closeSync, existsSync, openSync, statSync } from 'node:fs'
4
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
5
+ import { dirname, join } from 'node:path'
6
+
7
+ import type { CommandDefinition } from '../command'
8
+ import type { KernelCommandOpts } from '../kernel'
9
+ import type { ViewServeConfig, ViewSessionRecord } from '../lib/view/session'
10
+
11
+ import { AstraleError } from '../errors'
12
+ import { bindGraph, expandSelfInPath, resolveKernelTarget, withKernelClient } from '../kernel'
13
+ import { ab, AGENT_BROWSER_REPO, BROWSER_DIR, findAgentBrowser } from '../lib/browser'
14
+ import { readConfig } from '../lib/config'
15
+ import { readIdentities } from '../lib/identity'
16
+ import { readInstances } from '../lib/instance'
17
+ import { fatal, log } from '../lib/log'
18
+ import { isMachine, output, type RawOutputOpts } from '../lib/output'
19
+ import { findFreePort } from '../lib/port'
20
+ import { run, spawnHandle } from '../lib/proc'
21
+ import {
22
+ applyViewUrlOverride,
23
+ candidateSlug,
24
+ parseViewSpec,
25
+ pickCandidate,
26
+ resolveViewCandidates,
27
+ rewriteLocalViewUrl,
28
+ type ViewCandidate,
29
+ } from '../lib/view/resolve'
30
+ import { ensureViewerAssets } from '../lib/view/server'
31
+ import {
32
+ closeSession,
33
+ configPath,
34
+ listSessions,
35
+ logPath,
36
+ saveRecord,
37
+ VIEW_DIR,
38
+ } from '../lib/view/session'
39
+ import { snapshotText, waitForSettledSnapshot } from '../lib/view/snapshot'
40
+
41
+ /**
42
+ * `astrale view` — open ONE view in an emulated host shell, authenticated as
43
+ * the CLI identity, driveable by agent-browser (default) or a real browser.
44
+ * Design + protocol details: VIEW_CLI_SPEC.md at the workspace root.
45
+ */
46
+
47
+ type ViewOpts = KernelCommandOpts &
48
+ RawOutputOpts & {
49
+ target?: string
50
+ view?: string
51
+ list?: boolean
52
+ viewUrl?: string
53
+ handshake?: 'shell' | 'none'
54
+ headed?: boolean
55
+ browser?: boolean
56
+ open?: boolean
57
+ snapshot?: boolean
58
+ screenshot?: string
59
+ sessions?: boolean
60
+ close?: string | boolean
61
+ all?: boolean
62
+ }
63
+
64
+ const VIEW_PORT_BASE = 4419
65
+ const VIEW_PORT_SPAN = 20
66
+ const IDLE_MS = 30 * 60_000
67
+ const READY_TIMEOUT_MS = 8000
68
+ const STATE_TIMEOUT_MS = 25_000
69
+ const POLL_MS = 250
70
+ /** Dedicated agent-browser profile for view sessions (no cookies involved). */
71
+ const VIEW_PROFILE = `${BROWSER_DIR}/_view`
72
+
73
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
74
+
75
+ type ResolvedTarget = { id: string; path: string }
76
+ type ResolvedView = {
77
+ url: string
78
+ functionId: string
79
+ handshake: 'shell' | 'none'
80
+ path?: string
81
+ name?: string
82
+ }
83
+
84
+ export async function resolveSession(
85
+ spec: string | undefined,
86
+ opts: ViewOpts,
87
+ ): Promise<{ view?: ResolvedView; target?: ResolvedTarget; candidates: ViewCandidate[] }> {
88
+ const parsed = spec ? parseViewSpec(spec) : undefined
89
+ if (parsed?.kind === 'target' && opts.target) {
90
+ fatal(new Error('Pass the target either as the positional or as --target, not both'))
91
+ }
92
+ const targetInput = parsed?.kind === 'target' ? parsed.path : opts.target
93
+
94
+ const resolved = await withKernelClient(opts, async (ctx) => {
95
+ let target: ResolvedTarget | undefined
96
+ if (targetInput) {
97
+ const { path } = await expandSelfInPath(targetInput, opts)
98
+ const node = (await bindGraph(ctx).get(path)) as { id?: string } | null
99
+ if (!node?.id) {
100
+ throw new AstraleError('NOT_FOUND', `target ${path} not found or not visible`)
101
+ }
102
+ target = { id: node.id, path }
103
+ }
104
+ // A bare --view-url already identifies the frontend. Its target is only
105
+ // shell context, so asking View:resolve for installed candidates is both
106
+ // unnecessary and incorrect for classes without a class-owned self view.
107
+ const anchor = parsed ? (parsed.kind === 'view' ? parsed.path : target?.path) : undefined
108
+ const candidates = anchor ? await resolveViewCandidates(ctx, anchor) : []
109
+ return { target, candidates }
110
+ })
111
+
112
+ // Bare --view-url: mount an arbitrary URL as a view (nothing installed yet).
113
+ if (!parsed) {
114
+ return {
115
+ view: {
116
+ url: rewriteLocalViewUrl(opts.viewUrl!),
117
+ functionId: 'dev-view',
118
+ handshake: opts.handshake ?? 'shell',
119
+ name: 'dev',
120
+ },
121
+ target: resolved.target,
122
+ candidates: [],
123
+ }
124
+ }
125
+
126
+ const anchor = parsed.kind === 'view' ? parsed.path : resolved.target!.path
127
+ if (opts.list) {
128
+ return { target: resolved.target, candidates: resolved.candidates }
129
+ }
130
+ const picked = await chooseCandidate(resolved.candidates, anchor, opts)
131
+ let url = picked.url
132
+ if (opts.viewUrl) url = applyViewUrlOverride(url, opts.viewUrl)
133
+ url = rewriteLocalViewUrl(url)
134
+ return {
135
+ view: {
136
+ url,
137
+ functionId: picked.id,
138
+ handshake: opts.handshake ?? picked.handshake ?? 'shell',
139
+ path: picked.path,
140
+ name: candidateSlug(picked),
141
+ },
142
+ target: resolved.target,
143
+ candidates: resolved.candidates,
144
+ }
145
+ }
146
+
147
+ async function chooseCandidate(
148
+ candidates: ViewCandidate[],
149
+ anchor: string,
150
+ opts: ViewOpts,
151
+ ): Promise<ViewCandidate> {
152
+ const picked = pickCandidate(candidates, anchor, opts.view)
153
+ if (picked !== 'ambiguous') return picked
154
+ if (process.stdin.isTTY && !isMachine(opts)) {
155
+ const { select } = await import('@inquirer/prompts')
156
+ return select({
157
+ message: `${anchor} has ${candidates.length} views — open which?`,
158
+ choices: candidates.map((c) => ({
159
+ name: `${candidateSlug(c)} ${chalk.dim(c.url)}`,
160
+ value: c,
161
+ })),
162
+ })
163
+ }
164
+ throw new AstraleError(
165
+ 'AMBIGUOUS_VIEW',
166
+ `${anchor} resolves ${candidates.length} views — pick one with --view <slug>: ${candidates.map(candidateSlug).join(', ')}`,
167
+ )
168
+ }
169
+
170
+ /**
171
+ * The session server must run under NODE when possible: an orphaned Bun
172
+ * process on macOS cannot open TLS sockets at all (its TLS init needs the
173
+ * user session's trust services), so token mints and the kernel proxy would
174
+ * die once the CLI exits. The published CLI entry is node-runnable; a dev
175
+ * checkout builds `dist/astrale.js` on demand (Bun is present there).
176
+ */
177
+ async function resolveServeRuntime(): Promise<{ file: string; args: string[] }> {
178
+ const entry = process.argv[1]
179
+ const node = await findOnPath('node')
180
+ if (node && entry?.endsWith('.js') && existsSync(entry)) return { file: node, args: [entry] }
181
+ if (node && entry?.endsWith('.ts')) {
182
+ const dist = join(dirname(entry), '..', 'dist', 'astrale.js')
183
+ await ensureDevDist(entry, dist)
184
+ if (existsSync(dist)) return { file: node, args: [dist] }
185
+ }
186
+ return { file: process.execPath, args: entry && existsSync(entry) ? [entry] : [] }
187
+ }
188
+
189
+ async function findOnPath(name: string): Promise<string | null> {
190
+ const lookup =
191
+ process.platform === 'win32' ? run('where', [name]) : run('sh', ['-c', `command -v ${name}`])
192
+ const res = await lookup.catch(() => null)
193
+ if (!res || res.code !== 0) return null
194
+ return res.stdout.split(/\r?\n/)[0]?.trim() || null
195
+ }
196
+
197
+ /** Dev checkout: (re)build the node-runnable CLI bundle when missing or stale. */
198
+ async function ensureDevDist(entry: string, dist: string): Promise<void> {
199
+ const bun = (
200
+ globalThis as {
201
+ Bun?: { build: (o: object) => Promise<{ success: boolean; logs: unknown[] }> }
202
+ }
203
+ ).Bun
204
+ if (!bun) return
205
+ const srcDir = join(dirname(entry), '..', 'src')
206
+ if (existsSync(dist) && !(await newerThan(srcDir, statSync(dist).mtimeMs))) return
207
+ // stderr: --json consumers parse stdout.
208
+ console.error('(dev) building dist/astrale.js for the session server…')
209
+ await bun.build({
210
+ entrypoints: [entry],
211
+ outdir: dirname(dist),
212
+ target: 'node',
213
+ format: 'esm',
214
+ })
215
+ }
216
+
217
+ async function newerThan(dir: string, mtimeMs: number): Promise<boolean> {
218
+ const entries = await readdir(dir, { withFileTypes: true, recursive: true })
219
+ for (const item of entries) {
220
+ if (!item.isFile()) continue
221
+ if (statSync(join(item.parentPath, item.name)).mtimeMs > mtimeMs) return true
222
+ }
223
+ return false
224
+ }
225
+
226
+ /** Spawn the detached session server (the CLI re-invoking itself) and wait for it. */
227
+ async function startSession(
228
+ view: ResolvedView,
229
+ target: ResolvedTarget | undefined,
230
+ opts: ViewOpts,
231
+ ): Promise<ViewSessionRecord> {
232
+ await ensureViewerAssets()
233
+ const config = await readConfig()
234
+ const kernelTarget = await resolveKernelTarget(opts, config)
235
+ const port = await findFreePort(VIEW_PORT_BASE, VIEW_PORT_SPAN)
236
+ if (port === null) {
237
+ fatal(
238
+ new Error(
239
+ `No free port in ${VIEW_PORT_BASE}-${VIEW_PORT_BASE + VIEW_PORT_SPAN - 1} — close sessions with \`astrale view --close --all\``,
240
+ ),
241
+ )
242
+ }
243
+
244
+ const [instances, identities] = await Promise.all([readInstances(), readIdentities()])
245
+ const id = `v-${randomBytes(3).toString('hex')}`
246
+ const nonce = randomBytes(12).toString('hex')
247
+ const record: ViewSessionRecord = {
248
+ id,
249
+ pid: 0,
250
+ port,
251
+ nonce,
252
+ pageUrl: `http://127.0.0.1:${port}/s/${nonce}/`,
253
+ view,
254
+ target,
255
+ instance: opts.instance ?? (opts.url ? opts.url : instances.active),
256
+ identity: opts.creds ? '(pre-signed creds)' : (opts.as ?? identities.default),
257
+ createdAt: new Date().toISOString(),
258
+ }
259
+ const serveConfig: ViewServeConfig = {
260
+ session: record,
261
+ kernel: {
262
+ url: opts.url,
263
+ instance: opts.instance,
264
+ as: opts.as,
265
+ creds: opts.creds,
266
+ timeout: opts.timeout,
267
+ },
268
+ proxy: {
269
+ kernelUrl: kernelTarget.url,
270
+ caFile: kernelTarget.caFile,
271
+ direct: isPublicHttps(kernelTarget.url) && !kernelTarget.caFile,
272
+ },
273
+ idleMs: IDLE_MS,
274
+ }
275
+
276
+ await mkdir(VIEW_DIR, { recursive: true })
277
+ await writeFile(configPath(id), JSON.stringify(serveConfig, null, 2))
278
+ const logFd = openSync(logPath(id), 'a')
279
+ const runtime = await resolveServeRuntime()
280
+ const child = spawnHandle(
281
+ runtime.file,
282
+ [...runtime.args, '__view-serve', '--config', configPath(id)],
283
+ {
284
+ detached: true,
285
+ stdio: ['ignore', logFd, logFd],
286
+ },
287
+ )
288
+ child.unref()
289
+ closeSync(logFd)
290
+ if (!child.pid) fatal(new Error('Failed to spawn the view session server'))
291
+ const live = { ...record, pid: child.pid }
292
+ await saveRecord(live)
293
+
294
+ const deadline = Date.now() + READY_TIMEOUT_MS
295
+ while (Date.now() < deadline) {
296
+ try {
297
+ const res = await fetch(`${live.pageUrl}state`)
298
+ if (res.ok) return live
299
+ } catch {
300
+ // not up yet
301
+ }
302
+ if (child.exitCode !== null) break
303
+ await sleep(POLL_MS)
304
+ }
305
+ const tail = await readFile(logPath(id), 'utf8').catch(() => '')
306
+ await closeSession(live)
307
+ return fatal(
308
+ new Error(
309
+ `View session server did not come up.${tail ? `\n--- server log ---\n${tail.slice(-2000)}` : ''}`,
310
+ ),
311
+ )
312
+ }
313
+
314
+ type PageState = { state: string; error?: string }
315
+
316
+ async function waitForPageState(record: ViewSessionRecord): Promise<PageState> {
317
+ const deadline = Date.now() + STATE_TIMEOUT_MS
318
+ let last: PageState = { state: 'waiting' }
319
+ while (Date.now() < deadline) {
320
+ try {
321
+ last = (await (await fetch(`${record.pageUrl}state`)).json()) as PageState
322
+ if (last.state === 'connected' || last.state === 'plain' || last.state === 'failed') {
323
+ return last
324
+ }
325
+ } catch {
326
+ // transient
327
+ }
328
+ await sleep(POLL_MS)
329
+ }
330
+ return last
331
+ }
332
+
333
+ /**
334
+ * Public https kernels are dialed directly by the view iframe (the router
335
+ * serves CORS; Chrome's local-network-access rules forbid a public view
336
+ * origin fetching our loopback proxy). Local/self-signed kernels go through
337
+ * the proxy.
338
+ */
339
+ function isPublicHttps(url: string): boolean {
340
+ try {
341
+ const parsed = new URL(url)
342
+ return (
343
+ parsed.protocol === 'https:' &&
344
+ !['localhost', '127.0.0.1', '::1', 'host.docker.internal'].includes(parsed.hostname)
345
+ )
346
+ } catch {
347
+ return false
348
+ }
349
+ }
350
+
351
+ function openSystemBrowser(url: string): void {
352
+ const argv =
353
+ process.platform === 'darwin'
354
+ ? ['open', url]
355
+ : process.platform === 'win32'
356
+ ? ['cmd', '/c', 'start', '', url]
357
+ : ['xdg-open', url]
358
+ void run(argv[0], argv.slice(1)).catch(() => {})
359
+ }
360
+
361
+ function driveHint(headed: boolean): string {
362
+ return `agent-browser --profile ${VIEW_PROFILE}${headed ? ' --headed' : ''}`
363
+ }
364
+
365
+ function describeState(state: PageState): string {
366
+ switch (state.state) {
367
+ case 'connected':
368
+ return 'connected (shell handshake, kernel calls live)'
369
+ case 'plain':
370
+ return 'mounted (plain iframe, no shell handshake)'
371
+ case 'failed':
372
+ return `failed — ${state.error ?? 'unknown error'}`
373
+ case 'mounting':
374
+ return 'still mounting (check again with a snapshot)'
375
+ default:
376
+ return 'page not loaded yet'
377
+ }
378
+ }
379
+
380
+ async function reportOpened(
381
+ record: ViewSessionRecord,
382
+ state: PageState | null,
383
+ mode: 'agent' | 'system' | 'none',
384
+ opts: ViewOpts,
385
+ ): Promise<void> {
386
+ if (isMachine(opts)) {
387
+ output(
388
+ {
389
+ session: record,
390
+ state: state?.state ?? 'unopened',
391
+ error: state?.error,
392
+ driver: mode === 'agent' ? { profile: VIEW_PROFILE, headed: !!opts.headed } : mode,
393
+ },
394
+ opts,
395
+ )
396
+ return
397
+ }
398
+ const label = record.view.path ?? record.view.url
399
+ log.success(`View session ${chalk.bold(record.id)} — ${chalk.bold(label)}`)
400
+ if (record.target) log.dim(` target ${record.target.path} (${record.target.id})`)
401
+ log.dim(
402
+ ` identity ${record.identity ?? '(default)'} instance ${record.instance ?? '(active)'}`,
403
+ )
404
+ log.dim(` page ${record.pageUrl}`)
405
+ if (state) log.dim(` state ${describeState(state)}`)
406
+ console.log('')
407
+ if (mode === 'agent') {
408
+ console.log(chalk.bold('Drive it:'))
409
+ console.log(` ${driveHint(!!opts.headed)} snapshot`)
410
+ console.log(` ${driveHint(!!opts.headed)} click @e3`)
411
+ } else if (mode === 'none') {
412
+ console.log(chalk.bold('Open it:'))
413
+ console.log(` ${record.pageUrl}`)
414
+ }
415
+ console.log(`${chalk.bold('Close:')} astrale view --close ${record.id}`)
416
+ }
417
+
418
+ export async function runSnapshotExtras(
419
+ opts: Pick<ViewOpts, 'headed' | 'screenshot' | 'snapshot'>,
420
+ ): Promise<void> {
421
+ const target = { profile: VIEW_PROFILE, headed: !!opts.headed }
422
+ const settled =
423
+ opts.screenshot || opts.snapshot
424
+ ? await waitForSettledSnapshot(() => ab(['snapshot'], target))
425
+ : null
426
+
427
+ if (opts.screenshot) {
428
+ const shot = await ab(['screenshot', opts.screenshot], target)
429
+ if (!shot.ok) log.warn(`screenshot failed: ${shot.error ?? 'unknown error'}`)
430
+ else log.dim(` screenshot → ${opts.screenshot}`)
431
+ }
432
+ if (opts.snapshot && settled) {
433
+ if (!settled.ok) {
434
+ log.warn(`snapshot failed: ${settled.error ?? 'unknown error'}`)
435
+ return
436
+ }
437
+ console.log(snapshotText(settled) ?? JSON.stringify(settled.data, null, 2))
438
+ }
439
+ }
440
+
441
+ async function closeCommand(opts: ViewOpts): Promise<void> {
442
+ const sessions = await listSessions()
443
+ let targets: ViewSessionRecord[]
444
+ if (opts.all) targets = sessions
445
+ else if (typeof opts.close === 'string') {
446
+ const match = sessions.find((s) => s.id === opts.close)
447
+ if (!match)
448
+ return fatal(new Error(`No view session "${opts.close}" — see \`astrale view --sessions\``))
449
+ targets = [match]
450
+ } else if (sessions.length <= 1) targets = sessions
451
+ else {
452
+ return fatal(
453
+ new Error(
454
+ `${sessions.length} sessions open — pass --close <id> or --close --all:\n${sessions
455
+ .map((s) => ` ${s.id} ${s.view.path ?? s.view.url}`)
456
+ .join('\n')}`,
457
+ ),
458
+ )
459
+ }
460
+ for (const session of targets) await closeSession(session)
461
+ if (isMachine(opts)) output({ closed: targets.map((s) => s.id) }, opts)
462
+ else if (targets.length === 0) log.dim('No view sessions.')
463
+ else log.success(`Closed ${targets.map((s) => s.id).join(', ')}`)
464
+ }
465
+
466
+ async function sessionsCommand(opts: ViewOpts): Promise<void> {
467
+ const sessions = await listSessions()
468
+ if (isMachine(opts)) {
469
+ output(sessions, opts)
470
+ return
471
+ }
472
+ if (sessions.length === 0) {
473
+ log.dim('No view sessions.')
474
+ return
475
+ }
476
+ for (const s of sessions) {
477
+ console.log(
478
+ `${chalk.bold(s.id)} ${s.view.path ?? s.view.url}${s.target ? ` target ${s.target.path}` : ''} ${chalk.dim(s.pageUrl)}`,
479
+ )
480
+ }
481
+ }
482
+
483
+ export default {
484
+ name: 'view',
485
+ description: 'Open one view in an emulated host shell your agent can drive',
486
+ arguments: [
487
+ {
488
+ name: 'spec',
489
+ description: 'ViewPath (/:origin:view.slug) or target node (/path or @id)',
490
+ required: false,
491
+ },
492
+ ],
493
+ options: [
494
+ {
495
+ flags: '--target <path>',
496
+ description:
497
+ 'Target node to open the view on (optional — some views are standalone); @self works',
498
+ },
499
+ { flags: '--view <slug>', description: 'Pick a view when the target resolves several' },
500
+ { flags: '--list', description: 'Resolve and print the candidate views; do not open' },
501
+ {
502
+ flags: '--view-url <url>',
503
+ description: 'Override the view frontend URL (origin swaps origin; full URL replaces)',
504
+ },
505
+ {
506
+ flags: '--handshake <mode>',
507
+ description: 'Override the mount mode (needed with a bare --view-url)',
508
+ choices: ['shell', 'none'],
509
+ },
510
+ { flags: '--headed', description: 'Visible agent-browser window' },
511
+ { flags: '--browser', description: 'Open in the system default browser instead' },
512
+ { flags: '--no-open', description: 'Start the session and print the URL only' },
513
+ { flags: '--snapshot', description: 'Print an accessibility snapshot once the view is up' },
514
+ { flags: '--screenshot <file>', description: 'Save a screenshot once the view is up' },
515
+ { flags: '--sessions', description: 'List active view sessions' },
516
+ {
517
+ flags: '--close [id]',
518
+ description: 'Close a view session (bare: the only open one; with --all: every session)',
519
+ },
520
+ { flags: '--all', description: 'With --close: close every session' },
521
+ ],
522
+ afterHelpText: `
523
+ What it does:
524
+ Renders ONE view — no GUI, no cookies, no WorkOS. It resolves the view on the
525
+ kernel, starts a loopback session server that emulates the shell host (real
526
+ handshake via @astrale-os/shell, token minted from YOUR CLI identity, kernel
527
+ calls proxied), and opens the page headless in agent-browser. Driving stays
528
+ agent-browser's job; auth follows --as/--creds/-i like any kernel command.
529
+
530
+ A session stays up ~30 min idle (heartbeat while the page is open). The view
531
+ gets exactly what the GUI would hand it: a delegation token, a kernel URL,
532
+ and your target node id.
533
+
534
+ Examples:
535
+ $ astrale view /crm/customers/ada # views on a node
536
+ $ astrale view /:crm.acme.dev:view.dashboard # explicit ViewPath
537
+ $ astrale view /:agents.astrale.ai:view.agent --target @f00d1234 --as alice
538
+ $ astrale view /crm/customers/ada --snapshot # open + show it
539
+ $ astrale view /:d:view.x --view-url http://localhost:8787 # local frontend, live data
540
+ $ astrale view --view-url http://localhost:8787/ui/x --handshake shell --target /a/b
541
+ $ astrale view --sessions ; astrale view --close --all
542
+ `,
543
+ action: async (spec: string | undefined, opts: ViewOpts) => {
544
+ if (opts.close !== undefined) return closeCommand(opts)
545
+ if (opts.sessions) return sessionsCommand(opts)
546
+
547
+ if (!spec && !opts.viewUrl) {
548
+ return fatal(
549
+ new Error('Nothing to open — pass a ViewPath or target node, or --view-url <url>.'),
550
+ )
551
+ }
552
+ const wantsAgentBrowser = !opts.browser && opts.open !== false
553
+ if ((opts.snapshot || opts.screenshot) && !wantsAgentBrowser) {
554
+ return fatal(
555
+ new Error('--snapshot/--screenshot need the agent-browser page (drop --browser/--no-open)'),
556
+ )
557
+ }
558
+ if (wantsAgentBrowser && !(await findAgentBrowser())) {
559
+ log.error('agent-browser is not installed — it is the engine `astrale view` drives.')
560
+ log.dim(' npm install -g agent-browser && agent-browser install')
561
+ log.dim(` npx skills add ${AGENT_BROWSER_REPO}`)
562
+ log.dim(' (or use --browser / --no-open)')
563
+ process.exit(1)
564
+ }
565
+
566
+ const { view, target, candidates } = await resolveSession(spec, opts)
567
+ if (opts.list) {
568
+ if (isMachine(opts)) output(candidates, opts)
569
+ else if (candidates.length === 0) log.dim('No views resolve here.')
570
+ else {
571
+ for (const c of candidates) {
572
+ console.log(
573
+ `${chalk.bold(candidateSlug(c))} ${c.handshake ?? 'shell'} ${c.origin} ${chalk.dim(c.url)} ${c.path}`,
574
+ )
575
+ }
576
+ }
577
+ return
578
+ }
579
+ if (!view) throw new Error('View resolution completed without a selected view')
580
+
581
+ const record = await startSession(view, target, opts)
582
+
583
+ let mode: 'agent' | 'system' | 'none' = 'none'
584
+ if (wantsAgentBrowser) {
585
+ mode = 'agent'
586
+ const opened = await ab(['open', record.pageUrl], {
587
+ profile: VIEW_PROFILE,
588
+ headed: !!opts.headed,
589
+ })
590
+ if (!opened.ok) {
591
+ await closeSession(record)
592
+ return fatal(
593
+ new Error(`agent-browser could not open the page: ${opened.error ?? 'unknown error'}`),
594
+ )
595
+ }
596
+ } else if (opts.browser) {
597
+ mode = 'system'
598
+ openSystemBrowser(record.pageUrl)
599
+ }
600
+
601
+ const state = mode === 'none' ? null : await waitForPageState(record)
602
+ if (state?.state === 'failed') {
603
+ await reportOpened(record, state, mode, opts)
604
+ if (opts.debug) {
605
+ const tail = await readFile(logPath(record.id), 'utf8').catch(() => '')
606
+ if (tail) console.error(`--- server log ---\n${tail.slice(-3000)}`)
607
+ }
608
+ await closeSession(record)
609
+ process.exit(1)
610
+ }
611
+ await reportOpened(record, state, mode, opts)
612
+ if (mode === 'agent') await runSnapshotExtras(opts)
613
+ },
614
+ } satisfies CommandDefinition
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import * as C from './connect-core'
4
+
5
+ // Guards the frozen export surface `@astrale-os/connect-host` borrows. If a
6
+ // re-export name drifts (renamed / dropped upstream), this smoke fails loudly
7
+ // instead of the connect-host adapter breaking at type-check time in another
8
+ // submodule.
9
+ describe('connect-core barrel', () => {
10
+ test('re-exports every borrowed function', () => {
11
+ expect(C.readIdentities).toBeTypeOf('function')
12
+ expect(C.getDefault).toBeTypeOf('function')
13
+ expect(C.getIdentity).toBeTypeOf('function')
14
+ expect(C.signAs).toBeTypeOf('function')
15
+ expect(C.listIdentityKeys).toBeTypeOf('function')
16
+ expect(C.readInstances).toBeTypeOf('function')
17
+ expect(C.resetInstancesMemo).toBeTypeOf('function')
18
+ expect(C.resolveInstance).toBeTypeOf('function')
19
+ expect(C.normalizeInstanceKernelUrl).toBeTypeOf('function')
20
+ expect(C.orgIdForAudience).toBeTypeOf('function')
21
+ expect(C.resolveInstanceTarget).toBeTypeOf('function')
22
+ expect(C.readConfig).toBeTypeOf('function')
23
+ expect(C.resolveCredential).toBeTypeOf('function')
24
+ expect(C.fetchWithCaFile).toBeTypeOf('function')
25
+ expect(C.createPaths).toBeTypeOf('function')
26
+ expect(C.loginViaIdp).toBeTypeOf('function')
27
+ expect(C.resolveIdpName).toBeTypeOf('function')
28
+ expect(C.readIdpSession).toBeTypeOf('function')
29
+ expect(C.isSessionExpired).toBeTypeOf('function')
30
+ })
31
+
32
+ test('re-exports the error classes', () => {
33
+ expect(C.AuthError).toBeTypeOf('function')
34
+ expect(C.IdpRefreshTransientError).toBeTypeOf('function')
35
+ expect(C.IdpOrgMembershipError).toBeTypeOf('function')
36
+ })
37
+
38
+ test('re-exports the paths singleton', () => {
39
+ expect(C.paths).toBeTypeOf('object')
40
+ expect(C.paths.home).toBeTypeOf('string')
41
+ })
42
+ })