lyra-ai-agent 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lyra-ai-agent",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "lyra CLI: a Sui-native, policy-bound AI finance agent. Real on-chain work gated by deterministic policy, simulation, and approval",
6
6
  "license": "MIT",
@@ -50,11 +50,11 @@
50
50
  "@mysten/sui": "^1.45.2",
51
51
  "@opentui/core": "^0.1.97",
52
52
  "@opentui/solid": "^0.1.97",
53
- "lyra-core": "^0.1.1",
54
- "lyra-gateway": "^0.1.1",
55
- "lyra-plugin-onchain": "^0.1.1",
56
- "lyra-plugin-system": "^0.1.1",
57
- "lyra-plugin-telegram": "^0.1.1",
53
+ "lyra-core": "^0.1.2",
54
+ "lyra-gateway": "^0.1.2",
55
+ "lyra-plugin-onchain": "^0.1.2",
56
+ "lyra-plugin-system": "^0.1.2",
57
+ "lyra-plugin-telegram": "^0.1.2",
58
58
  "picocolors": "^1.1.1",
59
59
  "qrcode-terminal": "^0.12.0",
60
60
  "solid-js": "^1.9.12"
@@ -55,10 +55,11 @@ import {
55
55
  type TelegramRuntimeContext,
56
56
  formatInboundPreview as formatTelegramInboundPreview,
57
57
  } from 'lyra-plugin-telegram'
58
+ import { DEFAULT_LLM_MODEL, resolveLlmBaseUrl } from '../config/defaults'
58
59
  import { findAndLoadConfig } from '../config/load'
59
60
  import { writeConfigTs } from '../config/render'
60
61
  import { shortAddr } from '../util/format'
