@astrale-os/cli 0.4.0-alpha.13

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 (219) hide show
  1. package/.check-workspace.cjs +40 -0
  2. package/README.md +151 -0
  3. package/dist/astrale.js +59749 -0
  4. package/package.json +90 -0
  5. package/src/command.ts +40 -0
  6. package/src/commands/__tests__/admin-instance.test.ts +73 -0
  7. package/src/commands/__tests__/auth-login.test.ts +178 -0
  8. package/src/commands/__tests__/auth-token.test.ts +223 -0
  9. package/src/commands/__tests__/call.test.ts +72 -0
  10. package/src/commands/__tests__/domain-list.test.ts +74 -0
  11. package/src/commands/__tests__/help-contract.test.ts +136 -0
  12. package/src/commands/__tests__/install-identity-override.test.ts +65 -0
  13. package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
  14. package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
  15. package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
  16. package/src/commands/__tests__/logs.test.ts +117 -0
  17. package/src/commands/__tests__/ls.test.ts +25 -0
  18. package/src/commands/__tests__/setup-plan.test.ts +61 -0
  19. package/src/commands/admin/status.ts +61 -0
  20. package/src/commands/admin/use.ts +77 -0
  21. package/src/commands/auth/login.ts +82 -0
  22. package/src/commands/auth/logout.ts +50 -0
  23. package/src/commands/auth/status.ts +86 -0
  24. package/src/commands/auth/token.ts +162 -0
  25. package/src/commands/browser.ts +207 -0
  26. package/src/commands/call.ts +300 -0
  27. package/src/commands/describe.ts +182 -0
  28. package/src/commands/domain/install.ts +420 -0
  29. package/src/commands/domain/list.ts +154 -0
  30. package/src/commands/domain/publish.ts +155 -0
  31. package/src/commands/get.ts +60 -0
  32. package/src/commands/identity/create.ts +26 -0
  33. package/src/commands/identity/delete.ts +18 -0
  34. package/src/commands/identity/export.ts +66 -0
  35. package/src/commands/identity/import.ts +101 -0
  36. package/src/commands/identity/list.ts +56 -0
  37. package/src/commands/identity/register.ts +170 -0
  38. package/src/commands/identity/sync.ts +32 -0
  39. package/src/commands/identity/unsync.ts +24 -0
  40. package/src/commands/identity/use.ts +18 -0
  41. package/src/commands/identity/whoami.ts +34 -0
  42. package/src/commands/idp/add.ts +150 -0
  43. package/src/commands/idp/list.ts +57 -0
  44. package/src/commands/idp/refresh.ts +38 -0
  45. package/src/commands/idp/remove.ts +36 -0
  46. package/src/commands/idp/show.ts +29 -0
  47. package/src/commands/instance/active.ts +64 -0
  48. package/src/commands/instance/bookmark.ts +72 -0
  49. package/src/commands/instance/create.ts +69 -0
  50. package/src/commands/instance/delete.ts +72 -0
  51. package/src/commands/instance/forget.ts +26 -0
  52. package/src/commands/instance/list.ts +149 -0
  53. package/src/commands/instance/status.ts +42 -0
  54. package/src/commands/instance/use.ts +210 -0
  55. package/src/commands/logs.ts +347 -0
  56. package/src/commands/ls.ts +229 -0
  57. package/src/commands/query.ts +32 -0
  58. package/src/commands/setup.ts +54 -0
  59. package/src/commands/status.ts +60 -0
  60. package/src/commands/studio.ts +401 -0
  61. package/src/commands/token.ts +77 -0
  62. package/src/commands/update.ts +267 -0
  63. package/src/commands/use.ts +87 -0
  64. package/src/errors.ts +65 -0
  65. package/src/kernel/__tests__/auth.test.ts +77 -0
  66. package/src/kernel/__tests__/errors.test.ts +43 -0
  67. package/src/kernel/__tests__/remote-routing.test.ts +70 -0
  68. package/src/kernel/auth.ts +234 -0
  69. package/src/kernel/ca-fetch.ts +119 -0
  70. package/src/kernel/client.ts +191 -0
  71. package/src/kernel/errors.ts +280 -0
  72. package/src/kernel/expand.ts +217 -0
  73. package/src/kernel/index.ts +14 -0
  74. package/src/kernel/options.ts +22 -0
  75. package/src/kernel/remote-routing.ts +88 -0
  76. package/src/kernel/run.ts +63 -0
  77. package/src/kernel/types.ts +14 -0
  78. package/src/lib/__tests__/admin-target.test.ts +112 -0
  79. package/src/lib/__tests__/binary.test.ts +56 -0
  80. package/src/lib/__tests__/command-dx.test.ts +58 -0
  81. package/src/lib/__tests__/concurrency.test.ts +62 -0
  82. package/src/lib/__tests__/config.test.ts +53 -0
  83. package/src/lib/__tests__/design.test.ts +99 -0
  84. package/src/lib/__tests__/domain-identity.test.ts +60 -0
  85. package/src/lib/__tests__/format.test.ts +22 -0
  86. package/src/lib/__tests__/fs-atomic.test.ts +104 -0
  87. package/src/lib/__tests__/identity.test.ts +79 -0
  88. package/src/lib/__tests__/idp-session.driver.ts +53 -0
  89. package/src/lib/__tests__/idp-session.test.ts +357 -0
  90. package/src/lib/__tests__/idp.test.ts +385 -0
  91. package/src/lib/__tests__/instance-candidates.test.ts +73 -0
  92. package/src/lib/__tests__/instance-target.test.ts +183 -0
  93. package/src/lib/__tests__/instance.test.ts +136 -0
  94. package/src/lib/__tests__/keys.test.ts +129 -0
  95. package/src/lib/__tests__/local-status.test.ts +202 -0
  96. package/src/lib/__tests__/output.test.ts +150 -0
  97. package/src/lib/__tests__/panel.test.ts +40 -0
  98. package/src/lib/__tests__/port.test.ts +44 -0
  99. package/src/lib/__tests__/prompt.test.ts +25 -0
  100. package/src/lib/__tests__/sdk-deps.test.ts +68 -0
  101. package/src/lib/__tests__/self.test.ts +272 -0
  102. package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
  103. package/src/lib/__tests__/table.test.ts +53 -0
  104. package/src/lib/__tests__/update.test.ts +246 -0
  105. package/src/lib/__tests__/use-target.test.ts +56 -0
  106. package/src/lib/__tests__/validation.test.ts +34 -0
  107. package/src/lib/admin-domain.ts +25 -0
  108. package/src/lib/admin-instance.ts +26 -0
  109. package/src/lib/admin-target.ts +217 -0
  110. package/src/lib/binary.ts +131 -0
  111. package/src/lib/browser.ts +150 -0
  112. package/src/lib/command-dx.ts +161 -0
  113. package/src/lib/concurrency.ts +31 -0
  114. package/src/lib/config.ts +45 -0
  115. package/src/lib/domain-identity.ts +49 -0
  116. package/src/lib/env.ts +49 -0
  117. package/src/lib/format.ts +4 -0
  118. package/src/lib/fs-atomic.ts +126 -0
  119. package/src/lib/identity.ts +256 -0
  120. package/src/lib/idp-session.ts +134 -0
  121. package/src/lib/idp.ts +876 -0
  122. package/src/lib/instance-candidates.ts +49 -0
  123. package/src/lib/instance-target.ts +182 -0
  124. package/src/lib/instance.ts +395 -0
  125. package/src/lib/keys.ts +294 -0
  126. package/src/lib/local-status.ts +152 -0
  127. package/src/lib/log.ts +116 -0
  128. package/src/lib/login-flow.ts +164 -0
  129. package/src/lib/meta.ts +86 -0
  130. package/src/lib/output.ts +222 -0
  131. package/src/lib/panel.ts +61 -0
  132. package/src/lib/paths.ts +11 -0
  133. package/src/lib/port.ts +41 -0
  134. package/src/lib/proc.ts +82 -0
  135. package/src/lib/prompt.ts +136 -0
  136. package/src/lib/provision-instance.ts +170 -0
  137. package/src/lib/sdk-deps.ts +104 -0
  138. package/src/lib/self.ts +166 -0
  139. package/src/lib/skills.ts +171 -0
  140. package/src/lib/table.ts +62 -0
  141. package/src/lib/update.ts +315 -0
  142. package/src/lib/use-target.ts +24 -0
  143. package/src/lib/validation.ts +59 -0
  144. package/src/program.ts +200 -0
  145. package/src/registry.ts +59 -0
  146. package/src/setup/__tests__/util.test.ts +29 -0
  147. package/src/setup/engine.ts +83 -0
  148. package/src/setup/render.ts +109 -0
  149. package/src/setup/steps/admin.ts +78 -0
  150. package/src/setup/steps/agent-browser.ts +81 -0
  151. package/src/setup/steps/auth.ts +54 -0
  152. package/src/setup/steps/domain.ts +68 -0
  153. package/src/setup/steps/index.ts +18 -0
  154. package/src/setup/steps/instance.ts +119 -0
  155. package/src/setup/steps/skills-bridge.ts +70 -0
  156. package/src/setup/steps/skills.ts +59 -0
  157. package/src/setup/types.ts +61 -0
  158. package/src/setup/util.ts +34 -0
  159. package/src/test-utils.ts +18 -0
  160. package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
  161. package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
  162. package/studio/client/dist/index.html +13 -0
  163. package/studio/package.json +62 -0
  164. package/studio/server/agent/ask.ts +68 -0
  165. package/studio/server/agent/bridge-mcp.ts +182 -0
  166. package/studio/server/agent/bridge.ts +188 -0
  167. package/studio/server/agent/claude.ts +666 -0
  168. package/studio/server/agent/mock.ts +186 -0
  169. package/studio/server/agent/prompt.ts +202 -0
  170. package/studio/server/agent/registry.ts +29 -0
  171. package/studio/server/agent/runner.ts +484 -0
  172. package/studio/server/agent/schema-map.ts +112 -0
  173. package/studio/server/agent/types.ts +120 -0
  174. package/studio/server/api.ts +574 -0
  175. package/studio/server/cache.ts +138 -0
  176. package/studio/server/detect.ts +81 -0
  177. package/studio/server/domain.ts +70 -0
  178. package/studio/server/index.ts +136 -0
  179. package/studio/server/introspect/anatomy-extras.ts +398 -0
  180. package/studio/server/introspect/anatomy.ts +108 -0
  181. package/studio/server/introspect/bundle.ts +57 -0
  182. package/studio/server/introspect/core-extractor.ts +119 -0
  183. package/studio/server/introspect/core.ts +44 -0
  184. package/studio/server/introspect/diff.ts +133 -0
  185. package/studio/server/introspect/extractor.ts +102 -0
  186. package/studio/server/introspect/hash.ts +21 -0
  187. package/studio/server/introspect/overlay-tsmorph.ts +874 -0
  188. package/studio/server/introspect/overlay.ts +57 -0
  189. package/studio/server/introspect/runtime.ts +99 -0
  190. package/studio/server/introspect/schema-refs.ts +46 -0
  191. package/studio/server/lifecycle.ts +38 -0
  192. package/studio/server/sse.ts +57 -0
  193. package/studio/server/state/baseline.ts +211 -0
  194. package/studio/server/state/catalog.ts +117 -0
  195. package/studio/server/state/comments.ts +321 -0
  196. package/studio/server/state/context.ts +167 -0
  197. package/studio/server/state/copy.ts +156 -0
  198. package/studio/server/state/create.ts +156 -0
  199. package/studio/server/state/documents.ts +70 -0
  200. package/studio/server/state/env.ts +161 -0
  201. package/studio/server/state/git.ts +75 -0
  202. package/studio/server/state/handoff.ts +55 -0
  203. package/studio/server/state/harness-gateway.ts +181 -0
  204. package/studio/server/state/harness-token.ts +0 -0
  205. package/studio/server/state/instance.ts +244 -0
  206. package/studio/server/state/integrations.ts +55 -0
  207. package/studio/server/state/layout.ts +55 -0
  208. package/studio/server/state/settings.ts +39 -0
  209. package/studio/server/state/store.ts +97 -0
  210. package/studio/server/state/updates.ts +63 -0
  211. package/studio/server/state/usage.ts +37 -0
  212. package/studio/server/state/views.ts +138 -0
  213. package/studio/server/state/visibility.ts +33 -0
  214. package/studio/server/watch.ts +81 -0
  215. package/studio/server/workspace-state.ts +26 -0
  216. package/studio/server/workspace-watch.ts +101 -0
  217. package/studio/shared/types.ts +873 -0
  218. package/studio/tsconfig.json +23 -0
  219. package/tsconfig.json +14 -0
