@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.
- package/.check-workspace.cjs +40 -0
- package/README.md +151 -0
- package/dist/astrale.js +59749 -0
- package/package.json +90 -0
- package/src/command.ts +40 -0
- package/src/commands/__tests__/admin-instance.test.ts +73 -0
- package/src/commands/__tests__/auth-login.test.ts +178 -0
- package/src/commands/__tests__/auth-token.test.ts +223 -0
- package/src/commands/__tests__/call.test.ts +72 -0
- package/src/commands/__tests__/domain-list.test.ts +74 -0
- package/src/commands/__tests__/help-contract.test.ts +136 -0
- package/src/commands/__tests__/install-identity-override.test.ts +65 -0
- package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
- package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
- package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
- package/src/commands/__tests__/logs.test.ts +117 -0
- package/src/commands/__tests__/ls.test.ts +25 -0
- package/src/commands/__tests__/setup-plan.test.ts +61 -0
- package/src/commands/admin/status.ts +61 -0
- package/src/commands/admin/use.ts +77 -0
- package/src/commands/auth/login.ts +82 -0
- package/src/commands/auth/logout.ts +50 -0
- package/src/commands/auth/status.ts +86 -0
- package/src/commands/auth/token.ts +162 -0
- package/src/commands/browser.ts +207 -0
- package/src/commands/call.ts +300 -0
- package/src/commands/describe.ts +182 -0
- package/src/commands/domain/install.ts +420 -0
- package/src/commands/domain/list.ts +154 -0
- package/src/commands/domain/publish.ts +155 -0
- package/src/commands/get.ts +60 -0
- package/src/commands/identity/create.ts +26 -0
- package/src/commands/identity/delete.ts +18 -0
- package/src/commands/identity/export.ts +66 -0
- package/src/commands/identity/import.ts +101 -0
- package/src/commands/identity/list.ts +56 -0
- package/src/commands/identity/register.ts +170 -0
- package/src/commands/identity/sync.ts +32 -0
- package/src/commands/identity/unsync.ts +24 -0
- package/src/commands/identity/use.ts +18 -0
- package/src/commands/identity/whoami.ts +34 -0
- package/src/commands/idp/add.ts +150 -0
- package/src/commands/idp/list.ts +57 -0
- package/src/commands/idp/refresh.ts +38 -0
- package/src/commands/idp/remove.ts +36 -0
- package/src/commands/idp/show.ts +29 -0
- package/src/commands/instance/active.ts +64 -0
- package/src/commands/instance/bookmark.ts +72 -0
- package/src/commands/instance/create.ts +69 -0
- package/src/commands/instance/delete.ts +72 -0
- package/src/commands/instance/forget.ts +26 -0
- package/src/commands/instance/list.ts +149 -0
- package/src/commands/instance/status.ts +42 -0
- package/src/commands/instance/use.ts +210 -0
- package/src/commands/logs.ts +347 -0
- package/src/commands/ls.ts +229 -0
- package/src/commands/query.ts +32 -0
- package/src/commands/setup.ts +54 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/studio.ts +401 -0
- package/src/commands/token.ts +77 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +87 -0
- package/src/errors.ts +65 -0
- package/src/kernel/__tests__/auth.test.ts +77 -0
- package/src/kernel/__tests__/errors.test.ts +43 -0
- package/src/kernel/__tests__/remote-routing.test.ts +70 -0
- package/src/kernel/auth.ts +234 -0
- package/src/kernel/ca-fetch.ts +119 -0
- package/src/kernel/client.ts +191 -0
- package/src/kernel/errors.ts +280 -0
- package/src/kernel/expand.ts +217 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/options.ts +22 -0
- package/src/kernel/remote-routing.ts +88 -0
- package/src/kernel/run.ts +63 -0
- package/src/kernel/types.ts +14 -0
- package/src/lib/__tests__/admin-target.test.ts +112 -0
- package/src/lib/__tests__/binary.test.ts +56 -0
- package/src/lib/__tests__/command-dx.test.ts +58 -0
- package/src/lib/__tests__/concurrency.test.ts +62 -0
- package/src/lib/__tests__/config.test.ts +53 -0
- package/src/lib/__tests__/design.test.ts +99 -0
- package/src/lib/__tests__/domain-identity.test.ts +60 -0
- package/src/lib/__tests__/format.test.ts +22 -0
- package/src/lib/__tests__/fs-atomic.test.ts +104 -0
- package/src/lib/__tests__/identity.test.ts +79 -0
- package/src/lib/__tests__/idp-session.driver.ts +53 -0
- package/src/lib/__tests__/idp-session.test.ts +357 -0
- package/src/lib/__tests__/idp.test.ts +385 -0
- package/src/lib/__tests__/instance-candidates.test.ts +73 -0
- package/src/lib/__tests__/instance-target.test.ts +183 -0
- package/src/lib/__tests__/instance.test.ts +136 -0
- package/src/lib/__tests__/keys.test.ts +129 -0
- package/src/lib/__tests__/local-status.test.ts +202 -0
- package/src/lib/__tests__/output.test.ts +150 -0
- package/src/lib/__tests__/panel.test.ts +40 -0
- package/src/lib/__tests__/port.test.ts +44 -0
- package/src/lib/__tests__/prompt.test.ts +25 -0
- package/src/lib/__tests__/sdk-deps.test.ts +68 -0
- package/src/lib/__tests__/self.test.ts +272 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
- package/src/lib/__tests__/table.test.ts +53 -0
- package/src/lib/__tests__/update.test.ts +246 -0
- package/src/lib/__tests__/use-target.test.ts +56 -0
- package/src/lib/__tests__/validation.test.ts +34 -0
- package/src/lib/admin-domain.ts +25 -0
- package/src/lib/admin-instance.ts +26 -0
- package/src/lib/admin-target.ts +217 -0
- package/src/lib/binary.ts +131 -0
- package/src/lib/browser.ts +150 -0
- package/src/lib/command-dx.ts +161 -0
- package/src/lib/concurrency.ts +31 -0
- package/src/lib/config.ts +45 -0
- package/src/lib/domain-identity.ts +49 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/format.ts +4 -0
- package/src/lib/fs-atomic.ts +126 -0
- package/src/lib/identity.ts +256 -0
- package/src/lib/idp-session.ts +134 -0
- package/src/lib/idp.ts +876 -0
- package/src/lib/instance-candidates.ts +49 -0
- package/src/lib/instance-target.ts +182 -0
- package/src/lib/instance.ts +395 -0
- package/src/lib/keys.ts +294 -0
- package/src/lib/local-status.ts +152 -0
- package/src/lib/log.ts +116 -0
- package/src/lib/login-flow.ts +164 -0
- package/src/lib/meta.ts +86 -0
- package/src/lib/output.ts +222 -0
- package/src/lib/panel.ts +61 -0
- package/src/lib/paths.ts +11 -0
- package/src/lib/port.ts +41 -0
- package/src/lib/proc.ts +82 -0
- package/src/lib/prompt.ts +136 -0
- package/src/lib/provision-instance.ts +170 -0
- package/src/lib/sdk-deps.ts +104 -0
- package/src/lib/self.ts +166 -0
- package/src/lib/skills.ts +171 -0
- package/src/lib/table.ts +62 -0
- package/src/lib/update.ts +315 -0
- package/src/lib/use-target.ts +24 -0
- package/src/lib/validation.ts +59 -0
- package/src/program.ts +200 -0
- package/src/registry.ts +59 -0
- package/src/setup/__tests__/util.test.ts +29 -0
- package/src/setup/engine.ts +83 -0
- package/src/setup/render.ts +109 -0
- package/src/setup/steps/admin.ts +78 -0
- package/src/setup/steps/agent-browser.ts +81 -0
- package/src/setup/steps/auth.ts +54 -0
- package/src/setup/steps/domain.ts +68 -0
- package/src/setup/steps/index.ts +18 -0
- package/src/setup/steps/instance.ts +119 -0
- package/src/setup/steps/skills-bridge.ts +70 -0
- package/src/setup/steps/skills.ts +59 -0
- package/src/setup/types.ts +61 -0
- package/src/setup/util.ts +34 -0
- package/src/test-utils.ts +18 -0
- package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
- package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
- package/studio/client/dist/index.html +13 -0
- package/studio/package.json +62 -0
- package/studio/server/agent/ask.ts +68 -0
- package/studio/server/agent/bridge-mcp.ts +182 -0
- package/studio/server/agent/bridge.ts +188 -0
- package/studio/server/agent/claude.ts +666 -0
- package/studio/server/agent/mock.ts +186 -0
- package/studio/server/agent/prompt.ts +202 -0
- package/studio/server/agent/registry.ts +29 -0
- package/studio/server/agent/runner.ts +484 -0
- package/studio/server/agent/schema-map.ts +112 -0
- package/studio/server/agent/types.ts +120 -0
- package/studio/server/api.ts +574 -0
- package/studio/server/cache.ts +138 -0
- package/studio/server/detect.ts +81 -0
- package/studio/server/domain.ts +70 -0
- package/studio/server/index.ts +136 -0
- package/studio/server/introspect/anatomy-extras.ts +398 -0
- package/studio/server/introspect/anatomy.ts +108 -0
- package/studio/server/introspect/bundle.ts +57 -0
- package/studio/server/introspect/core-extractor.ts +119 -0
- package/studio/server/introspect/core.ts +44 -0
- package/studio/server/introspect/diff.ts +133 -0
- package/studio/server/introspect/extractor.ts +102 -0
- package/studio/server/introspect/hash.ts +21 -0
- package/studio/server/introspect/overlay-tsmorph.ts +874 -0
- package/studio/server/introspect/overlay.ts +57 -0
- package/studio/server/introspect/runtime.ts +99 -0
- package/studio/server/introspect/schema-refs.ts +46 -0
- package/studio/server/lifecycle.ts +38 -0
- package/studio/server/sse.ts +57 -0
- package/studio/server/state/baseline.ts +211 -0
- package/studio/server/state/catalog.ts +117 -0
- package/studio/server/state/comments.ts +321 -0
- package/studio/server/state/context.ts +167 -0
- package/studio/server/state/copy.ts +156 -0
- package/studio/server/state/create.ts +156 -0
- package/studio/server/state/documents.ts +70 -0
- package/studio/server/state/env.ts +161 -0
- package/studio/server/state/git.ts +75 -0
- package/studio/server/state/handoff.ts +55 -0
- package/studio/server/state/harness-gateway.ts +181 -0
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/instance.ts +244 -0
- package/studio/server/state/integrations.ts +55 -0
- package/studio/server/state/layout.ts +55 -0
- package/studio/server/state/settings.ts +39 -0
- package/studio/server/state/store.ts +97 -0
- package/studio/server/state/updates.ts +63 -0
- package/studio/server/state/usage.ts +37 -0
- package/studio/server/state/views.ts +138 -0
- package/studio/server/state/visibility.ts +33 -0
- package/studio/server/watch.ts +81 -0
- package/studio/server/workspace-state.ts +26 -0
- package/studio/server/workspace-watch.ts +101 -0
- package/studio/shared/types.ts +873 -0
- package/studio/tsconfig.json +23 -0
- 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
|