lyra-ai-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +39 -0
  2. package/bin/lyra +11 -0
  3. package/package.json +65 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/chat-telegram.ts +398 -0
  6. package/src/commands/chat.tsx +1220 -0
  7. package/src/commands/demo.ts +177 -0
  8. package/src/commands/gateway-logs.ts +49 -0
  9. package/src/commands/gateway-run.ts +42 -0
  10. package/src/commands/gateway-start.ts +89 -0
  11. package/src/commands/gateway-status.ts +73 -0
  12. package/src/commands/gateway-stop.ts +133 -0
  13. package/src/commands/gateway.ts +101 -0
  14. package/src/commands/init/model-picker.ts +17 -0
  15. package/src/commands/init.ts +153 -0
  16. package/src/commands/logs.ts +37 -0
  17. package/src/commands/model.ts +48 -0
  18. package/src/commands/pairing-approve.ts +65 -0
  19. package/src/commands/pairing-clear.ts +39 -0
  20. package/src/commands/pairing-list.ts +55 -0
  21. package/src/commands/pairing-revoke.ts +49 -0
  22. package/src/commands/pairing.test.ts +88 -0
  23. package/src/commands/pairing.ts +81 -0
  24. package/src/commands/status.ts +84 -0
  25. package/src/commands/telegram-remove.ts +45 -0
  26. package/src/commands/telegram-setup.ts +84 -0
  27. package/src/commands/telegram-status.ts +48 -0
  28. package/src/commands/telegram.test.ts +50 -0
  29. package/src/commands/telegram.ts +44 -0
  30. package/src/commands/whoami.ts +62 -0
  31. package/src/config/load.ts +35 -0
  32. package/src/config/render.test.ts +96 -0
  33. package/src/config/render.ts +99 -0
  34. package/src/index.ts +147 -0
  35. package/src/ui/app.tsx +719 -0
  36. package/src/ui/approval-summary.test.ts +132 -0
  37. package/src/ui/approval-summary.ts +28 -0
  38. package/src/ui/markdown-parse.ts +219 -0
  39. package/src/ui/markdown.test.ts +146 -0
  40. package/src/ui/markdown.tsx +37 -0
  41. package/src/ui/state.test.ts +74 -0
  42. package/src/ui/state.ts +176 -0
  43. package/src/util/bootstrap-mode.test.ts +40 -0
  44. package/src/util/bootstrap-mode.ts +25 -0
  45. package/src/util/bootstrap-progress-box.test.ts +190 -0
  46. package/src/util/bootstrap-progress-box.ts +378 -0
  47. package/src/util/cli-version.ts +28 -0
  48. package/src/util/format.test.ts +16 -0
  49. package/src/util/format.ts +11 -0
  50. package/src/util/gateway-spawn.test.ts +86 -0
  51. package/src/util/gateway-spawn.ts +125 -0
  52. package/src/util/gateway-version.test.ts +113 -0
  53. package/src/util/gateway-version.ts +154 -0
  54. package/src/util/github-releases.test.ts +116 -0
  55. package/src/util/github-releases.ts +79 -0
  56. package/src/util/ref-resolver.test.ts +77 -0
  57. package/src/util/ref-resolver.ts +55 -0
  58. package/src/util/silence-console.test.ts +53 -0
  59. package/src/util/silence-console.ts +40 -0
  60. package/src/util/sui-runtime.ts +74 -0
  61. package/src/util/telegram-secrets.test.ts +104 -0
  62. package/src/util/telegram-secrets.ts +99 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Sui runtime construction for the CLI.
