Skip to content

Commit 856989a

Browse files
committed
Add programmatic integration surface for embedding hosts
Expose ./integration with run/attach/detach/join helpers that boot the kernel in-process and return structured results instead of spawning the hyp binary.
1 parent 80a2215 commit 856989a

4 files changed

Lines changed: 413 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"./core/observability": "./src/core/observability/index.js",
1414
"./core/sinks": "./src/core/sinks/index.js",
1515
"./core/query": "./src/core/query/index.js",
16+
"./integration": "./src/core/cli/integration.js",
1617
"./tui": "./src/core/cli/tui/index.js"
1718
},
1819
"imports": {

src/core/cli/integration.d.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
export interface IntegrationOptions {
2+
/** State dir; overlays `HYP_HOME` for this call. Defaults to the ambient env. */
3+
hypHome?: string
4+
/** Base environment. Defaults to `process.env`. */
5+
env?: NodeJS.ProcessEnv
6+
/** Working directory for the command. */
7+
cwd?: string
8+
/** Resolve and report what would change without writing anything. */
9+
dryRun?: boolean
10+
}
11+
12+
export interface CommandResult {
13+
/** Process-style exit code (0 = success). */
14+
code: number
15+
/** Parsed last JSON line of stdout, or null when none was emitted. */
16+
json: unknown
17+
stdout: string
18+
stderr: string
19+
}
20+
21+
export interface ClientResult {
22+
status: 'ok' | 'failed'
23+
action: 'attach' | 'detach'
24+
client: string
25+
dry_run: boolean
26+
settings_path?: string
27+
changed: boolean
28+
port?: number
29+
prev_value?: unknown
30+
error?: string
31+
}
32+
33+
export declare class HypAwareCommandError extends Error {
34+
code: number
35+
stdout: string
36+
stderr: string
37+
json: unknown
38+
}
39+
40+
export declare function run(argv: string[], opts?: IntegrationOptions): Promise<CommandResult>
41+
export declare function attach(client?: string, opts?: IntegrationOptions): Promise<ClientResult>
42+
export declare function detach(client?: string, opts?: IntegrationOptions): Promise<ClientResult>
43+
export declare function join(
44+
url: string,
45+
token: string,
46+
opts?: IntegrationOptions & { noDaemon?: boolean }
47+
): Promise<CommandResult>

src/core/cli/integration.js

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// @ts-check
2+
3+
// Programmatic embedding surface for host applications (e.g. a signed
4+
// Electron app that bundles hypaware and drives it in-process instead of
5+
// spawning the `hyp` binary). Every helper here boots the kernel through
6+
// the same `dispatch()` the CLI uses, captures stdout/stderr into buffers,
7+
// and returns a structured result — so callers get a typed object and real
8+
// thrown errors instead of parsing JSON out of a child process's stdout.
9+
//
10+
// The long-running daemon is intentionally NOT exposed here: it must run as
11+
// its own process (the launchd/systemd unit), not inside the host.
12+
13+
import { dispatch } from './dispatch.js'
14+
15+
/**
16+
* @import { IntegrationOptions, CommandResult, ClientResult } from './integration.d.ts'
17+
*/
18+
19+
/**
20+
* Raised when an embedded command exits non-zero or reports a non-`ok`
21+
* status. Carries the exit code, captured streams, and any parsed JSON so
22+
* callers can branch on the details rather than scrape a message.
23+
*/
24+
export class HypAwareCommandError extends Error {
25+
/**
26+
* @param {string} message
27+
* @param {{ code: number, stdout: string, stderr: string, json: unknown }} detail
28+
*/
29+
constructor(message, detail) {
30+
super(message)
31+
this.name = 'HypAwareCommandError'
32+
this.code = detail.code
33+
this.stdout = detail.stdout
34+
this.stderr = detail.stderr
35+
this.json = detail.json
36+
}
37+
}
38+
39+
/** A string sink that accumulates everything written to it. */
40+
function makeBuffer() {
41+
let value = ''
42+
return {
43+
/** @param {unknown} chunk */
44+
write(chunk) {
45+
value += String(chunk)
46+
return true
47+
},
48+
text() {
49+
return value
50+
},
51+
}
52+
}
53+
54+
/**
55+
* Parse the last non-empty line of `text` as JSON. Commands invoked with
56+
* `--json` emit a single JSON object per affected target on its own line;
57+
* the final line is the one callers care about for a single target.
58+
*
59+
* @param {string} text
60+
* @returns {unknown}
61+
*/
62+
function parseLastJsonLine(text) {
63+
const lines = text.split('\n')
64+
for (let i = lines.length - 1; i >= 0; i--) {
65+
const line = lines[i].trim()
66+
if (!line) continue
67+
try {
68+
return JSON.parse(line)
69+
} catch {
70+
return null
71+
}
72+
}
73+
return null
74+
}
75+
76+
/**
77+
* Build the dispatch env, overlaying `HYP_HOME` when supplied so the call
78+
* targets a specific state dir regardless of the ambient environment.
79+
*
80+
* @param {IntegrationOptions} opts
81+
* @returns {NodeJS.ProcessEnv}
82+
*/
83+
function resolveEnv(opts) {
84+
const base = opts.env ?? process.env
85+
return opts.hypHome ? { ...base, HYP_HOME: opts.hypHome } : base
86+
}
87+
88+
/**
89+
* Run a hypaware command in-process and return its structured result. This
90+
* is the low-level escape hatch; prefer {@link attach}/{@link detach}/
91+
* {@link join} for the common flows.
92+
*
93+
* @param {string[]} argv
94+
* @param {IntegrationOptions} [opts]
95+
* @returns {Promise<CommandResult>}
96+
*/
97+
export async function run(argv, opts = {}) {
98+
const stdout = makeBuffer()
99+
const stderr = makeBuffer()
100+
const code = await dispatch(argv, {
101+
stdout,
102+
stderr,
103+
env: resolveEnv(opts),
104+
cwd: opts.cwd,
105+
// Test/advanced flows may inject a pre-built registry/kernel (the same
106+
// escape hatch `dispatch` itself documents); forward when present.
107+
registry: /** @type {any} */ (opts).registry,
108+
kernel: /** @type {any} */ (opts).kernel,
109+
})
110+
const outText = stdout.text()
111+
return {
112+
code,
113+
json: parseLastJsonLine(outText),
114+
stdout: outText,
115+
stderr: stderr.text(),
116+
}
117+
}
118+
119+
/**
120+
* Run `attach`/`detach` for a client with `--json`, returning the parsed
121+
* result. Throws {@link HypAwareCommandError} on a non-zero exit or a
122+
* non-`ok` status.
123+
*
124+
* @param {'attach'|'detach'} verb
125+
* @param {string} client
126+
* @param {IntegrationOptions} opts
127+
* @returns {Promise<ClientResult>}
128+
*/
129+
async function runClient(verb, client, opts) {
130+
const argv = [verb, client, '--json']
131+
if (opts.dryRun) argv.push('--dry-run')
132+
const result = await run(argv, opts)
133+
const json = /** @type {ClientResult | null} */ (result.json)
134+
if (result.code !== 0 || !json || json.status !== 'ok') {
135+
const reason =
136+
(json && typeof json === 'object' && 'error' in json && typeof json.error === 'string'
137+
? json.error
138+
: '') ||
139+
result.stderr.trim() ||
140+
`hyp ${verb} ${client} exited with code ${result.code}`
141+
throw new HypAwareCommandError(`hyp ${verb} ${client}: ${reason}`, {
142+
code: result.code,
143+
stdout: result.stdout,
144+
stderr: result.stderr,
145+
json: result.json,
146+
})
147+
}
148+
return json
149+
}
150+
151+
/**
152+
* Attach a client (e.g. `'claude'` or `'codex'`) to the local gateway by
153+
* editing its settings file. Resolves the gateway port from the effective
154+
* config / live daemon; no port argument is needed.
155+
*
156+
* @param {string} [client]
157+
* @param {IntegrationOptions} [opts]
158+
* @returns {Promise<ClientResult>}
159+
*/
160+
export function attach(client = 'claude', opts = {}) {
161+
return runClient('attach', client, opts)
162+
}
163+
164+
/**
165+
* Detach a previously attached client, restoring its prior settings.
166+
*
167+
* @param {string} [client]
168+
* @param {IntegrationOptions} [opts]
169+
* @returns {Promise<ClientResult>}
170+
*/
171+
export function detach(client = 'claude', opts = {}) {
172+
return runClient('detach', client, opts)
173+
}
174+
175+
/**
176+
* Join a central hypaware server, writing the join seed under
177+
* `<HYP_HOME>/hypaware/config-control/`. With `noDaemon` (the default for
178+
* embedded hosts that own their own daemon) it performs no network call and
179+
* installs no launchd/systemd unit — the host's already-running daemon
180+
* picks up the seed and pulls its configuration.
181+
*
182+
* @param {string} url
183+
* @param {string} token
184+
* @param {IntegrationOptions & { noDaemon?: boolean }} [opts]
185+
* @returns {Promise<CommandResult>}
186+
*/
187+
export async function join(url, token, opts = {}) {
188+
const argv = ['join', url]
189+
if (token) argv.push(token)
190+
if (opts.noDaemon ?? true) argv.push('--no-daemon')
191+
const result = await run(argv, opts)
192+
if (result.code !== 0) {
193+
const reason = result.stderr.trim() || `hyp join exited with code ${result.code}`
194+
throw new HypAwareCommandError(`hyp join: ${reason}`, {
195+
code: result.code,
196+
stdout: result.stdout,
197+
stderr: result.stderr,
198+
json: result.json,
199+
})
200+
}
201+
return result
202+
}

0 commit comments

Comments
 (0)