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.
- package/README.md +39 -0
- package/bin/lyra +11 -0
- package/package.json +65 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1220 -0
- package/src/commands/demo.ts +177 -0
- package/src/commands/gateway-logs.ts +49 -0
- package/src/commands/gateway-run.ts +42 -0
- package/src/commands/gateway-start.ts +89 -0
- package/src/commands/gateway-status.ts +73 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/init/model-picker.ts +17 -0
- package/src/commands/init.ts +153 -0
- package/src/commands/logs.ts +37 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +65 -0
- package/src/commands/pairing-clear.ts +39 -0
- package/src/commands/pairing-list.ts +55 -0
- package/src/commands/pairing-revoke.ts +49 -0
- package/src/commands/pairing.test.ts +88 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/status.ts +84 -0
- package/src/commands/telegram-remove.ts +45 -0
- package/src/commands/telegram-setup.ts +84 -0
- package/src/commands/telegram-status.ts +48 -0
- package/src/commands/telegram.test.ts +50 -0
- package/src/commands/telegram.ts +44 -0
- package/src/commands/whoami.ts +62 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.test.ts +96 -0
- package/src/config/render.ts +99 -0
- package/src/index.ts +147 -0
- package/src/ui/app.tsx +719 -0
- package/src/ui/approval-summary.test.ts +132 -0
- package/src/ui/approval-summary.ts +28 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.test.ts +146 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.test.ts +74 -0
- package/src/ui/state.ts +176 -0
- package/src/util/bootstrap-mode.test.ts +40 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.test.ts +190 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.test.ts +16 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.test.ts +86 -0
- package/src/util/gateway-spawn.ts +125 -0
- package/src/util/gateway-version.test.ts +113 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.test.ts +116 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/ref-resolver.test.ts +77 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.test.ts +53 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/sui-runtime.ts +74 -0
- package/src/util/telegram-secrets.test.ts +104 -0
- 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
|
+
}
|