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 +6 -6
- package/src/commands/chat.tsx +10 -6
- package/src/commands/demo.ts +3 -3
- package/src/commands/init/model-picker.ts +3 -1
- package/src/commands/init.ts +125 -33
- package/src/commands/login.test.ts +104 -0
- package/src/commands/login.ts +156 -0
- package/src/commands/status.ts +7 -6
- package/src/commands/whoami.ts +3 -7
- package/src/config/defaults.test.ts +90 -0
- package/src/config/defaults.ts +88 -0
- package/src/index.ts +22 -4
- package/src/util/dotenv.ts +67 -0
- package/src/util/sui-runtime.test.ts +73 -0
- package/src/util/sui-runtime.ts +47 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lyra-ai-agent",
|
|
3
|
-
"version": "0.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.
|
|
54
|
-
"lyra-gateway": "^0.1.
|
|
55
|
-
"lyra-plugin-onchain": "^0.1.
|
|
56
|
-
"lyra-plugin-system": "^0.1.
|
|
57
|
-
"lyra-plugin-telegram": "^0.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"
|
package/src/commands/chat.tsx
CHANGED
|
@@ -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,
|
|
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 =
|
|
82
|
+
const agent = loadAgent()
|
|
82
83
|
if (!agent) {
|
|
83
|
-
console.log('No
|
|
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
|
-
|
|
164
|
-
const
|
|
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 ??
|
|
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 },
|
package/src/commands/demo.ts
CHANGED
|
@@ -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,
|
|
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 =
|
|
63
|
+
const agent = loadAgent()
|
|
64
64
|
if (!agent) {
|
|
65
|
-
console.log('No
|
|
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 ??
|
|
17
|
+
model: process.env.LYRA_LLM_MODEL ?? DEFAULT_LLM_MODEL,
|
|
16
18
|
}
|
|
17
19
|
}
|
package/src/commands/init.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
22
|
+
* `lyra init` — bootstrap the agent for Sui with ZERO env vars.
|
|
18
23
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* (
|
|
22
|
-
*
|
|
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
|
|
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
`
|
|
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
|
+
}
|
package/src/commands/status.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
18
|
-
const agentAddress = agent?.address ?? config.identity.agent ?? '(no
|
|
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 =
|
|
29
|
-
console.log(`policy pkg ${packageId
|
|
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 {
|
package/src/commands/whoami.ts
CHANGED
|
@@ -8,12 +8,8 @@
|
|
|
8
8
|
* on CLI), but they all resolve to the same agent.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
|
|
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 = (
|
|
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
|
|
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
|
-
'
|
|
132
|
-
'
|
|
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
|
+
})
|
package/src/util/sui-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|