61
- import { buildOnchainContext, loadAgentFromEnv } from '../util/sui-runtime'
62
+ import { buildOnchainContext, loadAgent } from '../util/sui-runtime'
62
63
  import {
63
64
  type TelegramDispatchSlot,
64
65
  buildTelegramDispatch,
@@ -78,9 +79,9 @@ export async function runChat(opts?: { cwd?: string; yolo?: boolean }): Promise<
78
79
  // `LYRA_AGENT_KEY` env (`suiprivkey1…`). No operator wallet, keystore
79
80
  // decrypt, or a Sui wallet dance. The deterministic policy (mirrored
80
81
  // on-chain by `lyra::policy`) bounds what the key may do.
81
- const agent = loadAgentFromEnv()
82
+ const agent = loadAgent()
82
83
  if (!agent) {
83
- console.log('No LYRA_AGENT_KEY set. Run `lyra init` first (or export a suiprivkey1… key).')
84
+ console.log('No agent key found. Run `lyra init` first (or export a suiprivkey1… key).')
84
85
  process.exit(1)
85
86
  }
86
87
  const agentAddress = agent.address
@@ -160,8 +161,11 @@ export async function runChat(opts?: { cwd?: string; yolo?: boolean }): Promise<
160
161
  const userLlmKey = process.env.OPENAI_API_KEY ?? process.env.LYRA_LLM_API_KEY
161
162
  // No personal key set → fall back to the hosted demo proxy so lyra runs keyless.
162
163
  const llmApiKey = userLlmKey ?? DEMO_LLM_TOKEN
163
- const llmBaseUrl = process.env.LYRA_LLM_BASE_URL ?? (userLlmKey ? undefined : DEMO_LLM_BASE_URL)
164
- const llmModel = process.env.LYRA_LLM_MODEL ?? config.brain?.model ?? 'gpt-4o-mini'
164
+ // With a personal key, default to the OpenAI endpoint; keyless uses the demo proxy.
165
+ const llmBaseUrl = userLlmKey
166
+ ? resolveLlmBaseUrl()
167
+ : (process.env.LYRA_LLM_BASE_URL ?? DEMO_LLM_BASE_URL)
168
+ const llmModel = process.env.LYRA_LLM_MODEL ?? config.brain?.model ?? DEFAULT_LLM_MODEL
165
169
 
166
170
  // Sub-brain factory for delegate.task (Phase 9.3). The factory creates a
167
171
  // fresh OpenAIBrain with a custom system prompt. Tools default to none for
@@ -1086,7 +1090,7 @@ export async function runChat(opts?: { cwd?: string; yolo?: boolean }): Promise<
1086
1090
 
1087
1091
  async function runModelPicker(config: LyraConfig, configPath: string): Promise<LyraConfig | null> {
1088
1092
  // Lyra uses a fixed OpenAI-compatible model (env-configured); no live catalog.
1089
- const model = process.env.LYRA_LLM_MODEL ?? config.brain?.model ?? 'gpt-4o-mini'
1093
+ const model = process.env.LYRA_LLM_MODEL ?? config.brain?.model ?? DEFAULT_LLM_MODEL
1090
1094
  const updated: LyraConfig = {
1091
1095
  ...config,
1092
1096
  brain: { provider: 'openai-compatible', model },
@@ -25,7 +25,7 @@ import onchainPlugin, {
25
25
  suiToMist,
26
26
  } from 'lyra-plugin-onchain'
27
27
  import { findAndLoadConfig } from '../config/load'
28
- import { buildOnchainContext, loadAgentFromEnv } from '../util/sui-runtime'
28
+ import { buildOnchainContext, loadAgent } from '../util/sui-runtime'
29
29
 
30
30
  const SUI_TYPE = '0x2::sui::SUI'
31
31
 
@@ -60,9 +60,9 @@ export async function runDemo(opts: DemoOpts = {}): Promise<void> {
60
60
  }
61
61
  const { config } = found
62
62
 
63
- const agent = loadAgentFromEnv()
63
+ const agent = loadAgent()
64
64
  if (!agent) {
65
- console.log('No LYRA_AGENT_KEY set. Run `lyra init` first.')
65
+ console.log('No agent key found. Run `lyra init` first.')
66
66
  process.exit(1)
67
67
  }
68
68
 
@@ -1,3 +1,5 @@
1
+ import { DEFAULT_LLM_MODEL } from '../../config/defaults'
2
+
1
3
  export interface ModelPick {
2
4
  provider: string
3
5
  model: string | null
@@ -12,6 +14,6 @@ export interface ModelPick {
12
14
  export async function pickBrainModel(): Promise<ModelPick | null> {
13
15
  return {
14
16
  provider: 'openai-compatible',
15
- model: process.env.LYRA_LLM_MODEL ?? 'gpt-4o-mini',
17
+ model: process.env.LYRA_LLM_MODEL ?? DEFAULT_LLM_MODEL,
16
18
  }
17
19
  }
@@ -1,7 +1,8 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { mkdir, writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
- import { cancel, intro, isCancel, outro, select } from '@clack/prompts'
4
+ import { cancel, intro, isCancel, outro, password, select } from '@clack/prompts'
5
+ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
5
6
  import {
6
7
  type LyraNetwork,
7
8
  agentPaths,
@@ -9,24 +10,44 @@ import {
9
10
  placeholderAgentId,
10
11
  suiRpcUrl,
11
12
  } from 'lyra-core'
13
+ import { keypairFromSecret } from 'lyra-plugin-onchain'
14
+ import { DEFAULT_NETWORK, resolvePackageId } from '../config/defaults'
12
15
  import { writeConfigTs } from '../config/render'
13
- import { AGENT_KEY_ENV, loadAgentFromEnv } from '../util/sui-runtime'
16
+ import { setDotenvVar } from '../util/dotenv'
17
+ import { type SuiAgent, loadAgent, writeAgentKey } from '../util/sui-runtime'
14
18
  import { pickBrainModel } from './init/model-picker'
19
+ import { deviceLink, resolveWebBase } from './login'
15
20
 
16
21
  /**
17
- * `lyra init` — bootstrap the agent config for Sui.
22
+ * `lyra init` — bootstrap the agent for Sui with ZERO env vars.
18
23
  *
19
- * On Sui there is no operator wallet, keystore, or funding step. The agent IS
20
- * the signer: one Ed25519 keypair, supplied via the `LYRA_AGENT_KEY` env var
21
- * (`suiprivkey1…`). init resolves that key, derives the agent's Sui address,
22
- * writes `~/.lyra/config.ts`, and seeds the starter memory files.
24
+ * The user answers ~1 prompt and the CLI works:
25
+ * 1. LLM key reuse OPENAI_API_KEY if set, else prompt + persist to
26
+ * `~/.lyra/.env` (mode 0600).
27
+ * 2. Agent "Create new" (generate a fresh Ed25519 key, written to
28
+ * `~/.lyra/agent.key`) OR "Login with web" (device-link to lyraai.space →
29
+ * the SAME agent as the web wallet).
30
+ * 3. Config — network + package id defaults written to `~/.lyra/config.ts`.
31
+ *
32
+ * Non-interactive safe: when stdin is not a TTY, or `--yes` / `--new` is passed,
33
+ * prompts are skipped and init defaults to "Create new" using env-provided keys.
23
34
  */
24
- export async function runInit(opts?: { resume?: boolean }): Promise<void> {
35
+ export interface InitOpts {
36
+ resume?: boolean
37
+ /** Skip all prompts; default to "Create new" (CI / non-TTY). */
38
+ yes?: boolean
39
+ /** Force "Create new" even in a TTY (skip the create/login choice). */
40
+ new?: boolean
41
+ }
42
+
43
+ export async function runInit(opts?: InitOpts): Promise<void> {
25
44
  const configPath = agentPaths.config
45
+ const interactive = !!process.stdin.isTTY && !opts?.yes
46
+ const forceNew = !!opts?.new || !interactive
26
47
 
27
48
  intro('lyra init')
28
49
 
29
- if (existsSync(configPath) && !opts?.resume) {
50
+ if (existsSync(configPath) && !opts?.resume && interactive) {
30
51
  const choice = (await select({
31
52
  message: `${configPath} exists`,
32
53
  options: [
@@ -41,28 +62,97 @@ export async function runInit(opts?: { resume?: boolean }): Promise<void> {
41
62
  }
42
63
  }
43
64
 
44
- // The agent key is sourced from the environment (never prompted/stored on
45
- // disk). If it is missing we cannot derive the Sui address, so bail with a
46
- // clear instruction.
47
- const agent = loadAgentFromEnv()
48
- if (!agent) {
49
- cancel(
50
- `No ${AGENT_KEY_ENV} set. Export your agent's Sui secret key first, e.g.\n export ${AGENT_KEY_ENV}=suiprivkey1...\nThen re-run \`lyra init\`.`,
51
- )
52
- return
65
+ // 1) LLM key. Reuse OPENAI_API_KEY if present; else prompt + persist so the
66
+ // CLI runs keyless next time. In non-interactive mode we just use whatever
67
+ // the env provides (the CLI falls back to the hosted demo proxy if unset).
68
+ if (interactive && !process.env.OPENAI_API_KEY && !process.env.LYRA_LLM_API_KEY) {
69
+ const key = await password({
70
+ message: 'OpenAI API key (sk-…) — leave blank to use the hosted demo proxy',
71
+ })
72
+ if (isCancel(key)) {
73
+ cancel('Aborted.')
74
+ return
75
+ }
76
+ const trimmed = (key ?? '').toString().trim()
77
+ if (trimmed) {
78
+ const envPath = setDotenvVar('OPENAI_API_KEY', trimmed)
79
+ console.log(` saved OPENAI_API_KEY → ${envPath}`)
80
+ }
81
+ }
82
+
83
+ // 2) Agent: create new (default) or login with web.
84
+ let agent: SuiAgent
85
+ let linkedOwner: string | null = null
86
+
87
+ let mode: 'create' | 'login' = 'create'
88
+ if (!forceNew) {
89
+ const picked = (await select({
90
+ message: 'How do you want your agent?',
91
+ options: [
92
+ {
93
+ value: 'create',
94
+ label: 'Create new — generate a fresh agent, you hold the key',
95
+ },
96
+ {
97
+ value: 'login',
98
+ label: 'Login with web — use the same agent as lyraai.space',
99
+ },
100
+ ],
101
+ initialValue: 'create',
102
+ })) as 'create' | 'login' | symbol
103
+ if (isCancel(picked)) {
104
+ cancel('Aborted.')
105
+ return
106
+ }
107
+ mode = picked
108
+ }
109
+
110
+ if (mode === 'login') {
111
+ try {
112
+ const result = await deviceLink({
113
+ base: resolveWebBase(),
114
+ fetchImpl: fetch,
115
+ sleep: (ms: number) => new Promise(r => setTimeout(r, ms)),
116
+ log: (m: string) => console.log(m),
117
+ })
118
+ linkedOwner = result.owner
119
+ // writeAgentKey already ran inside deviceLink; reload from disk.
120
+ const loaded = loadAgent()
121
+ if (!loaded) throw new Error('agent key was not written after login')
122
+ agent = loaded
123
+ console.log('')
124
+ console.log(`✓ Linked agent ${result.address} (same as your web wallet)`)
125
+ } catch (e) {
126
+ cancel(`Login failed: ${(e as Error).message}`)
127
+ return
128
+ }
129
+ } else {
130
+ // Create new: generate a fresh Ed25519 agent and persist the secret.
131
+ const kp = new Ed25519Keypair()
132
+ const secret = kp.getSecretKey()
133
+ const keyPath = writeAgentKey(secret)
134
+ agent = { keypair: keypairFromSecret(secret), address: kp.toSuiAddress() }
135
+ console.log('')
136
+ console.log(`✓ Created agent ${agent.address}`)
137
+ console.log(` key ${keyPath} (mode 0600 — back this up; it controls funds)`)
53
138
  }
54
139
 
55
- const network = (await select({
56
- message: 'Which Sui network?',
57
- options: [
58
- { value: 'mainnet' as LyraNetwork, label: 'Sui mainnet' },
59
- { value: 'testnet' as LyraNetwork, label: 'Sui testnet' },
60
- ],
61
- initialValue: 'mainnet' as LyraNetwork,
62
- })) as LyraNetwork
63
- if (isCancel(network)) {
64
- cancel('Aborted.')
65
- return
140
+ // 3) Network. Skip the prompt when non-interactive (default mainnet).
141
+ let network: LyraNetwork = DEFAULT_NETWORK
142
+ if (interactive) {
143
+ const picked = (await select({
144
+ message: 'Which Sui network?',
145
+ options: [
146
+ { value: 'mainnet' as LyraNetwork, label: 'Sui mainnet' },
147
+ { value: 'testnet' as LyraNetwork, label: 'Sui testnet' },
148
+ ],
149
+ initialValue: DEFAULT_NETWORK as LyraNetwork,
150
+ })) as LyraNetwork | symbol
151
+ if (isCancel(picked)) {
152
+ cancel('Aborted.')
153
+ return
154
+ }
155
+ network = picked
66
156
  }
67
157
 
68
158
  const modelPick = await pickBrainModel()
@@ -85,7 +175,7 @@ export async function runInit(opts?: { resume?: boolean }): Promise<void> {
85
175
 
86
176
  const cfg = defineConfig({
87
177
  identity: {
88
- operator: null,
178
+ operator: linkedOwner,
89
179
  agent: agent.address,
90
180
  },
91
181
  network,
@@ -102,15 +192,17 @@ export async function runInit(opts?: { resume?: boolean }): Promise<void> {
102
192
  header: '// Regenerated by `lyra init`. Edit freely; type-safe.',
103
193
  })
104
194
 
105
- const packageId = process.env.LYRA_PACKAGE_ID
195
+ const packageId = resolvePackageId()
106
196
  const lines = [
107
197
  '',
108
198
  ` agent id ${agentId}`,
109
199
  ` agent addr ${agent.address}`,
200
+ linkedOwner ? ` owner ${linkedOwner} (linked from web)` : '',
110
201
  ` network ${network} (${suiRpcUrl(network)})`,
111
202
  ` config ${configPath}`,
112
- ` policy pkg ${packageId ?? '(set LYRA_PACKAGE_ID for on-chain receipts)'}`,
113
- ]
203
+ ` agent key ${agentPaths.agentKey}`,
204
+ ` policy pkg ${packageId}`,
205
+ ].filter(Boolean)
114
206
  if (modelPick) lines.push(` brain ${modelPick.model ?? '?'} (${modelPick.provider})`)
115
207
  if (telegramEnabled) lines.push(' telegram enabled (TELEGRAM_BOT_TOKEN set)')
116
208
  lines.push(
@@ -0,0 +1,104 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { mkdtempSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
6
+ import { type LoginDeps, deviceLink, resolveWebBase } from './login'
7
+
8
+ // Isolate the agent dir so writeAgentKey() (called on approval) doesn't touch
9
+ // the real ~/.lyra.
10
+ let prevRoot: string | undefined
11
+ beforeEach(() => {
12
+ prevRoot = process.env.LYRA_ROOT
13
+ process.env.LYRA_ROOT = mkdtempSync(join(tmpdir(), 'lyra-login-'))
14
+ })
15
+ afterEach(() => {
16
+ if (prevRoot === undefined) Reflect.deleteProperty(process.env, 'LYRA_ROOT')
17
+ else process.env.LYRA_ROOT = prevRoot
18
+ })
19
+
20
+ /** Build a mock fetch over a scripted sequence of poll responses. */
21
+ function mockFetch(start: unknown, polls: unknown[]): { fetchImpl: typeof fetch; calls: string[] } {
22
+ const calls: string[] = []
23
+ let pollIdx = 0
24
+ const fetchImpl = (async (url: string | URL | Request) => {
25
+ const u = String(url)
26
+ calls.push(u)
27
+ if (u.includes('/api/cli/login/start')) {
28
+ return new Response(JSON.stringify(start), { status: 200 })
29
+ }
30
+ if (u.includes('/api/cli/login/poll')) {
31
+ const body = polls[Math.min(pollIdx, polls.length - 1)]
32
+ pollIdx += 1
33
+ return new Response(JSON.stringify(body), { status: 200 })
34
+ }
35
+ return new Response('not found', { status: 404 })
36
+ }) as unknown as typeof fetch
37
+ return { fetchImpl, calls }
38
+ }
39
+
40
+ const baseDeps = (fetchImpl: typeof fetch): LoginDeps => ({
41
+ base: 'https://example.test',
42
+ fetchImpl,
43
+ sleep: async () => {},
44
+ log: () => {},
45
+ openBrowser: () => {},
46
+ })
47
+
48
+ describe('resolveWebBase', () => {
49
+ test('defaults to lyraai.space, env override wins', () => {
50
+ expect(resolveWebBase({})).toBe('https://lyraai.space')
51
+ expect(resolveWebBase({ LYRA_WEB_URL: 'http://localhost:3000' })).toBe('http://localhost:3000')
52
+ })
53
+ })
54
+
55
+ describe('deviceLink contract', () => {
56
+ test('calls start then poll, writes key on approved, returns address', async () => {
57
+ const kp = new Ed25519Keypair()
58
+ const agentKey = kp.getSecretKey()
59
+ const owner = `0x${'a'.repeat(64)}`
60
+ const { fetchImpl, calls } = mockFetch(
61
+ { code: 'ABCD', pollToken: 'tok-1', verifyUrl: 'https://example.test/cli', expiresInSec: 60 },
62
+ [{ status: 'pending' }, { status: 'approved', owner, agentKey }],
63
+ )
64
+ const res = await deviceLink(baseDeps(fetchImpl))
65
+ expect(res.owner).toBe(owner)
66
+ expect(res.address).toBe(kp.toSuiAddress())
67
+ // start first, then at least two polls.
68
+ expect(calls[0]).toContain('/api/cli/login/start')
69
+ expect(calls[1]).toContain('/api/cli/login/poll?pollToken=tok-1')
70
+ expect(calls.length).toBeGreaterThanOrEqual(3)
71
+ })
72
+
73
+ test('throws a friendly error on expired', async () => {
74
+ const { fetchImpl } = mockFetch(
75
+ { code: 'X', pollToken: 't', verifyUrl: 'https://example.test/cli', expiresInSec: 60 },
76
+ [{ status: 'expired' }],
77
+ )
78
+ await expect(deviceLink(baseDeps(fetchImpl))).rejects.toThrow(/expired/i)
79
+ })
80
+
81
+ test('throws on not_found', async () => {
82
+ const { fetchImpl } = mockFetch(
83
+ { code: 'X', pollToken: 't', verifyUrl: 'https://example.test/cli', expiresInSec: 60 },
84
+ [{ status: 'not_found' }],
85
+ )
86
+ await expect(deviceLink(baseDeps(fetchImpl))).rejects.toThrow(/not found/i)
87
+ })
88
+
89
+ test('times out when expiresInSec elapses without approval', async () => {
90
+ const { fetchImpl } = mockFetch(
91
+ { code: 'X', pollToken: 't', verifyUrl: 'https://example.test/cli', expiresInSec: 4 },
92
+ [{ status: 'pending' }],
93
+ )
94
+ // Advancing clock: each read jumps 1s, so a 4s window closes after a few
95
+ // polls without ever hitting the wall clock.
96
+ let t = 0
97
+ const now = (): number => {
98
+ t += 1000
99
+ return t
100
+ }
101
+ const deps: LoginDeps = { ...baseDeps(fetchImpl), now }
102
+ await expect(deviceLink(deps)).rejects.toThrow(/timed out/i)
103
+ })
104
+ })
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `lyra login` — device-link the CLI to the same agent your web wallet
3
+ * controls on lyraai.space.
4
+ *
5
+ * Flow (matches the web server contract exactly):
6
+ * 1. POST {base}/api/cli/login/start → { code, pollToken, verifyUrl, expiresInSec }
7
+ * 2. Show the user the verify URL + code; best-effort open the browser.
8
+ * 3. Poll GET {base}/api/cli/login/poll?pollToken=… every 2s until expiry.
9
+ * → { status:'pending' } keep waiting
10
+ * → { status:'approved', owner, agentKey } write the key, done
11
+ * → { status:'expired' | 'not_found' } friendly error, exit non-zero.
12
+ *
13
+ * The approved `agentKey` is a `suiprivkey1…` string — the SAME secret the web
14
+ * derives for that owner — so the CLI and the web operate one identical agent.
15
+ */
16
+
17
+ import { keypairFromSecret } from 'lyra-plugin-onchain'
18
+ import { writeAgentKey } from '../util/sui-runtime'
19
+
20
+ /** Default web origin; override with LYRA_WEB_URL for local/staging testing. */
21
+ const DEFAULT_WEB_URL = 'https://lyraai.space'
22
+ const POLL_INTERVAL_MS = 2000
23
+
24
+ export interface LoginStart {
25
+ code: string
26
+ pollToken: string
27
+ verifyUrl: string
28
+ expiresInSec: number
29
+ }
30
+
31
+ export type LoginPoll =
32
+ | { status: 'pending' }
33
+ | { status: 'approved'; owner: string; agentKey: string }
34
+ | { status: 'expired' }
35
+ | { status: 'not_found' }
36
+
37
+ export interface LoginResult {
38
+ owner: string
39
+ address: string
40
+ keyPath: string
41
+ }
42
+
43
+ export interface LoginDeps {
44
+ base: string
45
+ fetchImpl: typeof fetch
46
+ /** Sleep between polls (injectable so tests run instantly). */
47
+ sleep: (ms: number) => Promise<void>
48
+ /** Monotonic clock (injectable so timeout tests are deterministic). */
49
+ now?: () => number
50
+ log: (msg: string) => void
51
+ openBrowser?: (url: string) => void
52
+ }
53
+
54
+ const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
55
+
56
+ /** Best-effort: open `url` in the user's default browser. Never throws. */
57
+ function openBrowserBestEffort(url: string): void {
58
+ try {
59
+ const platform = process.platform
60
+ const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open'
61
+ // Lazy import so the happy path / tests don't pull in child_process.
62
+ const { spawn } = require('node:child_process') as typeof import('node:child_process')
63
+ const child = spawn(cmd, [url], { stdio: 'ignore', detached: true })
64
+ child.on('error', () => {})
65
+ child.unref()
66
+ } catch {
67
+ // Headless / no browser — the printed URL is the fallback.
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Core device-link logic, dependency-injected so tests never hit the network.
73
+ * Resolves with the linked agent or throws a friendly Error on
74
+ * expiry/timeout/not_found.
75
+ */
76
+ export async function deviceLink(deps: LoginDeps): Promise<LoginResult> {
77
+ const { base, fetchImpl, log } = deps
78
+
79
+ const startRes = await fetchImpl(`${base}/api/cli/login/start`, {
80
+ method: 'POST',
81
+ headers: { 'content-type': 'application/json' },
82
+ body: '{}',
83
+ })
84
+ if (!startRes.ok) {
85
+ throw new Error(`login/start failed (HTTP ${startRes.status}) at ${base}`)
86
+ }
87
+ const start = (await startRes.json()) as LoginStart
88
+ if (!start?.code || !start?.pollToken || !start?.verifyUrl) {
89
+ throw new Error('login/start returned an unexpected response')
90
+ }
91
+
92
+ const linkUrl = `${start.verifyUrl}?code=${encodeURIComponent(start.code)}`
93
+ log('')
94
+ log('Approve this login in your browser:')
95
+ log(` ${linkUrl}`)
96
+ log(` (or enter code: ${start.code})`)
97
+ log('')
98
+ log('Waiting for approval…')
99
+ ;(deps.openBrowser ?? openBrowserBestEffort)(linkUrl)
100
+
101
+ const now = deps.now ?? Date.now
102
+ const expiresInSec = start.expiresInSec > 0 ? start.expiresInSec : 300
103
+ const deadline = now() + expiresInSec * 1000
104
+
105
+ while (now() < deadline) {
106
+ await deps.sleep(POLL_INTERVAL_MS)
107
+ const pollRes = await fetchImpl(
108
+ `${base}/api/cli/login/poll?pollToken=${encodeURIComponent(start.pollToken)}`,
109
+ )
110
+ if (!pollRes.ok) {
111
+ // Transient server hiccup — keep polling until the deadline.
112
+ continue
113
+ }
114
+ const poll = (await pollRes.json()) as LoginPoll
115
+ if (poll.status === 'approved') {
116
+ const keyPath = writeAgentKey(poll.agentKey)
117
+ const address = keypairFromSecret(poll.agentKey).toSuiAddress()
118
+ return { owner: poll.owner, address, keyPath }
119
+ }
120
+ if (poll.status === 'expired') {
121
+ throw new Error('Login request expired. Re-run `lyra login` and approve faster.')
122
+ }
123
+ if (poll.status === 'not_found') {
124
+ throw new Error('Login request not found (already used or invalid). Re-run `lyra login`.')
125
+ }
126
+ // pending → keep waiting.
127
+ }
128
+ throw new Error('Login timed out before approval. Re-run `lyra login`.')
129
+ }
130
+
131
+ /** Resolve the web base URL (env override → default). */
132
+ export function resolveWebBase(env: NodeJS.ProcessEnv = process.env): string {
133
+ return env.LYRA_WEB_URL ?? DEFAULT_WEB_URL
134
+ }
135
+
136
+ /** Entry point for `lyra login`. Exits non-zero on failure. */
137
+ export async function runLogin(): Promise<void> {
138
+ const base = resolveWebBase()
139
+ try {
140
+ const result = await deviceLink({
141
+ base,
142
+ fetchImpl: fetch,
143
+ sleep,
144
+ log: (m: string) => console.log(m),
145
+ })
146
+ console.log('')
147
+ console.log(`✓ Linked agent ${result.address} (same as your web wallet)`)
148
+ console.log(` owner ${result.owner}`)
149
+ console.log(` key ${result.keyPath}`)
150
+ console.log('')
151
+ console.log('Next: `lyra` to chat · `lyra status` for health')
152
+ } catch (e) {
153
+ console.error(`lyra login: ${(e as Error).message}`)
154
+ process.exit(1)
155
+ }
156
+ }
@@ -1,8 +1,9 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
2
  import { agentPaths, formatSui, getSuiBalanceMist, makeSuiClient, suiRpcUrl } from 'lyra-core'
3
3
  import { type SuiPolicy, policyFromEnv } from 'lyra-plugin-onchain'
4
+ import { resolvePackageId, resolvePolicyEnv } from '../config/defaults'
4
5
  import { findAndLoadConfig } from '../config/load'
5
- import { loadAgentFromEnv } from '../util/sui-runtime'
6
+ import { loadAgent } from '../util/sui-runtime'
6
7
  import { listAgentIds } from './_agents'
7
8
 
8
9
  export async function runStatus(opts?: { cwd?: string }): Promise<void> {
@@ -14,8 +15,8 @@ export async function runStatus(opts?: { cwd?: string }): Promise<void> {
14
15
  }
15
16
  const { config, path } = found
16
17
 
17
- const agent = loadAgentFromEnv()
18
- const agentAddress = agent?.address ?? config.identity.agent ?? '(no LYRA_AGENT_KEY)'
18
+ const agent = loadAgent()
19
+ const agentAddress = agent?.address ?? config.identity.agent ?? '(no agent key — run `lyra init`)'
19
20
 
20
21
  console.log(`config ${path}`)
21
22
  console.log(`network ${config.network}`)
@@ -25,11 +26,11 @@ export async function runStatus(opts?: { cwd?: string }): Promise<void> {
25
26
  console.log(`brain ${config.brain.provider ?? '(not picked)'}`)
26
27
 
27
28
  // lyra::policy package + active deterministic policy summary.
28
- const packageId = process.env.LYRA_PACKAGE_ID
29
- console.log(`policy pkg ${packageId ?? '(LYRA_PACKAGE_ID unset)'}`)
29
+ const packageId = resolvePackageId()
30
+ console.log(`policy pkg ${packageId}`)
30
31
  const policyObjectId = process.env.LYRA_POLICY_OBJECT_ID
31
32
  if (policyObjectId) console.log(`policy obj ${policyObjectId}`)
32
- const policy = policyFromEnv()
33
+ const policy = policyFromEnv(resolvePolicyEnv())
33
34
  if (policy) {
34
35
  console.log(`policy ${summarizePolicy(policy)}`)
35
36
  } else {
@@ -8,12 +8,8 @@
8
8
  * on CLI), but they all resolve to the same agent.
9
9
  */
10
10
 
11
- import {
12
- type SuiNetwork,
13
- deriveAgentAddress,
14
- makeSuiClient,
15
- resolveOwnerVault,
16
- } from 'lyra-plugin-onchain'
11
+ import { deriveAgentAddress, makeSuiClient, resolveOwnerVault } from 'lyra-plugin-onchain'
12
+ import { resolveNetwork } from '../config/defaults'
17
13
 
18
14
  export async function runWhoami(opts: { owner?: string }): Promise<void> {
19
15
  const owner = opts.owner ?? process.env.LYRA_OWNER_ADDRESS
@@ -30,7 +26,7 @@ export async function runWhoami(opts: { owner?: string }): Promise<void> {
30
26
  process.exit(1)
31
27
  return
32
28
  }
33
- const network = (process.env.LYRA_NETWORK as SuiNetwork) ?? 'mainnet'
29
+ const network = resolveNetwork()
34
30
  const client = makeSuiClient(network)
35
31
  const [bal, ov] = await Promise.all([
36
32
  client.getBalance({ owner: agent }).catch(() => null),
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ DEFAULT_ALLOWED_COINS,
4
+ DEFAULT_ALLOWED_PROTOCOLS,
5
+ DEFAULT_AUTO_MAX_SUI,
6
+ DEFAULT_LLM_BASE_URL,
7
+ DEFAULT_LLM_MODEL,
8
+ DEFAULT_MAX_PER_TX_SUI,
9
+ DEFAULT_MAX_SLIPPAGE_BPS,
10
+ DEFAULT_NETWORK,
11
+ DEFAULT_PACKAGE_ID,
12
+ defaultPolicyEnv,
13
+ hasNoPolicyEnv,
14
+ resolveLlmBaseUrl,
15
+ resolveLlmModel,
16
+ resolveNetwork,
17
+ resolvePackageId,
18
+ resolvePolicyEnv,
19
+ } from './defaults'
20
+
21
+ describe('config defaults (zero-env-var)', () => {
22
+ test('exported constant values match the shared contract', () => {
23
+ expect(DEFAULT_PACKAGE_ID).toBe(
24
+ '0x8e984145d636037cebf5c402ac4b338567411ba6dd275948d7ff593b1ed01a04',
25
+ )
26
+ expect(DEFAULT_NETWORK).toBe('mainnet')
27
+ expect(DEFAULT_LLM_BASE_URL).toBe('https://api.openai.com/v1')
28
+ expect(DEFAULT_LLM_MODEL).toBe('gpt-4o-mini')
29
+ expect(DEFAULT_MAX_PER_TX_SUI).toBe(1)
30
+ expect(DEFAULT_AUTO_MAX_SUI).toBe(0.1)
31
+ expect(DEFAULT_MAX_SLIPPAGE_BPS).toBe(100)
32
+ expect(DEFAULT_ALLOWED_COINS).toEqual(['0x2::sui::SUI'])
33
+ expect(DEFAULT_ALLOWED_PROTOCOLS).toEqual([
34
+ 'transfer',
35
+ 'swap',
36
+ 'deepbook',
37
+ 'scallop',
38
+ 'navi',
39
+ 'walrus',
40
+ ])
41
+ })
42
+
43
+ test('resolvePackageId: default when env unset, env wins when set', () => {
44
+ expect(resolvePackageId({})).toBe(DEFAULT_PACKAGE_ID)
45
+ expect(resolvePackageId({ LYRA_PACKAGE_ID: '0xabc' })).toBe('0xabc')
46
+ // Blank/whitespace env falls back to the default.
47
+ expect(resolvePackageId({ LYRA_PACKAGE_ID: ' ' })).toBe(DEFAULT_PACKAGE_ID)
48
+ })
49
+
50
+ test('resolveNetwork: default mainnet, env override, ignores garbage', () => {
51
+ expect(resolveNetwork({})).toBe('mainnet')
52
+ expect(resolveNetwork({ LYRA_NETWORK: 'testnet' })).toBe('testnet')
53
+ expect(resolveNetwork({ LYRA_NETWORK: 'devnet' })).toBe('mainnet')
54
+ })
55
+
56
+ test('resolveLlmBaseUrl / resolveLlmModel: default then env override', () => {
57
+ expect(resolveLlmBaseUrl({})).toBe(DEFAULT_LLM_BASE_URL)
58
+ expect(resolveLlmBaseUrl({ LYRA_LLM_BASE_URL: 'https://gw' })).toBe('https://gw')
59
+ expect(resolveLlmModel({})).toBe(DEFAULT_LLM_MODEL)
60
+ expect(resolveLlmModel({ LYRA_LLM_MODEL: 'gpt-4o' })).toBe('gpt-4o')
61
+ })
62
+
63
+ test('defaultPolicyEnv produces a bounded LYRA_POLICY_* record', () => {
64
+ const env = defaultPolicyEnv()
65
+ expect(env.LYRA_POLICY_MAX_PER_TX_SUI).toBe('1')
66
+ expect(env.LYRA_POLICY_AUTO_MAX_SUI).toBe('0.1')
67
+ expect(env.LYRA_POLICY_MAX_SLIPPAGE_BPS).toBe('100')
68
+ expect(env.LYRA_POLICY_ALLOWED_COINS).toBe('0x2::sui::SUI')
69
+ expect(env.LYRA_POLICY_ALLOWED_PROTOCOLS).toBe('transfer,swap,deepbook,scallop,navi,walrus')
70
+ })
71
+
72
+ test('hasNoPolicyEnv: true with no policy vars, false when any set', () => {
73
+ expect(hasNoPolicyEnv({})).toBe(true)
74
+ expect(hasNoPolicyEnv({ OPENAI_API_KEY: 'x' })).toBe(true)
75
+ expect(hasNoPolicyEnv({ LYRA_POLICY_MAX_PER_TX_SUI: '5' })).toBe(false)
76
+ })
77
+
78
+ test('resolvePolicyEnv: seeds defaults only when no policy var is set', () => {
79
+ // No policy env → defaults applied.
80
+ const seeded = resolvePolicyEnv({})
81
+ expect(seeded.LYRA_POLICY_MAX_PER_TX_SUI).toBe('1')
82
+ expect(seeded.LYRA_POLICY_ALLOWED_PROTOCOLS).toContain('deepbook')
83
+
84
+ // Any policy env present → caller's env passed through untouched (no merge).
85
+ const userEnv = { LYRA_POLICY_MAX_PER_TX_SUI: '5' }
86
+ const passed = resolvePolicyEnv(userEnv)
87
+ expect(passed.LYRA_POLICY_MAX_PER_TX_SUI).toBe('5')
88
+ expect(passed.LYRA_POLICY_ALLOWED_PROTOCOLS).toBeUndefined()
89
+ })
90
+ })
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Zero-env-var defaults for the Lyra CLI.
3
+ *
4
+ * The whole point of `lyra init` is that a fresh user runs ONE prompt and the
5
+ * CLI works with no env vars. Every value here is the fallback used when the
6
+ * corresponding `LYRA_*` / `OPENAI_*` env var is unset. Env still WINS when set
7
+ * — these are last-resort defaults, never overrides.
8
+ */
9
+
10
+ /** Deployed `lyra::policy` Move package on mainnet (on-chain receipts). */
11
+ export const DEFAULT_PACKAGE_ID =
12
+ '0x8e984145d636037cebf5c402ac4b338567411ba6dd275948d7ff593b1ed01a04'
13
+
14
+ /** Default Sui network. */
15
+ export const DEFAULT_NETWORK = 'mainnet' as const
16
+
17
+ /** Default OpenAI-compatible LLM endpoint + model. */
18
+ export const DEFAULT_LLM_BASE_URL = 'https://api.openai.com/v1'
19
+ export const DEFAULT_LLM_MODEL = 'gpt-4o-mini'
20
+
21
+ /** Default deterministic policy bounds (mirrored on-chain by `lyra::policy`). */
22
+ export const DEFAULT_MAX_PER_TX_SUI = 1
23
+ export const DEFAULT_AUTO_MAX_SUI = 0.1
24
+ export const DEFAULT_MAX_SLIPPAGE_BPS = 100
25
+ export const DEFAULT_ALLOWED_COINS = ['0x2::sui::SUI']
26
+ export const DEFAULT_ALLOWED_PROTOCOLS = [
27
+ 'transfer',
28
+ 'swap',
29
+ 'deepbook',
30
+ 'scallop',
31
+ 'navi',
32
+ 'walrus',
33
+ ]
34
+
35
+ /**
36
+ * Resolve the package id: env override first, then the deployed default. Always
37
+ * returns a concrete id so on-chain receipts work with zero config.
38
+ */
39
+ export function resolvePackageId(env: NodeJS.ProcessEnv = process.env): string {
40
+ const fromEnv = env.LYRA_PACKAGE_ID
41
+ return fromEnv && fromEnv.trim().length > 0 ? fromEnv.trim() : DEFAULT_PACKAGE_ID
42
+ }
43
+
44
+ /** Resolve the network: env override first, then `mainnet`. */
45
+ export function resolveNetwork(env: NodeJS.ProcessEnv = process.env): 'mainnet' | 'testnet' {
46
+ const fromEnv = env.LYRA_NETWORK
47
+ return fromEnv === 'testnet' || fromEnv === 'mainnet' ? fromEnv : DEFAULT_NETWORK
48
+ }
49
+
50
+ /** Resolve the LLM base URL: env override first, then the OpenAI endpoint. */
51
+ export function resolveLlmBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
52
+ return env.LYRA_LLM_BASE_URL ?? DEFAULT_LLM_BASE_URL
53
+ }
54
+
55
+ /** Resolve the LLM model: env override first, then `gpt-4o-mini`. */
56
+ export function resolveLlmModel(env: NodeJS.ProcessEnv = process.env): string {
57
+ return env.LYRA_LLM_MODEL ?? DEFAULT_LLM_MODEL
58
+ }
59
+
60
+ /**
61
+ * Build a `LYRA_POLICY_*`-shaped env record from the defaults, used only to seed
62
+ * `policyFromEnv` when the user has not set ANY `LYRA_POLICY_*` var. This keeps
63
+ * the agent bounded out of the box (never an unbounded auto-spender).
64
+ */
65
+ export function defaultPolicyEnv(): Record<string, string> {
66
+ return {
67
+ LYRA_POLICY_MAX_PER_TX_SUI: String(DEFAULT_MAX_PER_TX_SUI),
68
+ LYRA_POLICY_AUTO_MAX_SUI: String(DEFAULT_AUTO_MAX_SUI),
69
+ LYRA_POLICY_MAX_SLIPPAGE_BPS: String(DEFAULT_MAX_SLIPPAGE_BPS),
70
+ LYRA_POLICY_ALLOWED_COINS: DEFAULT_ALLOWED_COINS.join(','),
71
+ LYRA_POLICY_ALLOWED_PROTOCOLS: DEFAULT_ALLOWED_PROTOCOLS.join(','),
72
+ }
73
+ }
74
+
75
+ /** True when the user set no `LYRA_POLICY_*` var (so defaults should apply). */
76
+ export function hasNoPolicyEnv(env: NodeJS.ProcessEnv = process.env): boolean {
77
+ return !Object.keys(env).some(k => k.startsWith('LYRA_POLICY_'))
78
+ }
79
+
80
+ /**
81
+ * Resolve the deterministic policy env: the user's `LYRA_POLICY_*` vars if ANY
82
+ * are set, otherwise the bounded defaults. Pass the result to `policyFromEnv`.
83
+ */
84
+ export function resolvePolicyEnv(
85
+ env: NodeJS.ProcessEnv = process.env,
86
+ ): Record<string, string | undefined> {
87
+ return hasNoPolicyEnv(env) ? { ...env, ...defaultPolicyEnv() } : env
88
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,13 @@
3
3
  * commands/<name>.
4
4
  */
5
5
 
6
+ import { loadDotenvFile } from './util/dotenv'
7
+
8
+ // Zero-env-var startup: load `~/.lyra/.env` (e.g. the OPENAI_API_KEY that
9
+ // `lyra init` persisted) into process.env before any command reads it. Real
10
+ // shell env always wins — loadDotenvFile only fills UNSET keys.
11
+ loadDotenvFile()
12
+
6
13
  const argv = process.argv.slice(2)
7
14
  // First arg starting with `--` means the user invoked the default subcommand
8
15
  // (chat) with flags, e.g. `lyra --yolo`. Treat it as if `chat` were implicit.
@@ -21,7 +28,15 @@ async function main(): Promise<void> {
21
28
  }
22
29
  case 'init': {
23
30
  const { runInit } = await import('./commands/init')
24
- await runInit()
31
+ await runInit({
32
+ yes: argv.includes('--yes') || argv.includes('-y'),
33
+ new: argv.includes('--new'),
34
+ })
35
+ return
36
+ }
37
+ case 'login': {
38
+ const { runLogin } = await import('./commands/login')
39
+ await runLogin()
25
40
  return
26
41
  }
27
42
  case 'status': {
@@ -113,7 +128,8 @@ function printHelp(): void {
113
128
  'lyra: a Sui-native, policy-bound AI finance agent',
114
129
  '',
115
130
  'Commands:',
116
- ' lyra init bootstrap the agent config (uses LYRA_AGENT_KEY)',
131
+ ' lyra init set up your agent (interactive; --new / --yes skip prompts)',
132
+ ' lyra login link the same agent as lyraai.space (device-link)',
117
133
  ' lyra [--yolo] interactive chat with your agent (default; --yolo skips approvals)',
118
134
  ' lyra status show agent address + network + SUI balance + policy',
119
135
  ' lyra whoami [--owner 0x…] resolve the agent wallet an owner controls (same on web/CLI/TG)',
@@ -128,8 +144,10 @@ function printHelp(): void {
128
144
  ' lyra version print CLI version (aliases: --version, -v)',
129
145
  ' lyra help show this message (aliases: --help, -h)',
130
146
  '',
131
- 'Env: LYRA_AGENT_KEY (suiprivkey1…), LYRA_NETWORK, LYRA_PACKAGE_ID,',
132
- ' LYRA_LLM_BASE_URL, LYRA_LLM_MODEL, OPENAI_API_KEY, LYRA_POLICY_*',
147
+ 'Zero-config: `lyra init` writes ~/.lyra/agent.key + ~/.lyra/.env, so no env',
148
+ 'vars are required. All env below are OPTIONAL overrides (env wins if set):',
149
+ ' LYRA_AGENT_KEY (suiprivkey1…), LYRA_NETWORK, LYRA_PACKAGE_ID, LYRA_WEB_URL,',
150
+ ' LYRA_LLM_BASE_URL, LYRA_LLM_MODEL, OPENAI_API_KEY, LYRA_POLICY_*',
133
151
  '',
134
152
  ].join('\n'),
135
153
  )
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Minimal `~/.lyra/.env` loader.
3
+ *
4
+ * `lyra init` persists the OpenAI key to `~/.lyra/.env` (mode 0600) so the CLI
5
+ * runs with zero shell env vars. On startup we read that file and fill any
6
+ * UNSET vars — real shell env always wins (we never clobber an existing value).
7
+ * Kept dependency-free (no dotenv pkg) and intentionally simple: `KEY=value`
8
+ * lines, `#` comments, optional surrounding quotes.
9
+ */
10
+
11
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
+ import { dirname } from 'node:path'
13
+ import { agentPaths } from 'lyra-core'
14
+
15
+ /** Parse `KEY=value` lines (ignoring blanks/comments) into a record. */
16
+ export function parseDotenv(text: string): Record<string, string> {
17
+ const out: Record<string, string> = {}
18
+ for (const raw of text.split('\n')) {
19
+ const line = raw.trim()
20
+ if (!line || line.startsWith('#')) continue
21
+ const eq = line.indexOf('=')
22
+ if (eq <= 0) continue
23
+ const key = line.slice(0, eq).trim()
24
+ let val = line.slice(eq + 1).trim()
25
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
26
+ val = val.slice(1, -1)
27
+ }
28
+ if (key) out[key] = val
29
+ }
30
+ return out
31
+ }
32
+
33
+ /**
34
+ * Load `~/.lyra/.env` into `process.env`, filling only UNSET keys. No-op when
35
+ * the file is absent. Returns the keys it actually set.
36
+ */
37
+ export function loadDotenvFile(): string[] {
38
+ const path = agentPaths.dotenv
39
+ if (!existsSync(path)) return []
40
+ const parsed = parseDotenv(readFileSync(path, 'utf8'))
41
+ const set: string[] = []
42
+ for (const [k, v] of Object.entries(parsed)) {
43
+ if (process.env[k] === undefined) {
44
+ process.env[k] = v
45
+ set.push(k)
46
+ }
47
+ }
48
+ return set
49
+ }
50
+
51
+ /**
52
+ * Persist (or update) a single `KEY=value` in `~/.lyra/.env`, preserving other
53
+ * lines, and lock the file to mode 0600. Also updates `process.env[key]`.
54
+ */
55
+ export function setDotenvVar(key: string, value: string): string {
56
+ const path = agentPaths.dotenv
57
+ mkdirSync(dirname(path), { recursive: true })
58
+ const existing = existsSync(path) ? parseDotenv(readFileSync(path, 'utf8')) : {}
59
+ existing[key] = value
60
+ const body = `${Object.entries(existing)
61
+ .map(([k, v]) => `${k}=${v}`)
62
+ .join('\n')}\n`
63
+ writeFileSync(path, body, { mode: 0o600 })
64
+ chmodSync(path, 0o600)
65
+ process.env[key] = value
66
+ return path
67
+ }
@@ -0,0 +1,73 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { mkdtempSync, statSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
6
+ import { agentPaths } from 'lyra-core'
7
+ import { loadAgent, loadAgentFromEnv, readAgentKeyFile, writeAgentKey } from './sui-runtime'
8
+
9
+ // Isolate the agent dir per-test via LYRA_ROOT so we never touch the real
10
+ // ~/.lyra. Also clear LYRA_AGENT_KEY so file-vs-env precedence is deterministic.
11
+ // Use Reflect.deleteProperty (not the `delete` operator) for a true unset —
12
+ // assigning `undefined` would leave the string "undefined" and break the loader.
13
+ let prevRoot: string | undefined
14
+ let prevKey: string | undefined
15
+
16
+ function restoreEnv(key: string, prev: string | undefined): void {
17
+ if (prev === undefined) Reflect.deleteProperty(process.env, key)
18
+ else process.env[key] = prev
19
+ }
20
+
21
+ beforeEach(() => {
22
+ prevRoot = process.env.LYRA_ROOT
23
+ prevKey = process.env.LYRA_AGENT_KEY
24
+ process.env.LYRA_ROOT = mkdtempSync(join(tmpdir(), 'lyra-runtime-'))
25
+ Reflect.deleteProperty(process.env, 'LYRA_AGENT_KEY')
26
+ })
27
+
28
+ afterEach(() => {
29
+ restoreEnv('LYRA_ROOT', prevRoot)
30
+ restoreEnv('LYRA_AGENT_KEY', prevKey)
31
+ })
32
+
33
+ describe('writeAgentKey / readAgentKeyFile', () => {
34
+ test('writes the secret to ~/.lyra/agent.key with mode 0600', () => {
35
+ const secret = new Ed25519Keypair().getSecretKey()
36
+ const path = writeAgentKey(secret)
37
+ expect(path).toBe(agentPaths.agentKey)
38
+ expect(readAgentKeyFile()).toBe(secret)
39
+ // Permission bits masked to the low 9 (rwx for u/g/o) should be 0600.
40
+ expect(statSync(path).mode & 0o777).toBe(0o600)
41
+ })
42
+
43
+ test('readAgentKeyFile returns null when no key file exists', () => {
44
+ expect(readAgentKeyFile()).toBeNull()
45
+ })
46
+ })
47
+
48
+ describe('loadAgent (env-first, then file)', () => {
49
+ test('returns null when neither env nor file is present', () => {
50
+ expect(loadAgent()).toBeNull()
51
+ expect(loadAgentFromEnv()).toBeNull()
52
+ })
53
+
54
+ test('reads the on-disk key when LYRA_AGENT_KEY is unset', () => {
55
+ const kp = new Ed25519Keypair()
56
+ writeAgentKey(kp.getSecretKey())
57
+ // env unset → falls through to the file.
58
+ expect(loadAgentFromEnv()).toBeNull()
59
+ const agent = loadAgent()
60
+ expect(agent).not.toBeNull()
61
+ expect(agent?.address).toBe(kp.toSuiAddress())
62
+ })
63
+
64
+ test('LYRA_AGENT_KEY env wins over the on-disk key', () => {
65
+ const fileKp = new Ed25519Keypair()
66
+ const envKp = new Ed25519Keypair()
67
+ writeAgentKey(fileKp.getSecretKey())
68
+ process.env.LYRA_AGENT_KEY = envKp.getSecretKey()
69
+ const agent = loadAgent()
70
+ expect(agent?.address).toBe(envKp.toSuiAddress())
71
+ expect(agent?.address).not.toBe(fileKp.toSuiAddress())
72
+ })
73
+ })
@@ -11,6 +11,9 @@
11
11
  * lyra-plugin-onchain tools read via `(ctx as any).onchain`.
12
12
  */
13
13
 
14
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
15
+ import { dirname } from 'node:path'
16
+ import { agentPaths } from 'lyra-core'
14
17
  import {
15
18
  type OnchainRuntimeContext,
16
19
  type SuiNetwork,
@@ -18,6 +21,7 @@ import {
18
21
  makeSuiClient,
19
22
  policyFromEnv,
20
23
  } from 'lyra-plugin-onchain'
24
+ import { resolvePackageId, resolvePolicyEnv } from '../config/defaults'
21
25
 
22
26
  /** Env var holding the agent's Sui secret key (`suiprivkey1…` or base64 seed). */
23
27
  export const AGENT_KEY_ENV = 'LYRA_AGENT_KEY'
@@ -40,6 +44,43 @@ export function loadAgentFromEnv(): SuiAgent | null {
40
44
  return { keypair, address: keypair.toSuiAddress() }
41
45
  }
42
46
 
47
+ /** Read the on-disk agent secret (`~/.lyra/agent.key`), or null if absent. */
48
+ export function readAgentKeyFile(): string | null {
49
+ const path = agentPaths.agentKey
50
+ if (!existsSync(path)) return null
51
+ const secret = readFileSync(path, 'utf8').trim()
52
+ return secret.length > 0 ? secret : null
53
+ }
54
+
55
+ /**
56
+ * Resolve the agent keypair the file-aware way: `LYRA_AGENT_KEY` env wins if
57
+ * set, else the on-disk key at `~/.lyra/agent.key`, else null. This is the
58
+ * loader every command should use — it makes the agent key a zero-env-var
59
+ * concern (`lyra init` / `lyra login` write the file).
60
+ */
61
+ export function loadAgent(): SuiAgent | null {
62
+ const fromEnv = loadAgentFromEnv()
63
+ if (fromEnv) return fromEnv
64
+ const secret = readAgentKeyFile()
65
+ if (!secret) return null
66
+ const keypair = keypairFromSecret(secret)
67
+ return { keypair, address: keypair.toSuiAddress() }
68
+ }
69
+
70
+ /**
71
+ * Persist the agent secret (`suiprivkey1…`) to `~/.lyra/agent.key` with mode
72
+ * 0600. Used by `lyra init` (create new) and `lyra login` (device-link).
73
+ */
74
+ export function writeAgentKey(suiprivkey: string): string {
75
+ const path = agentPaths.agentKey
76
+ mkdirSync(dirname(path), { recursive: true })
77
+ writeFileSync(path, `${suiprivkey.trim()}\n`, { mode: 0o600 })
78
+ // writeFileSync's mode is masked by umask on create and ignored when the file
79
+ // already exists — chmod unconditionally so re-runs stay locked down.
80
+ chmodSync(path, 0o600)
81
+ return path
82
+ }
83
+
43
84
  export interface BuildOnchainOpts {
44
85
  agent: SuiAgent
45
86
  network: SuiNetwork
@@ -57,14 +98,18 @@ export interface BuildOnchainOpts {
57
98
  * mirror comes from `policyFromEnv()` (the `LYRA_POLICY_*` vars).
58
99
  */
59
100
  export function buildOnchainContext(opts: BuildOnchainOpts): OnchainRuntimeContext {
60
- const packageId = opts.packageId ?? process.env.LYRA_PACKAGE_ID ?? undefined
101
+ // packageId: explicit opt wins, then LYRA_PACKAGE_ID, then the deployed
102
+ // mainnet default — so on-chain receipts work with zero env config.
103
+ const packageId = opts.packageId ?? resolvePackageId()
61
104
  const policyObjectId = opts.policyObjectId ?? process.env.LYRA_POLICY_OBJECT_ID ?? undefined
62
105
  return {
63
106
  client: makeSuiClient(opts.network),
64
107
  keypair: opts.agent.keypair,
65
108
  agentAddress: opts.agent.address,
66
109
  network: opts.network,
67
- policy: policyFromEnv(),
110
+ // Default to the bounded policy when no LYRA_POLICY_* is set (never an
111
+ // unbounded auto-spender out of the box).
112
+ policy: policyFromEnv(resolvePolicyEnv()),
68
113
  packageId: packageId || undefined,
69
114
  policyObjectId: policyObjectId || undefined,
70
115
  agentDir: opts.agentDir,