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,153 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { cancel, intro, isCancel, outro, select } from '@clack/prompts'
5
+ import {
6
+ type LyraNetwork,
7
+ agentPaths,
8
+ defineConfig,
9
+ placeholderAgentId,
10
+ suiRpcUrl,
11
+ } from 'lyra-core'
12
+ import { writeConfigTs } from '../config/render'
13
+ import { AGENT_KEY_ENV, loadAgentFromEnv } from '../util/sui-runtime'
14
+ import { pickBrainModel } from './init/model-picker'
15
+
16
+ /**
17
+ * `lyra init` — bootstrap the agent config for Sui.
18
+ *
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.
23
+ */
24
+ export async function runInit(opts?: { resume?: boolean }): Promise<void> {
25
+ const configPath = agentPaths.config
26
+
27
+ intro('lyra init')
28
+
29
+ if (existsSync(configPath) && !opts?.resume) {
30
+ const choice = (await select({
31
+ message: `${configPath} exists`,
32
+ options: [
33
+ { value: 'overwrite', label: 'Start fresh (overwrite)' },
34
+ { value: 'cancel', label: 'Cancel' },
35
+ ],
36
+ initialValue: 'cancel',
37
+ })) as 'overwrite' | 'cancel' | symbol
38
+ if (isCancel(choice) || choice === 'cancel') {
39
+ cancel('Aborted.')
40
+ return
41
+ }
42
+ }
43
+
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
53
+ }
54
+
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
66
+ }
67
+
68
+ const modelPick = await pickBrainModel()
69
+
70
+ const agentId = placeholderAgentId(agent.address)
71
+ const paths = agentPaths.agent(agentId)
72
+ await mkdir(paths.dir, { recursive: true })
73
+
74
+ // Telegram is env-driven on Sui (TELEGRAM_BOT_TOKEN). Auto-enable the plugin
75
+ // when the token is present so `lyra` brings the DM gateway online.
76
+ const telegramEnabled = !!process.env.TELEGRAM_BOT_TOKEN
77
+
78
+ await seedStarterMemoryFiles({
79
+ paths,
80
+ network,
81
+ agentAddress: agent.address,
82
+ brainProvider: modelPick?.provider ?? null,
83
+ brainModel: modelPick?.model ?? null,
84
+ })
85
+
86
+ const cfg = defineConfig({
87
+ identity: {
88
+ operator: null,
89
+ agent: agent.address,
90
+ },
91
+ network,
92
+ storage: { network },
93
+ brain: {
94
+ provider: modelPick?.provider ?? null,
95
+ model: modelPick?.model ?? null,
96
+ },
97
+ plugins: telegramEnabled ? ['onchain', 'system', 'telegram'] : ['onchain', 'system'],
98
+ tools: {},
99
+ imports: { claudeCode: true },
100
+ })
101
+ await writeConfigTs(configPath, cfg, {
102
+ header: '// Regenerated by `lyra init`. Edit freely; type-safe.',
103
+ })
104
+
105
+ const packageId = process.env.LYRA_PACKAGE_ID
106
+ const lines = [
107
+ '',
108
+ ` agent id ${agentId}`,
109
+ ` agent addr ${agent.address}`,
110
+ ` network ${network} (${suiRpcUrl(network)})`,
111
+ ` config ${configPath}`,
112
+ ` policy pkg ${packageId ?? '(set LYRA_PACKAGE_ID for on-chain receipts)'}`,
113
+ ]
114
+ if (modelPick) lines.push(` brain ${modelPick.model ?? '?'} (${modelPick.provider})`)
115
+ if (telegramEnabled) lines.push(' telegram enabled (TELEGRAM_BOT_TOKEN set)')
116
+ lines.push(
117
+ '',
118
+ 'Next: `lyra` to chat · `lyra status` for health · `lyra demo` for the guarded pipeline',
119
+ )
120
+ outro(lines.join('\n'))
121
+ }
122
+
123
+ interface SeedStarterOpts {
124
+ paths: ReturnType<typeof agentPaths.agent>
125
+ network: LyraNetwork
126
+ agentAddress: string
127
+ brainProvider: string | null
128
+ brainModel: string | null
129
+ }
130
+
131
+ /**
132
+ * Seed `MEMORY.md`, `/agent/identity.md`, and `/agent/persona.md` so the
133
+ * brain's first turn sees a parseable memory index and introduces itself.
134
+ */
135
+ async function seedStarterMemoryFiles(opts: SeedStarterOpts): Promise<void> {
136
+ const memDir = opts.paths.memoryDir
137
+ const agentMem = `${memDir}/agent`
138
+ const userMem = `${memDir}/user`
139
+ await mkdir(agentMem, { recursive: true })
140
+ await mkdir(userMem, { recursive: true })
141
+
142
+ const now = new Date().toISOString().slice(0, 10)
143
+ const identity = `---\nname: identity\ndescription: Auto-written agent identity facts.\ntype: agent-identity\n---\n# Lyra identity\n\n- Name: Lyra\n- Agent address: ${opts.agentAddress} (${opts.network})\n- Created: ${now}\n${opts.brainProvider ? `- Brain provider: ${opts.brainProvider}\n` : ''}${opts.brainModel ? `- Brain model: ${opts.brainModel}\n` : ''}`
144
+ const persona =
145
+ '---\nname: persona\ndescription: Voice + behavior style.\ntype: agent-persona\n---\n# Persona\n\nI am Lyra, a Sui-native autonomous finance agent. I convert goals into policy-checked PTBs, execute only within my approved protocol scope, and store auditable memory and receipts with Walrus. Every value-moving action is checked by a deterministic policy (mirrored on-chain by lyra::policy) before it runs. I am direct, concise, and factual.\n'
146
+ const profile =
147
+ '---\nname: profile\ndescription: User profile (local only).\ntype: user\n---\n# User profile\n\n(empty, fills as we chat)\n'
148
+
149
+ await writeFile(join(agentMem, 'identity.md'), identity, 'utf8')
150
+ await writeFile(join(agentMem, 'persona.md'), persona, 'utf8')
151
+ await writeFile(join(userMem, 'profile.md'), profile, 'utf8')
152
+ await writeFile(opts.paths.memoryIndex, '# Lyra Memory Index\n\n', 'utf8')
153
+ }
@@ -0,0 +1,37 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { agentPaths } from 'lyra-core'
3
+ import { pickDefaultAgent } from './_agents'
4
+
5
+ export async function runLogs(opts: { agent?: string; tail?: number } = {}): Promise<void> {
6
+ // Local mode: read from agentPaths
7
+ const id = opts.agent ?? (await pickDefaultAgent())
8
+ if (!id) {
9
+ console.log('No agents found in ~/.lyra/agents. Run `lyra init` first.')
10
+ process.exit(1)
11
+ }
12
+ const path = agentPaths.agent(id).activityLog
13
+
14
+ let raw: string
15
+ try {
16
+ raw = await readFile(path, 'utf8')
17
+ } catch (e) {
18
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
19
+ console.log(`No activity log at ${path}`)
20
+ return
21
+ }
22
+ throw e
23
+ }
24
+
25
+ const lines = raw.trimEnd().split('\n').filter(Boolean)
26
+ const slice = opts.tail ? lines.slice(-opts.tail) : lines
27
+ for (const line of slice) {
28
+ try {
29
+ const entry = JSON.parse(line) as { ts: number; kind: string; data: unknown }
30
+ const d = new Date(entry.ts).toISOString()
31
+ const body = JSON.stringify(entry.data)
32
+ console.log(`${d} ${entry.kind.padEnd(16)} ${body.slice(0, 200)}`)
33
+ } catch {
34
+ console.log(line)
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,48 @@
1
+ import { cancel, intro, outro } from '@clack/prompts'
2
+ import { defineConfig } from 'lyra-core'
3
+ import { findAndLoadConfig } from '../config/load'
4
+ import { writeConfigTs } from '../config/render'
5
+ import { pickBrainModel } from './init/model-picker'
6
+
7
+ /**
8
+ * `lyra model` — re-pick the brain provider/model. Updates the persisted
9
+ * config so subsequent `lyra` (chat) sessions use the new choice.
10
+ *
11
+ * The TUI also exposes `/model` as a slash command for in-session switching;
12
+ * see `chat.tsx`.
13
+ */
14
+ export async function runModel(): Promise<void> {
15
+ intro('lyra model')
16
+
17
+ const loaded = await findAndLoadConfig()
18
+ if (!loaded) {
19
+ cancel('No lyra.config.ts found. Run `lyra init` first.')
20
+ return
21
+ }
22
+ const { config } = loaded
23
+
24
+ const pick = await pickBrainModel()
25
+ if (!pick) {
26
+ cancel('No model picked.')
27
+ return
28
+ }
29
+
30
+ const updated = defineConfig({
31
+ ...config,
32
+ brain: { provider: pick.provider, model: pick.model },
33
+ })
34
+ await writeConfigTs(loaded.path, updated, {
35
+ header: '// Updated by `lyra model`. Edit freely; type-safe.',
36
+ })
37
+
38
+ outro(
39
+ [
40
+ '',
41
+ ` brain ${pick.model ?? '?'}`,
42
+ ` provider ${pick.provider}`,
43
+ ` config ${loaded.path}`,
44
+ '',
45
+ 'Next chat session will use the new brain.',
46
+ ].join('\n'),
47
+ )
48
+ }
@@ -0,0 +1,65 @@
1
+ import {
2
+ PAIRING_ALPHABET,
3
+ PAIRING_CODE_LENGTH,
4
+ PairingStore,
5
+ agentPaths,
6
+ placeholderAgentId,
7
+ } from 'lyra-core'
8
+ import { findAndLoadConfig } from '../config/load'
9
+
10
+ export interface RunPairingApproveOpts {
11
+ platform: string
12
+ code: string
13
+ }
14
+
15
+ export async function runPairingApprove(opts: RunPairingApproveOpts): Promise<void> {
16
+ const normalized = opts.code.toUpperCase().trim()
17
+ if (normalized.length !== PAIRING_CODE_LENGTH) {
18
+ console.error(
19
+ `Invalid pairing code: expected ${PAIRING_CODE_LENGTH} characters, got ${normalized.length}`,
20
+ )
21
+ process.exit(1)
22
+ }
23
+ for (const ch of normalized) {
24
+ if (!PAIRING_ALPHABET.includes(ch)) {
25
+ console.error(`Invalid pairing code: contains '${ch}' which is not in the pairing alphabet`)
26
+ process.exit(1)
27
+ }
28
+ }
29
+
30
+ const loaded = await findAndLoadConfig()
31
+ if (!loaded) {
32
+ console.error('No lyra.config.ts found. Run `lyra init` first.')
33
+ process.exit(1)
34
+ }
35
+ const { config } = loaded
36
+ if (!config.identity.agent) {
37
+ console.error('Config has no agent. Run `lyra init` first.')
38
+ process.exit(1)
39
+ }
40
+
41
+ // Operate directly on the host's PairingStore (same path as the daemon
42
+ // process when LYRA_FORCE_EMBEDDED or local-mode chat.tsx).
43
+ const agentId = placeholderAgentId(config.identity.agent)
44
+ const dir = agentPaths.agent(agentId).pairingDir
45
+ const store = new PairingStore({ dir })
46
+
47
+ const result = store.approveCode(opts.platform, normalized)
48
+ if (!result) {
49
+ if (store.isLockedOut(opts.platform)) {
50
+ console.error(
51
+ `Platform '${opts.platform}' is locked out due to repeated bad codes. Wait 1 hour and try again.`,
52
+ )
53
+ process.exit(1)
54
+ }
55
+ console.error(`Code ${normalized} not found in pending list. Maybe it expired (1h TTL).`)
56
+ process.exit(1)
57
+ }
58
+
59
+ console.log(
60
+ `✓ Approved on ${opts.platform}: id=${result.userId}${
61
+ result.userName ? ` (@${result.userName})` : ''
62
+ }`,
63
+ )
64
+ console.log('The user can now DM the bot. Their next message will be processed.')
65
+ }
@@ -0,0 +1,39 @@
1
+ import { confirm, isCancel } from '@clack/prompts'
2
+ import { PairingStore, agentPaths, placeholderAgentId } from 'lyra-core'
3
+ import { findAndLoadConfig } from '../config/load'
4
+
5
+ export interface RunPairingClearOpts {
6
+ platform?: string
7
+ yes?: boolean
8
+ }
9
+
10
+ export async function runPairingClear(opts: RunPairingClearOpts): Promise<void> {
11
+ const loaded = await findAndLoadConfig()
12
+ if (!loaded) {
13
+ console.error('No lyra.config.ts found. Run `lyra init` first.')
14
+ process.exit(1)
15
+ }
16
+ const { config } = loaded
17
+ if (!config.identity.agent) {
18
+ console.error('Config has no agent. Run `lyra init` first.')
19
+ process.exit(1)
20
+ }
21
+ const agentId = placeholderAgentId(config.identity.agent)
22
+ const dir = agentPaths.agent(agentId).pairingDir
23
+ const store = new PairingStore({ dir })
24
+
25
+ if (!opts.yes) {
26
+ const target = opts.platform ? `${opts.platform} pending` : 'ALL pending pairing codes'
27
+ const ok = await confirm({
28
+ message: `Clear ${target}?`,
29
+ initialValue: false,
30
+ })
31
+ if (isCancel(ok) || !ok) {
32
+ console.log('Aborted.')
33
+ return
34
+ }
35
+ }
36
+
37
+ const count = store.clearPending(opts.platform)
38
+ console.log(`✓ Cleared ${count} pending pairing code${count === 1 ? '' : 's'}`)
39
+ }
@@ -0,0 +1,55 @@
1
+ import { PairingStore, agentPaths, placeholderAgentId } from 'lyra-core'
2
+ import { findAndLoadConfig } from '../config/load'
3
+
4
+ export interface RunPairingListOpts {
5
+ platform?: string
6
+ }
7
+
8
+ export async function runPairingList(opts: RunPairingListOpts): Promise<void> {
9
+ const store = await openPairingStore()
10
+ if (!store) return
11
+
12
+ const pending = store.listPending(opts.platform)
13
+ const approved = store.listApproved(opts.platform)
14
+
15
+ const pendingTitle = opts.platform ? `Pending (${opts.platform})` : 'Pending'
16
+ console.log(`\n${pendingTitle} (1h TTL):`)
17
+ if (pending.length === 0) {
18
+ console.log(' (none)')
19
+ } else {
20
+ for (const p of pending) {
21
+ const userLabel = p.userName ? `@${p.userName}` : '(unknown)'
22
+ const idLabel = `id=${p.userId}`
23
+ console.log(` [${p.platform}] ${p.code} ${userLabel} ${idLabel} age=${p.ageMinutes}m`)
24
+ }
25
+ }
26
+
27
+ const approvedTitle = opts.platform ? `Approved (${opts.platform})` : 'Approved'
28
+ console.log(`\n${approvedTitle}:`)
29
+ if (approved.length === 0) {
30
+ console.log(' (none)')
31
+ } else {
32
+ for (const a of approved) {
33
+ const userLabel = a.userName ? `@${a.userName}` : '(unknown)'
34
+ const idLabel = `id=${a.userId}`
35
+ console.log(` [${a.platform}] ${userLabel} ${idLabel}`)
36
+ }
37
+ }
38
+ console.log()
39
+ }
40
+
41
+ async function openPairingStore(): Promise<PairingStore | null> {
42
+ const loaded = await findAndLoadConfig()
43
+ if (!loaded) {
44
+ console.error('No lyra.config.ts found. Run `lyra init` first.')
45
+ return null
46
+ }
47
+ const { config } = loaded
48
+ if (!config.identity.agent) {
49
+ console.error('Config has no agent. Run `lyra init` first.')
50
+ return null
51
+ }
52
+ const agentId = placeholderAgentId(config.identity.agent)
53
+ const dir = agentPaths.agent(agentId).pairingDir
54
+ return new PairingStore({ dir })
55
+ }
@@ -0,0 +1,49 @@
1
+ import { confirm, isCancel } from '@clack/prompts'
2
+ import { PairingStore, agentPaths, placeholderAgentId } from 'lyra-core'
3
+ import { findAndLoadConfig } from '../config/load'
4
+
5
+ export interface RunPairingRevokeOpts {
6
+ platform: string
7
+ userId: string
8
+ yes?: boolean
9
+ }
10
+
11
+ export async function runPairingRevoke(opts: RunPairingRevokeOpts): Promise<void> {
12
+ const loaded = await findAndLoadConfig()
13
+ if (!loaded) {
14
+ console.error('No lyra.config.ts found. Run `lyra init` first.')
15
+ process.exit(1)
16
+ }
17
+ const { config } = loaded
18
+ if (!config.identity.agent) {
19
+ console.error('Config has no agent. Run `lyra init` first.')
20
+ process.exit(1)
21
+ }
22
+ const agentId = placeholderAgentId(config.identity.agent)
23
+ const dir = agentPaths.agent(agentId).pairingDir
24
+ const store = new PairingStore({ dir })
25
+
26
+ if (!store.isApproved(opts.platform, opts.userId)) {
27
+ console.error(`User ${opts.userId} is not on the ${opts.platform} approved list.`)
28
+ process.exit(1)
29
+ }
30
+
31
+ if (!opts.yes) {
32
+ const ok = await confirm({
33
+ message: `Revoke ${opts.platform} access for user id ${opts.userId}?`,
34
+ initialValue: false,
35
+ })
36
+ if (isCancel(ok) || !ok) {
37
+ console.log('Aborted.')
38
+ return
39
+ }
40
+ }
41
+
42
+ const removed = store.revoke(opts.platform, opts.userId)
43
+ if (removed) {
44
+ console.log(`✓ Revoked: ${opts.platform} id=${opts.userId}`)
45
+ } else {
46
+ console.error('Revoke failed (concurrent removal?)')
47
+ process.exit(1)
48
+ }
49
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { parsePairingArgs } from './pairing'
3
+
4
+ describe('parsePairingArgs', () => {
5
+ it('errors on no args', () => {
6
+ const r = parsePairingArgs([])
7
+ expect('error' in r).toBe(true)
8
+ })
9
+
10
+ it('errors on unknown subcommand', () => {
11
+ const r = parsePairingArgs(['quack'])
12
+ expect('error' in r).toBe(true)
13
+ })
14
+
15
+ it('parses `list`', () => {
16
+ const r = parsePairingArgs(['list']) as Exclude<
17
+ ReturnType<typeof parsePairingArgs>,
18
+ { error: string }
19
+ >
20
+ expect(r.sub).toBe('list')
21
+ })
22
+
23
+ it('parses `list telegram` with platform filter', () => {
24
+ const r = parsePairingArgs(['list', 'telegram']) as Exclude<
25
+ ReturnType<typeof parsePairingArgs>,
26
+ { error: string }
27
+ >
28
+ expect(r.sub).toBe('list')
29
+ expect(r.platform).toBe('telegram')
30
+ })
31
+
32
+ it('parses `approve telegram ABCDEFGH`', () => {
33
+ const r = parsePairingArgs(['approve', 'telegram', 'ABCDEFGH']) as Exclude<
34
+ ReturnType<typeof parsePairingArgs>,
35
+ { error: string }
36
+ >
37
+ expect(r.sub).toBe('approve')
38
+ expect(r.platform).toBe('telegram')
39
+ expect(r.code).toBe('ABCDEFGH')
40
+ })
41
+
42
+ it('errors on `approve` without arguments', () => {
43
+ const r = parsePairingArgs(['approve'])
44
+ expect('error' in r).toBe(true)
45
+ })
46
+
47
+ it('errors on `approve telegram` without code', () => {
48
+ const r = parsePairingArgs(['approve', 'telegram'])
49
+ expect('error' in r).toBe(true)
50
+ })
51
+
52
+ it('parses `revoke telegram 12345`', () => {
53
+ const r = parsePairingArgs(['revoke', 'telegram', '12345']) as Exclude<
54
+ ReturnType<typeof parsePairingArgs>,
55
+ { error: string }
56
+ >
57
+ expect(r.sub).toBe('revoke')
58
+ expect(r.platform).toBe('telegram')
59
+ expect(r.userId).toBe('12345')
60
+ })
61
+
62
+ it('parses `clear-pending` and `clear-pending telegram`', () => {
63
+ const a = parsePairingArgs(['clear-pending']) as Exclude<
64
+ ReturnType<typeof parsePairingArgs>,
65
+ { error: string }
66
+ >
67
+ expect(a.sub).toBe('clear-pending')
68
+ expect(a.platform).toBeUndefined()
69
+ const b = parsePairingArgs(['clear-pending', 'telegram']) as Exclude<
70
+ ReturnType<typeof parsePairingArgs>,
71
+ { error: string }
72
+ >
73
+ expect(b.platform).toBe('telegram')
74
+ })
75
+
76
+ it('extracts --yes / -y flag', () => {
77
+ const r = parsePairingArgs(['revoke', 'telegram', '12345', '--yes']) as Exclude<
78
+ ReturnType<typeof parsePairingArgs>,
79
+ { error: string }
80
+ >
81
+ expect(r.yes).toBe(true)
82
+ const s = parsePairingArgs(['clear-pending', '-y']) as Exclude<
83
+ ReturnType<typeof parsePairingArgs>,
84
+ { error: string }
85
+ >
86
+ expect(s.yes).toBe(true)
87
+ })
88
+ })
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `lyra pairing <subcommand>` — argv dispatcher for the DM pairing flow.
3
+ *
4
+ * Subcommands:
5
+ * list show pending codes + approved users
6
+ * approve <platform> <code> approve a pairing code (case-insensitive)
7
+ * revoke <platform> <userId> revoke an approved user
8
+ * clear-pending [platform] drop all pending codes
9
+ *
10
+ * Platform is `telegram` for Phase 12. Future platforms (discord, slack) will
11
+ * reuse the same command surface.
12
+ */
13
+
14
+ export interface PairingArgs {
15
+ sub: 'list' | 'approve' | 'revoke' | 'clear-pending'
16
+ platform?: string
17
+ code?: string
18
+ userId?: string
19
+ yes?: boolean
20
+ }
21
+
22
+ const VALID_SUBS = ['list', 'approve', 'revoke', 'clear-pending'] as const
23
+
24
+ export type PairingParseResult = PairingArgs | { error: string }
25
+
26
+ export function parsePairingArgs(argv: string[]): PairingParseResult {
27
+ const sub = argv[0]
28
+ if (!sub) {
29
+ return {
30
+ error:
31
+ 'usage: lyra pairing <list | approve <platform> <code> | revoke <platform> <userId> | clear-pending [platform]>',
32
+ }
33
+ }
34
+ if (!(VALID_SUBS as readonly string[]).includes(sub)) {
35
+ return { error: `unknown subcommand '${sub}' (expected: ${VALID_SUBS.join(' | ')})` }
36
+ }
37
+ const positional = argv.slice(1).filter(a => !a.startsWith('-'))
38
+ const yes = argv.includes('--yes') || argv.includes('-y')
39
+
40
+ if (sub === 'approve') {
41
+ if (positional.length < 2) {
42
+ return { error: 'usage: lyra pairing approve <platform> <code>' }
43
+ }
44
+ return { sub: 'approve', platform: positional[0], code: positional[1], yes }
45
+ }
46
+ if (sub === 'revoke') {
47
+ if (positional.length < 2) {
48
+ return { error: 'usage: lyra pairing revoke <platform> <userId>' }
49
+ }
50
+ return { sub: 'revoke', platform: positional[0], userId: positional[1], yes }
51
+ }
52
+ if (sub === 'clear-pending') {
53
+ return { sub: 'clear-pending', platform: positional[0], yes }
54
+ }
55
+ return { sub: 'list', platform: positional[0], yes }
56
+ }
57
+
58
+ export async function runPairing(args: PairingArgs): Promise<void> {
59
+ switch (args.sub) {
60
+ case 'list': {
61
+ const { runPairingList } = await import('./pairing-list')
62
+ await runPairingList({ platform: args.platform })
63
+ return
64
+ }
65
+ case 'approve': {
66
+ const { runPairingApprove } = await import('./pairing-approve')
67
+ await runPairingApprove({ platform: args.platform!, code: args.code! })
68
+ return
69
+ }
70
+ case 'revoke': {
71
+ const { runPairingRevoke } = await import('./pairing-revoke')
72
+ await runPairingRevoke({ platform: args.platform!, userId: args.userId!, yes: args.yes })
73
+ return
74
+ }
75
+ case 'clear-pending': {
76
+ const { runPairingClear } = await import('./pairing-clear')
77
+ await runPairingClear({ platform: args.platform, yes: args.yes })
78
+ return
79
+ }
80
+ }
81
+ }