@@ -0,0 +1,207 @@
1
+ import chalk from 'chalk'
2
+ import { existsSync } from 'node:fs'
3
+
4
+ import type { CommandDefinition } from '../command'
5
+
6
+ import {
7
+ ab,
8
+ AGENT_BROWSER_REPO,
9
+ type BrowserSession,
10
+ findAgentBrowser,
11
+ navigateAndCheck,
12
+ pollAuth,
13
+ profileDirFor,
14
+ saveSession,
15
+ } from '../lib/browser'
16
+ import { readLocalStatus } from '../lib/local-status'
17
+ import { fatal, log } from '../lib/log'
18
+ import { isMachine, output, RAW_OUTPUT_OPTIONS, type RawOutputOpts } from '../lib/output'
19
+
20
+ type BrowserOpts = RawOutputOpts & {
21
+ url?: string
22
+ cdp?: string
23
+ profile?: string
24
+ login?: boolean
25
+ check?: boolean
26
+ }
27
+
28
+ const LOGIN_TIMEOUT_MS = 180_000
29
+ const POLL_INTERVAL_MS = 2500
30
+
31
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
32
+
33
+ /** Resolve the GUI origin for the active instance (or an explicit --url). */
34
+ async function resolveGuiOrigin(explicit?: string): Promise<string> {
35
+ let target = explicit
36
+ if (!target) {
37
+ const status = await readLocalStatus()
38
+ target = status.instance?.url ?? undefined
39
+ }
40
+ if (!target) {
41
+ fatal(
42
+ new Error(
43
+ 'No target instance. Set one with `astrale instance use <name>`, or pass `--url <gui-url>`.',
44
+ ),
45
+ )
46
+ }
47
+ try {
48
+ return new URL(target).origin
49
+ } catch {
50
+ return fatal(new Error(`Invalid instance URL: ${target}`))
51
+ }
52
+ }
53
+
54
+ function requireAgentBrowser(machine: boolean, opts: RawOutputOpts): Promise<string> {
55
+ return findAgentBrowser().then((bin) => {
56
+ if (bin) return bin
57
+ if (machine) {
58
+ output({ error: 'agent-browser-not-installed', repo: AGENT_BROWSER_REPO }, opts)
59
+ } else {
60
+ log.error('agent-browser is not installed — it is the engine `astrale browser` drives.')
61
+ log.dim(' Install it:')
62
+ log.dim(' npm install -g agent-browser && agent-browser install')
63
+ log.dim(' Recommended — also install its agent skill so your coding agent knows it:')
64
+ log.dim(` npx skills add ${AGENT_BROWSER_REPO}`)
65
+ }
66
+ process.exit(1)
67
+ })
68
+ }
69
+
70
+ function reportConnected(session: BrowserSession, machine: boolean, opts: RawOutputOpts): void {
71
+ if (machine) {
72
+ output({ connected: true, ...session }, opts)
73
+ return
74
+ }
75
+ const drive = session.profile ? `--profile ${session.profile}` : `--cdp ${session.cdp}`
76
+ log.success(
77
+ `Connected to ${chalk.bold(session.url)}${session.email ? ` as ${session.email}` : ''}`,
78
+ )
79
+ log.dim(` session saved → ~/.astrale/browser.json (reused automatically next time)`)
80
+ console.log('')
81
+ console.log(chalk.bold('Drive it:'))
82
+ console.log(` agent-browser ${drive} snapshot`)
83
+ console.log(` agent-browser ${drive} open ${session.url}`)
84
+ console.log(` agent-browser ${drive} click @e3`)
85
+ }
86
+
87
+ export default {
88
+ name: 'browser',
89
+ description: 'Open a reusable, authenticated browser session your agent can drive',
90
+ options: [
91
+ { flags: '--url <url>', description: 'GUI URL to connect (default: active instance)' },
92
+ {
93
+ flags: '--cdp <endpoint>',
94
+ description: 'Attach to a running Chrome (port or ws URL) instead of a profile',
95
+ },
96
+ { flags: '--profile <dir>', description: 'Override the persistent profile directory' },
97
+ { flags: '--login', description: 'Force interactive sign-in even if a session exists' },
98
+ { flags: '--check', description: 'Report session status only; never open a window' },
99
+ ...RAW_OUTPUT_OPTIONS,
100
+ ],
101
+ afterHelpText: `
102
+ What it does:
103
+ Wires your coding agent to the live Astrale GUI. It owns the *session* — pins a
104
+ persistent per-instance profile, runs the one-time WorkOS sign-in, and verifies
105
+ auth. Driving the page is agent-browser's job: once connected, your agent runs
106
+ \`agent-browser --profile <dir> snapshot|open|click|eval\` directly.
107
+
108
+ The GUI session is an httpOnly cookie with no token-injection path, so "use my
109
+ session" means driving a browser whose profile holds the cookie. You sign in
110
+ once; the profile keeps it and every later run is silent.
111
+
112
+ Requires agent-browser (https://github.com/${AGENT_BROWSER_REPO}):
113
+ npm install -g agent-browser && agent-browser install
114
+ npx skills add ${AGENT_BROWSER_REPO} # recommended: teaches the agent its commands
115
+
116
+ Examples:
117
+ $ astrale browser # connect the active instance (sign in once)
118
+ $ astrale browser --check # is the saved session still authenticated?
119
+ $ astrale browser --login # force a fresh sign-in
120
+ $ astrale browser --cdp 9222 # attach to a Chrome you already have open
121
+ `,
122
+ action: async (opts: BrowserOpts) => {
123
+ const machine = isMachine(opts)
124
+ const gui = await resolveGuiOrigin(opts.url)
125
+ const host = new URL(gui).host
126
+ await requireAgentBrowser(machine, opts)
127
+
128
+ const usingCdp = !!opts.cdp
129
+ const profile = usingCdp ? null : (opts.profile ?? profileDirFor(host))
130
+ const target = usingCdp ? { cdp: opts.cdp } : { profile: profile! }
131
+
132
+ const persist = async (state: { email?: string }): Promise<BrowserSession> => {
133
+ const session: BrowserSession = {
134
+ url: gui,
135
+ host,
136
+ profile,
137
+ cdp: opts.cdp ?? null,
138
+ email: state.email,
139
+ updatedAt: new Date().toISOString(),
140
+ }
141
+ await saveSession(session)
142
+ return session
143
+ }
144
+
145
+ // Attach mode: the external Chrome is the user's — never drive its login.
146
+ if (usingCdp) {
147
+ const state = await navigateAndCheck(gui, target)
148
+ if (!state.authed) {
149
+ if (machine) output({ connected: false, url: gui, host, cdp: opts.cdp }, opts)
150
+ else {
151
+ log.warn(`Attached to Chrome on ${opts.cdp}, but not signed in to ${gui}.`)
152
+ log.dim(' Sign in in that browser window, then re-run `astrale browser --cdp ...`.')
153
+ }
154
+ process.exit(1)
155
+ }
156
+ reportConnected(await persist(state), machine, opts)
157
+ return
158
+ }
159
+
160
+ // Profile mode: silent (headless) reuse check when the profile may already
161
+ // hold a cookie. Skip for a brand-new profile — nothing to reuse, and it
162
+ // avoids a throwaway headless navigation before the sign-in window opens.
163
+ if (!opts.login && profile && existsSync(profile)) {
164
+ const reused = await navigateAndCheck(gui, target)
165
+ if (reused.authed) {
166
+ reportConnected(await persist(reused), machine, opts)
167
+ return
168
+ }
169
+ }
170
+
171
+ // Not authenticated. In check/machine mode we never pop a window.
172
+ if (opts.check || machine) {
173
+ if (machine) output({ connected: false, url: gui, host, profile }, opts)
174
+ else {
175
+ log.warn(`No authenticated session for ${gui}.`)
176
+ log.dim(' Run `astrale browser` (without --check) to sign in.')
177
+ }
178
+ process.exit(1)
179
+ }
180
+
181
+ // Interactive sign-in: open a HEADED window and poll until the router sets
182
+ // the cookie. Every command on this browser must carry `headed:true` —
183
+ // agent-browser defaults to headless, and an unflagged command flips the
184
+ // live window back to headless (closing it mid-login). Proven: a poll
185
+ // `eval` without --headed turns HEADED → HEADLESS on the next call.
186
+ const headedTarget = { ...target, headed: true }
187
+ log.step(`Opening ${chalk.bold(gui)} — sign in with your Astrale account in the window…`)
188
+ await ab(['close'], target) // release any stale browser on this profile
189
+ await ab(['open', gui], headedTarget)
190
+
191
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS
192
+ let state = { authed: false } as { authed: boolean; email?: string }
193
+ while (Date.now() < deadline) {
194
+ await sleep(POLL_INTERVAL_MS)
195
+ state = await pollAuth(headedTarget)
196
+ if (state.authed) break
197
+ }
198
+ if (!state.authed) {
199
+ fatal(
200
+ new Error(
201
+ `Sign-in not detected within ${LOGIN_TIMEOUT_MS / 1000}s. Re-run \`astrale browser\`.`,
202
+ ),
203
+ )
204
+ }
205
+ reportConnected(await persist(state), machine, opts)
206
+ },
207
+ } satisfies CommandDefinition
@@ -0,0 +1,300 @@
1
+ import { K } from '@astrale-os/kernel-core'
2
+
3
+ import type { CommandDefinition } from '../command'
4
+ import type { CallCommandOpts, ClientContext, SelfExpansionMeta } from '../kernel'
5
+
6
+ import { buildSelfContext, resolveSelfIdLazy, runKernelCommand, withSelfHint } from '../kernel'
7
+ import { presentBinary, type BinaryLike } from '../lib/binary'
8
+ import { log } from '../lib/log'
9
+ import { output, present } from '../lib/output'
10
+ import { containsSelfRef, expandSelfReferences } from '../lib/self'
11
+
12
+ type CallOpts = CallCommandOpts & { describe?: boolean; dryRun?: boolean; output?: string }
13
+
14
+ /** A call resolves to either a JSON value or a binary response. */
15
+ type CallResult = { kind: 'binary'; response: BinaryLike } | { kind: 'value'; value: unknown }
16
+
17
+ export async function callCommand(
18
+ path: string,
19
+ rawParams: string[],
20
+ opts: CallOpts,
21
+ ): Promise<void> {
22
+ // ── Expand `@self` in path + raw param strings ──────────
23
+ // Local resolution, with ONE lazy whoami round-trip for IdP identities
24
+ // missing a cached registration (persisted, so it's local afterwards).
25
+ // Throws a typed SelfRefusalError (manager, instance-signed, …) which we
26
+ // surface as a fatal CLI error. Runs BEFORE `--describe` so users get the
27
+ // typed refusal instead of a generic NotFoundError from the kernel.
28
+ let expandedPath = path
29
+ let expandedRaw = rawParams
30
+ let selfMeta: SelfExpansionMeta | undefined
31
+ const inputsHaveSelf = containsSelfRef(path) || rawParams.some(containsSelfRef)
32
+ if (inputsHaveSelf) {
33
+ try {
34
+ const selfCtx = await buildSelfContext(opts)
35
+ const selfId = await resolveSelfIdLazy(selfCtx, opts)
36
+ expandedPath = expandSelfReferences(path, selfId)
37
+ expandedRaw = rawParams.map((p) => expandSelfReferences(p, selfId))
38
+ // Stamp metadata whenever ANY input mutated — the stale-registration
39
+ // hint in `formatKernelError` is just as useful when `@self` lived in
40
+ // a param (`node=@self`) as when it was in the path head.
41
+ const rawMutated = expandedRaw.some((p, i) => p !== rawParams[i])
42
+ if (expandedPath !== path || rawMutated) {
43
+ selfMeta = {
44
+ original: path,
45
+ expanded: expandedPath,
46
+ selfId,
47
+ identity: selfCtx.identity?.name,
48
+ slug: selfCtx.instanceSlug,
49
+ }
50
+ }
51
+ } catch (e) {
52
+ log.error(e instanceof Error ? e.message : 'Invalid @self expansion')
53
+ process.exit(1)
54
+ }
55
+ }
56
+
57
+ // ── Describe mode: show schema without executing ────────
58
+ // Runs AFTER expansion so `astrale call @self::m --describe` works.
59
+ if (opts.describe) {
60
+ return describeOperation(expandedPath, opts)
61
+ }
62
+
63
+ // ── Parse params ────────────────────────────────────────
64
+ let params: Record<string, unknown>
65
+ try {
66
+ params = await parseParams(expandedRaw, opts.data)
67
+ } catch (e) {
68
+ log.error(e instanceof Error ? e.message : 'Invalid params')
69
+ process.exit(1)
70
+ }
71
+
72
+ // ── Dry-run: show what would be sent ─────────────────────
73
+ if (opts.dryRun) {
74
+ output({ method: expandedPath, params }, opts)
75
+ return
76
+ }
77
+
78
+ // ── Execute ────────────────────────────────────────────
79
+ await runKernelCommand<CallResult>({
80
+ opts,
81
+ label: expandedPath,
82
+ fn: async (ctx): Promise<CallResult> => {
83
+ // Remote-bound functions live on an external worker. The kernel resolves
84
+ // the call to a redirect carrying the worker's URL + `iss`; the session
85
+ // follows it, minting a worker-scoped delegation for that `iss` (the
86
+ // delegation cache wired in `client.ts`). No client-side binding lookup.
87
+ //
88
+ // The one thing the reactive path can't discover after dispatch is a
89
+ // binary output mode (a JSON decode would corrupt the bytes), so detect
90
+ // it up front and route to the binary transport — which also auto-follows.
91
+ if (await isBinaryOutput(ctx, expandedPath)) {
92
+ const response = await withSelfHint(() => ctx.client.binary(expandedPath, params), selfMeta)
93
+ return { kind: 'binary', response }
94
+ }
95
+ const value = await withSelfHint(() => ctx.client.call(expandedPath, params), selfMeta)
96
+ return { kind: 'value', value }
97
+ },
98
+ format: async (result, fmtOpts) => {
99
+ if (result.kind === 'binary') {
100
+ await presentBinary(result.response, fmtOpts, { outFile: opts.output })
101
+ return
102
+ }
103
+ present(result.value, fmtOpts)
104
+ },
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Best-effort pre-flight: does the target Function declare a binary output?
110
+ * A binary method must use the binary transport (the value path would JSON-
111
+ * decode and corrupt the bytes), and the client can't discover that after
112
+ * dispatch. For a static path the path IS the Function node — read `output`
113
+ * off it via `::get`. For an instance-method path (`<node>::method`) the
114
+ * Function node isn't addressable that way: resolve the instance's class
115
+ * first, then probe the class's method node (`<classPath>:method`). An
116
+ * interface-hosted instance method still escapes the probe (its Function node
117
+ * hangs off the interface, not the class) — that and any other failure returns
118
+ * false and falls through to the value path, letting the kernel surface the
119
+ * real error. `::get` is a kernel syscall (same origin) so it neither
120
+ * redirects nor triggers delegation.
121
+ */
122
+ async function isBinaryOutput(ctx: ClientContext, path: string): Promise<boolean> {
123
+ try {
124
+ const target = path.includes('::') ? await instanceMethodNodePath(ctx, path) : path
125
+ if (!target) return false
126
+ const node = (await ctx.client.call(`${target}::get`, {})) as {
127
+ props?: Record<string, unknown>
128
+ } | null
129
+ return node?.props?.[K.$.i('Function').output.key] === 'binary'
130
+ } catch {
131
+ return false
132
+ }
133
+ }
134
+
135
+ /** `<node>::method` → the method's Function-node MethodPath, via the node's class. */
136
+ async function instanceMethodNodePath(
137
+ ctx: ClientContext,
138
+ path: string,
139
+ ): Promise<string | undefined> {
140
+ const sep = path.lastIndexOf('::')
141
+ const source = path.slice(0, sep)
142
+ const method = path.slice(sep + 2)
143
+ if (!source || !method) return undefined
144
+ const node = (await ctx.client.call(`${source}::get`, {})) as { class?: string } | null
145
+ // node.class is a ClassPath (`/:domain:class.Name`); appending `:<method>`
146
+ // forms the MethodPath of the class-owned Function node.
147
+ return node?.class ? `${node.class}:${method}` : undefined
148
+ }
149
+
150
+ async function describeOperation(path: string, opts: CallOpts): Promise<void> {
151
+ await runKernelCommand<Record<string, unknown>>({
152
+ opts,
153
+ label: `Schema for ${path}`,
154
+ fn: (ctx) => ctx.client.call(`${path}::get`, {}) as Promise<Record<string, unknown>>,
155
+ format: (node, fmtOpts) => {
156
+ const props = (node.properties ?? node) as Record<string, unknown>
157
+ const schema: Record<string, unknown> = {}
158
+ if (props.inputSchema) schema.input = tryParseJson(props.inputSchema)
159
+ if (props.outputSchema) schema.output = tryParseJson(props.outputSchema)
160
+ output(Object.keys(schema).length > 0 ? schema : node, fmtOpts)
161
+ },
162
+ })
163
+ }
164
+
165
+ function tryParseJson(value: unknown): unknown {
166
+ if (typeof value !== 'string') return value
167
+ try {
168
+ return JSON.parse(value)
169
+ } catch {
170
+ return value
171
+ }
172
+ }
173
+
174
+ // ── Param parsing ───────────────────────────────────────────
175
+
176
+ export async function parseParams(
177
+ rawParams: string[],
178
+ dataFlag?: string,
179
+ ): Promise<Record<string, unknown>> {
180
+ if (dataFlag) {
181
+ if (rawParams.length > 0) {
182
+ log.warn('--data provided, ignoring key=value params')
183
+ }
184
+ try {
185
+ return JSON.parse(dataFlag)
186
+ } catch {
187
+ throw new Error(`Invalid JSON in --data: ${dataFlag}`)
188
+ }
189
+ }
190
+
191
+ const stdin = await readStdin()
192
+ if (stdin) {
193
+ try {
194
+ return JSON.parse(stdin)
195
+ } catch {
196
+ throw new Error('Invalid JSON from stdin')
197
+ }
198
+ }
199
+
200
+ if (rawParams.length > 0) {
201
+ return parseKeyValue(rawParams)
202
+ }
203
+
204
+ return {}
205
+ }
206
+
207
+ async function readStdin(): Promise<string | null> {
208
+ if (process.stdin.isTTY) return null
209
+ const chunks: Buffer[] = []
210
+ for await (const chunk of process.stdin) {
211
+ chunks.push(chunk)
212
+ }
213
+ const text = Buffer.concat(chunks).toString('utf-8').trim()
214
+ return text || null
215
+ }
216
+
217
+ // Top-level param keys are identifier-shaped: letters, digits, underscore,
218
+ // hyphen. No `:` (would catch httpie's `key:=value` syntax — not supported,
219
+ // use `--data '{...}'` instead) and no `.` (qualified prop keys appear
220
+ // inside nested objects, never as top-level CLI params).
221
+ const PARAM_KEY_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/
222
+
223
+ export function parseKeyValue(pairs: string[]): Record<string, unknown> {
224
+ const result: Record<string, unknown> = {}
225
+ for (const pair of pairs) {
226
+ const eqIdx = pair.indexOf('=')
227
+ if (eqIdx === -1) {
228
+ throw new Error(`Invalid param "${pair}" — expected key=value format`)
229
+ }
230
+ const key = pair.slice(0, eqIdx)
231
+ const raw = pair.slice(eqIdx + 1)
232
+ if (!PARAM_KEY_RE.test(key)) {
233
+ const hint = key.endsWith(':')
234
+ ? ` (looks like httpie's "key:=value" syntax — Astrale CLI doesn't support it; use --data '{"${key.slice(0, -1)}":<value>}' for nested values)`
235
+ : ` (keys must be identifier-shaped: letters, digits, underscore, hyphen)`
236
+ throw new Error(`Invalid param key "${key}" in "${pair}"${hint}`)
237
+ }
238
+ result[key] = coerceValue(raw)
239
+ }
240
+ return result
241
+ }
242
+
243
+ export function coerceValue(raw: string): unknown {
244
+ if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) {
245
+ try {
246
+ return JSON.parse(raw)
247
+ } catch {
248
+ /* fall through */
249
+ }
250
+ }
251
+ if (raw === 'true') return true
252
+ if (raw === 'false') return false
253
+ if (raw === 'null') return null
254
+ if (/^-?\d+(\.\d+)?$/.test(raw)) return Number(raw)
255
+ return raw
256
+ }
257
+
258
+ export default {
259
+ name: 'call',
260
+ description: 'Call a kernel operation',
261
+ afterHelpText: `
262
+ Behavior:
263
+ Param priority (highest wins): --data > stdin > key=value > {}. If
264
+ both --data and key=value are given, key=value is ignored (warned).
265
+ Stdin is read only when piped (ignored on a TTY). --describe and
266
+ --dry-run short-circuit (no execution). Remote-bound functions
267
+ auto-mint a worker-scoped credential; --creds overrides it.
268
+
269
+ Self-reference:
270
+ @self expands to your nodeId on the active instance (path head or
271
+ bare param value, e.g. node=@self). --data and stdin payloads are
272
+ sent verbatim — pre-resolve manually to a literal @<nodeId> there
273
+ (e.g. via 'astrale describe @self -q', or shell-substituted from the
274
+ registration record in ~/.astrale/identities.json).
275
+
276
+ Examples:
277
+ $ astrale call /:host.astrale.ai:class.KernelInstance:list
278
+ $ astrale call /:blog.acme.com:class.Author:list limit=10
279
+ $ astrale call '@self::deactivate'
280
+ $ astrale call /:dist.astrale.ai:class.Domain:install --creds "$TOKEN" \\
281
+ -d "$(cat spec.json)"
282
+ `,
283
+ arguments: [
284
+ {
285
+ name: 'path',
286
+ description:
287
+ 'Operation path (e.g., /:host.astrale.ai:class.KernelInstance:list or /node::method)',
288
+ },
289
+ { name: 'params...', description: 'Params as key=value pairs', required: false },
290
+ ],
291
+ options: [
292
+ { flags: '-d, --data <json>', description: 'Params as JSON string' },
293
+ { flags: '-o, --output <file>', description: 'Write binary/raw output to a file' },
294
+ { flags: '--describe', description: 'Show operation schema without executing' },
295
+ { flags: '--dry-run', description: 'Show what would be sent without executing' },
296
+ ],
297
+ action: async (path, params, opts) => {
298
+ await callCommand(path as string, params as string[], opts)
299
+ },
300
+ } satisfies CommandDefinition
@@ -0,0 +1,182 @@
1
+ import { ClassPath } from '@astrale-os/kernel-core/domain'
2
+ import chalk from 'chalk'
3
+
4
+ import type { CommandDefinition } from '../command'
5
+ import type { KernelCommandOpts } from '../kernel'
6
+
7
+ import { expandSelfInPath, extractItems, runKernelCommand, withSelfHint } from '../kernel'
8
+ import { log } from '../lib/log'
9
+ import { output } from '../lib/output'
10
+
11
+ type NodeItem = {
12
+ id?: string
13
+ slug?: string
14
+ class?: string
15
+ properties?: Record<string, unknown>
16
+ }
17
+
18
+ type DescribeResult = { node: NodeItem; children: NodeItem[] }
19
+
20
+ // Commander turns `--no-schema` into `schema: false`.
21
+ type DescribeOpts = KernelCommandOpts & { schema?: boolean }
22
+
23
+ export async function describeCommand(path: string, opts: DescribeOpts): Promise<void> {
24
+ let expandedPath: string
25
+ let meta
26
+ try {
27
+ ;({ path: expandedPath, meta } = await expandSelfInPath(path, opts))
28
+ } catch (e) {
29
+ log.error(e instanceof Error ? e.message : 'Invalid @self expansion')
30
+ process.exit(1)
31
+ }
32
+ await runKernelCommand<DescribeResult>({
33
+ opts,
34
+ label: expandedPath,
35
+ fn: async (ctx) => {
36
+ const node = (await withSelfHint(
37
+ () => ctx.client.call(`${expandedPath}::get`, {}),
38
+ meta,
39
+ )) as NodeItem
40
+
41
+ let children: NodeItem[] = []
42
+ try {
43
+ const result = await withSelfHint(
44
+ () => ctx.client.call(`${expandedPath}::listChildren`, {}),
45
+ meta,
46
+ )
47
+ children = extractItems<NodeItem>(result)
48
+ } catch {
49
+ // Node may have no children (leaf node)
50
+ }
51
+
52
+ return { node, children }
53
+ },
54
+ format: (result, fmtOpts, isRaw) => {
55
+ const shown = opts.schema === false ? stripSchemaProp(result) : result
56
+ if (isRaw) {
57
+ output(shown, fmtOpts)
58
+ return
59
+ }
60
+ printDescribe(shown, expandedPath)
61
+ },
62
+ })
63
+ }
64
+
65
+ function stripSchemaProp(result: DescribeResult): DescribeResult {
66
+ const props = result.node.properties
67
+ if (!props || !('schema' in props)) return result
68
+ const { schema: _omitted, ...rest } = props
69
+ return { ...result, node: { ...result.node, properties: rest } }
70
+ }
71
+
72
+ // ── Pretty-print ────────────────────────────────────────────
73
+
74
+ function printDescribe({ node, children }: DescribeResult, path: string): void {
75
+ const kind = classNameOf(node) ?? 'Node'
76
+ const slug = node.properties?.slug ?? node.slug ?? path.split('/').pop()
77
+ console.log(` ${chalk.bold.cyan(String(slug))} ${chalk.dim(`(${kind})`)}`)
78
+
79
+ if (node.properties?.description) {
80
+ console.log(` ${chalk.dim(String(node.properties.description))}`)
81
+ }
82
+ console.log('')
83
+
84
+ const operations = children.filter(isFunction)
85
+ const otherChildren = children.filter((c) => !isFunction(c))
86
+
87
+ if (operations.length > 0) {
88
+ console.log(` ${chalk.bold('Operations:')}`)
89
+ const slugW = Math.max(4, ...operations.map((o) => (o.slug ?? '').length))
90
+ for (const op of operations) {
91
+ const opSlug = (op.slug ?? '?').padEnd(slugW)
92
+ const schema = formatInputSchema(op.properties)
93
+ console.log(` ${chalk.green(opSlug)} ${chalk.dim(schema)}`)
94
+ }
95
+ console.log('')
96
+ }
97
+
98
+ if (otherChildren.length > 0) {
99
+ console.log(` ${chalk.bold('Children:')}`)
100
+ for (const child of otherChildren) {
101
+ const childKind = classNameOf(child) ?? '?'
102
+ console.log(` ${chalk.cyan(child.slug ?? '?')} ${chalk.dim(childKind)}`)
103
+ }
104
+ console.log('')
105
+ }
106
+
107
+ if (children.length === 0) {
108
+ printProperties(node)
109
+ }
110
+ }
111
+
112
+ /** Short class name from the contract field `class` (a serialized ClassPath). */
113
+ function classNameOf(item: NodeItem): string | undefined {
114
+ return item.class ? (ClassPath.tryParse(item.class)?.className ?? undefined) : undefined
115
+ }
116
+
117
+ function isFunction(item: NodeItem): boolean {
118
+ return classNameOf(item) === 'Function'
119
+ }
120
+
121
+ function printProperties(node: NodeItem): void {
122
+ const props = node.properties ?? (node as Record<string, unknown>)
123
+ const entries = Object.entries(props).filter(([k]) => k !== '__labels' && k !== 'id')
124
+ if (entries.length === 0) return
125
+
126
+ console.log(` ${chalk.bold('Properties:')}`)
127
+ for (const [k, v] of entries) {
128
+ const val = typeof v === 'string' && v.length > 80 ? v.slice(0, 80) + '...' : String(v)
129
+ console.log(` ${chalk.cyan(k)}: ${chalk.dim(val)}`)
130
+ }
131
+ }
132
+
133
+ function formatInputSchema(properties?: Record<string, unknown>): string {
134
+ if (!properties) return '{}'
135
+ const raw = properties.inputSchema
136
+ if (!raw) return '{}'
137
+
138
+ try {
139
+ const schema = typeof raw === 'string' ? JSON.parse(raw) : raw
140
+ if (typeof schema !== 'object' || schema === null) return '{}'
141
+
142
+ const props = (schema as { properties?: Record<string, { type?: string }> }).properties
143
+ if (!props || Object.keys(props).length === 0) return '{}'
144
+
145
+ const required = new Set((schema as { required?: string[] }).required ?? [])
146
+
147
+ const fields = Object.entries(props).map(([name, def]) => {
148
+ const type = def?.type ?? '?'
149
+ return required.has(name) ? `${name}: ${type}` : `${name}?: ${type}`
150
+ })
151
+
152
+ return `{ ${fields.join(', ')} }`
153
+ } catch {
154
+ return '{...}'
155
+ }
156
+ }
157
+
158
+ export default {
159
+ name: 'describe',
160
+ description: 'Describe a node: its kind, operations, children, and schemas',
161
+ afterHelpText: `
162
+ Behavior:
163
+ Raw node dump: full properties + children. For Domain nodes this
164
+ includes a multi-kB serialized 'schema' — use --no-schema, and pipe
165
+ to jq rather than reading by eye (it is not a curated summary).
166
+
167
+ Examples:
168
+ $ astrale describe /kernel.astrale.ai
169
+ $ astrale describe /host.astrale.ai --no-schema | jq .
170
+ `,
171
+ arguments: [{ name: 'path', description: 'Node path (/domain/Class) or ID (@nodeId)' }],
172
+ options: [
173
+ {
174
+ flags: '--no-schema',
175
+ description:
176
+ 'Omit the serialized `schema` property (useful for Domain nodes, where it is multi-kB)',
177
+ },
178
+ ],
179
+ action: async (path, opts) => {
180
+ await describeCommand(path as string, opts as Parameters<typeof describeCommand>[1])
181
+ },
182
+ } satisfies CommandDefinition