3
+ *
4
+ * Lyra on Sui keys the agent off a single env secret (`LYRA_AGENT_KEY`, a
5
+ * `suiprivkey1…` bech32 string). That one Ed25519 keypair signs every PTB and
6
+ * pays gas; the deterministic policy (mirrored on-chain by the `lyra::policy`
7
+ * Move package) bounds what it may do. There is no operator wallet, keystore
8
+ * decrypt, or a Sui wallet dance — the agent IS the signer.
9
+ *
10
+ * `buildOnchainContext` assembles the `OnchainRuntimeContext` the
11
+ * lyra-plugin-onchain tools read via `(ctx as any).onchain`.
12
+ */
13
+
14
+ import {
15
+ type OnchainRuntimeContext,
16
+ type SuiNetwork,
17
+ keypairFromSecret,
18
+ makeSuiClient,
19
+ policyFromEnv,
20
+ } from 'lyra-plugin-onchain'
21
+
22
+ /** Env var holding the agent's Sui secret key (`suiprivkey1…` or base64 seed). */
23
+ export const AGENT_KEY_ENV = 'LYRA_AGENT_KEY'
24
+
25
+ export interface SuiAgent {
26
+ keypair: ReturnType<typeof keypairFromSecret>
27
+ /** `keypair.toSuiAddress()` — 0x + 64 hex. */
28
+ address: string
29
+ }
30
+
31
+ /**
32
+ * Resolve the agent keypair from `LYRA_AGENT_KEY`. Returns null when the env
33
+ * var is unset so callers can print a friendly "run lyra init" message instead
34
+ * of throwing a raw decode error.
35
+ */
36
+ export function loadAgentFromEnv(): SuiAgent | null {
37
+ const secret = process.env[AGENT_KEY_ENV]
38
+ if (!secret || secret.trim().length === 0) return null
39
+ const keypair = keypairFromSecret(secret)
40
+ return { keypair, address: keypair.toSuiAddress() }
41
+ }
42
+
43
+ export interface BuildOnchainOpts {
44
+ agent: SuiAgent
45
+ network: SuiNetwork
46
+ agentDir: string
47
+ packageId?: string | null
48
+ policyObjectId?: string | null
49
+ brainProvider?: string | null
50
+ brainModel?: string | null
51
+ }
52
+
53
+ /**
54
+ * Build the side-band `OnchainRuntimeContext` consumed by lyra-plugin-onchain.
55
+ * `packageId` / `policyObjectId` default to the env values the Move deploy
56
+ * wrote (`LYRA_PACKAGE_ID` / `LYRA_POLICY_OBJECT_ID`); the off-chain policy
57
+ * mirror comes from `policyFromEnv()` (the `LYRA_POLICY_*` vars).
58
+ */
59
+ export function buildOnchainContext(opts: BuildOnchainOpts): OnchainRuntimeContext {
60
+ const packageId = opts.packageId ?? process.env.LYRA_PACKAGE_ID ?? undefined
61
+ const policyObjectId = opts.policyObjectId ?? process.env.LYRA_POLICY_OBJECT_ID ?? undefined
62
+ return {
63
+ client: makeSuiClient(opts.network),
64
+ keypair: opts.agent.keypair,
65
+ agentAddress: opts.agent.address,
66
+ network: opts.network,
67
+ policy: policyFromEnv(),
68
+ packageId: packageId || undefined,
69
+ policyObjectId: policyObjectId || undefined,
70
+ agentDir: opts.agentDir,
71
+ brainProvider: opts.brainProvider ?? null,
72
+ brainModel: opts.brainModel ?? null,
73
+ }
74
+ }
@@ -0,0 +1,104 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test'
2
+ import { looksLikeBotToken, parseAllowedUserIds, telegramSecretsFromEnv } from './telegram-secrets'
3
+
4
+ describe('looksLikeBotToken', () => {
5
+ it('accepts a real-shaped token', () => {
6
+ expect(looksLikeBotToken('8776805236:AAGgfvp2AwYBvDc3COYfjC9m8w2s0e4t4hw')).toBe(true)
7
+ })
8
+
9
+ it('rejects empty / wrong delimiters', () => {
10
+ expect(looksLikeBotToken('')).toBe(false)
11
+ expect(looksLikeBotToken('8776805236-AAGgfvp2AwYBvDc3COYfjC9m8w2s0e4t4hw')).toBe(false)
12
+ expect(looksLikeBotToken('AAGgfvp2AwYBvDc3COYfjC9m8w2s0e4t4hw')).toBe(false)
13
+ })
14
+
15
+ it('rejects too-short secret half', () => {
16
+ expect(looksLikeBotToken('1234567890:short')).toBe(false)
17
+ })
18
+
19
+ it('trims surrounding whitespace before checking', () => {
20
+ expect(looksLikeBotToken(' 8731160904:AAH8FQ3CLrE8-WAfZtDeOTqmpVgOFLg8GyU\n')).toBe(true)
21
+ })
22
+ })
23
+
24
+ describe('parseAllowedUserIds', () => {
25
+ it('returns empty list for blank input', () => {
26
+ const r = parseAllowedUserIds('')
27
+ expect(r.ok).toBe(true)
28
+ if (r.ok) expect(r.ids).toEqual([])
29
+ })
30
+
31
+ it('parses a comma-separated list', () => {
32
+ const r = parseAllowedUserIds('123, 456, 789')
33
+ expect(r.ok).toBe(true)
34
+ if (r.ok) expect(r.ids).toEqual([123, 456, 789])
35
+ })
36
+
37
+ it('parses whitespace-only delimiters', () => {
38
+ const r = parseAllowedUserIds('123 456\t789')
39
+ expect(r.ok).toBe(true)
40
+ if (r.ok) expect(r.ids).toEqual([123, 456, 789])
41
+ })
42
+
43
+ it('dedupes preserving first-seen order', () => {
44
+ const r = parseAllowedUserIds('123, 456, 123')
45
+ expect(r.ok).toBe(true)
46
+ if (r.ok) expect(r.ids).toEqual([123, 456])
47
+ })
48
+
49
+ it('rejects non-numeric ids', () => {
50
+ const r = parseAllowedUserIds('123, abc')
51
+ expect(r.ok).toBe(false)
52
+ if (!r.ok) expect(r.reason).toContain('abc')
53
+ })
54
+
55
+ it('rejects negative ids', () => {
56
+ const r = parseAllowedUserIds('-123')
57
+ expect(r.ok).toBe(false)
58
+ })
59
+
60
+ it('rejects zero', () => {
61
+ const r = parseAllowedUserIds('0')
62
+ expect(r.ok).toBe(false)
63
+ })
64
+ })
65
+
66
+ describe('telegramSecretsFromEnv', () => {
67
+ const keys = ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID', 'TELEGRAM_USERNAME'] as const
68
+ const saved: Record<string, string | undefined> = {}
69
+ for (const k of keys) saved[k] = process.env[k]
70
+
71
+ afterEach(() => {
72
+ for (const k of keys) {
73
+ if (saved[k] === undefined) Reflect.deleteProperty(process.env, k)
74
+ else process.env[k] = saved[k]
75
+ }
76
+ })
77
+
78
+ it('returns null when no token is set', () => {
79
+ Reflect.deleteProperty(process.env, 'TELEGRAM_BOT_TOKEN')
80
+ expect(telegramSecretsFromEnv()).toBeNull()
81
+ })
82
+
83
+ it('parses token + chat id + username', () => {
84
+ process.env.TELEGRAM_BOT_TOKEN = '8731160904:AAH8FQ3CLrE8-WAfZtDeOTqmpVgOFLg8GyU'
85
+ process.env.TELEGRAM_CHAT_ID = '1140813034'
86
+ process.env.TELEGRAM_USERNAME = 'lyra_test_bot'
87
+ expect(telegramSecretsFromEnv()).toEqual({
88
+ botToken: '8731160904:AAH8FQ3CLrE8-WAfZtDeOTqmpVgOFLg8GyU',
89
+ botUsername: 'lyra_test_bot',
90
+ allowedUserIds: [1140813034],
91
+ })
92
+ })
93
+
94
+ it('open access (empty allowlist) when no chat id', () => {
95
+ process.env.TELEGRAM_BOT_TOKEN = '8731160904:AAH8FQ3CLrE8-WAfZtDeOTqmpVgOFLg8GyU'
96
+ Reflect.deleteProperty(process.env, 'TELEGRAM_CHAT_ID')
97
+ Reflect.deleteProperty(process.env, 'TELEGRAM_USERNAME')
98
+ expect(telegramSecretsFromEnv()).toEqual({
99
+ botToken: '8731160904:AAH8FQ3CLrE8-WAfZtDeOTqmpVgOFLg8GyU',
100
+ botUsername: undefined,
101
+ allowedUserIds: [],
102
+ })
103
+ })
104
+ })
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Telegram bot helpers for the Sui CLI.
3
+ *
4
+ * On Sui, telegram is configured purely via environment variables — there is
5
+ * no operator wallet to sign-derive an encryption key, so the previous
6
+ * on-disk encrypted-blob flow is gone. The agent reads:
7
+ *
8
+ * TELEGRAM_BOT_TOKEN — from @BotFather (required to enable the gateway)
9
+ * TELEGRAM_CHAT_ID — optional sole allowed DM user (blank = open access)
10
+ * TELEGRAM_USERNAME — optional cached bot @username for nicer status output
11
+ *
12
+ * This module keeps the pure token/allowlist validators plus the Telegram
13
+ * `getMe` probe used by `lyra telegram setup` / `status`.
14
+ */
15
+
16
+ export interface TelegramEnvSecrets {
17
+ botToken: string
18
+ botUsername?: string
19
+ allowedUserIds: number[]
20
+ }
21
+
22
+ /**
23
+ * Resolve telegram config from the environment, or null when no token is set.
24
+ */
25
+ export function telegramSecretsFromEnv(): TelegramEnvSecrets | null {
26
+ const botToken = process.env.TELEGRAM_BOT_TOKEN
27
+ if (!botToken) return null
28
+ const chatId = process.env.TELEGRAM_CHAT_ID
29
+ return {
30
+ botToken,
31
+ botUsername: process.env.TELEGRAM_USERNAME,
32
+ allowedUserIds: chatId ? [Number(chatId)] : [],
33
+ }
34
+ }
35
+
36
+ const BOT_TOKEN_RE = /^\d{6,15}:[A-Za-z0-9_-]{30,}$/
37
+
38
+ export function looksLikeBotToken(s: string): boolean {
39
+ return BOT_TOKEN_RE.test(s.trim())
40
+ }
41
+
42
+ export interface ValidatedBotInfo {
43
+ id: number
44
+ username: string
45
+ firstName: string
46
+ }
47
+
48
+ /**
49
+ * Telegram Bot API getMe — cheap, free, no message side-effect. Used by
50
+ * `lyra telegram setup` to validate the token before persisting it AND by
51
+ * `lyra telegram status` to confirm the stored token still works.
52
+ *
53
+ * Throws on non-200 / `ok: false` with a clean error message; caller wraps
54
+ * the throw in a clack spinner.stop().
55
+ */
56
+ export async function fetchBotInfo(
57
+ botToken: string,
58
+ opts?: { signal?: AbortSignal },
59
+ ): Promise<ValidatedBotInfo> {
60
+ const url = `https://api.telegram.org/bot${encodeURIComponent(botToken)}/getMe`
61
+ const res = await fetch(url, { signal: opts?.signal })
62
+ if (!res.ok) {
63
+ throw new Error(`getMe HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`)
64
+ }
65
+ const body = (await res.json()) as {
66
+ ok: boolean
67
+ description?: string
68
+ result?: { id: number; username?: string; first_name?: string }
69
+ }
70
+ if (!body.ok || !body.result) {
71
+ throw new Error(`getMe rejected: ${body.description ?? 'unknown error'}`)
72
+ }
73
+ if (!body.result.username) throw new Error('bot has no username; create one in @BotFather')
74
+ return {
75
+ id: body.result.id,
76
+ username: body.result.username,
77
+ firstName: body.result.first_name ?? body.result.username,
78
+ }
79
+ }
80
+
81
+ export function parseAllowedUserIds(
82
+ input: string,
83
+ ): { ok: true; ids: number[] } | { ok: false; reason: string } {
84
+ const trimmed = input.trim()
85
+ if (trimmed.length === 0) return { ok: true, ids: [] }
86
+ const parts = trimmed
87
+ .split(/[,\s]+/)
88
+ .map(p => p.trim())
89
+ .filter(p => p.length > 0)
90
+ const ids: number[] = []
91
+ for (const p of parts) {
92
+ if (!/^\d+$/.test(p)) return { ok: false, reason: `not a numeric id: "${p}"` }
93
+ const n = Number(p)
94
+ if (!Number.isFinite(n) || n <= 0) return { ok: false, reason: `not a positive id: "${p}"` }
95
+ ids.push(n)
96
+ }
97
+ // Dedupe, preserve first-seen order.
98
+ return { ok: true, ids: [...new Set(ids)] }
99
+ }