lyra-core 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 (131) hide show
  1. package/README.md +24 -0
  2. package/package.json +65 -0
  3. package/src/brain/compaction.test.ts +156 -0
  4. package/src/brain/compaction.ts +131 -0
  5. package/src/brain/demo-endpoint.ts +13 -0
  6. package/src/brain/frozen-prefix.test.ts +235 -0
  7. package/src/brain/frozen-prefix.ts +320 -0
  8. package/src/brain/history-persist.test.ts +129 -0
  9. package/src/brain/history-persist.ts +154 -0
  10. package/src/brain/index.ts +44 -0
  11. package/src/brain/openai-brain.test.ts +61 -0
  12. package/src/brain/openai-brain.ts +544 -0
  13. package/src/brain/sanitize.test.ts +27 -0
  14. package/src/brain/sanitize.ts +23 -0
  15. package/src/brain/stub.ts +20 -0
  16. package/src/brain/types.ts +129 -0
  17. package/src/chain.ts +35 -0
  18. package/src/claude-plugins/discovery.test.ts +71 -0
  19. package/src/claude-plugins/discovery.ts +152 -0
  20. package/src/claude-plugins/index.ts +6 -0
  21. package/src/claude-plugins/types.ts +38 -0
  22. package/src/commands/index.ts +16 -0
  23. package/src/commands/registry.test.ts +186 -0
  24. package/src/commands/registry.ts +255 -0
  25. package/src/config.ts +201 -0
  26. package/src/economy/index.ts +6 -0
  27. package/src/events/index.ts +4 -0
  28. package/src/events/listeners.ts +37 -0
  29. package/src/events/queue.test.ts +43 -0
  30. package/src/events/queue.ts +63 -0
  31. package/src/events/router.ts +42 -0
  32. package/src/events/types.ts +28 -0
  33. package/src/format.ts +13 -0
  34. package/src/index.test.ts +6 -0
  35. package/src/index.ts +314 -0
  36. package/src/locks.test.ts +259 -0
  37. package/src/locks.ts +233 -0
  38. package/src/mcp/discovery.test.ts +97 -0
  39. package/src/mcp/discovery.ts +150 -0
  40. package/src/mcp/index.ts +10 -0
  41. package/src/mcp/manager.test.ts +97 -0
  42. package/src/mcp/manager.ts +110 -0
  43. package/src/mcp/stdio-client.ts +154 -0
  44. package/src/mcp/types.ts +44 -0
  45. package/src/memory/edit.test.ts +41 -0
  46. package/src/memory/edit.ts +53 -0
  47. package/src/memory/encryption.test.ts +68 -0
  48. package/src/memory/encryption.ts +94 -0
  49. package/src/memory/fs-util.ts +15 -0
  50. package/src/memory/index-file.ts +74 -0
  51. package/src/memory/index-sync.test.ts +81 -0
  52. package/src/memory/index-sync.ts +99 -0
  53. package/src/memory/index.ts +58 -0
  54. package/src/memory/list-tool.ts +105 -0
  55. package/src/memory/pack-blob.test.ts +90 -0
  56. package/src/memory/pack-blob.ts +120 -0
  57. package/src/memory/pack-gather.test.ts +110 -0
  58. package/src/memory/pack-gather.ts +112 -0
  59. package/src/memory/parser.test.ts +29 -0
  60. package/src/memory/parser.ts +20 -0
  61. package/src/memory/path-drift.test.ts +71 -0
  62. package/src/memory/read-tool-fallback.test.ts +54 -0
  63. package/src/memory/read-tool.ts +198 -0
  64. package/src/memory/save-tool.test.ts +193 -0
  65. package/src/memory/save-tool.ts +189 -0
  66. package/src/memory/scan.test.ts +24 -0
  67. package/src/memory/scan.ts +63 -0
  68. package/src/memory/topic.ts +32 -0
  69. package/src/memory/types.ts +49 -0
  70. package/src/migration/index.ts +6 -0
  71. package/src/migration/option3-crypto.test.ts +106 -0
  72. package/src/migration/option3-crypto.ts +143 -0
  73. package/src/pairing.test.ts +212 -0
  74. package/src/pairing.ts +285 -0
  75. package/src/paths.ts +70 -0
  76. package/src/permission/dangerous.ts +105 -0
  77. package/src/permission/env-redact.ts +54 -0
  78. package/src/permission/index.ts +16 -0
  79. package/src/permission/path-guard.ts +114 -0
  80. package/src/permission/permission.test.ts +299 -0
  81. package/src/permission/service.ts +191 -0
  82. package/src/plugins/context.ts +226 -0
  83. package/src/plugins/hooks.ts +81 -0
  84. package/src/plugins/index.ts +24 -0
  85. package/src/plugins/plugins.test.ts +196 -0
  86. package/src/plugins/tool-search.ts +49 -0
  87. package/src/public/card.test.ts +70 -0
  88. package/src/public/card.ts +67 -0
  89. package/src/runtime/activity.ts +29 -0
  90. package/src/runtime/index.ts +7 -0
  91. package/src/runtime/runtime.test.ts +55 -0
  92. package/src/runtime/runtime.ts +126 -0
  93. package/src/sandbox/credentials.ts +25 -0
  94. package/src/sandbox/docker.ts +389 -0
  95. package/src/sandbox/factory.ts +99 -0
  96. package/src/sandbox/index.ts +15 -0
  97. package/src/sandbox/linux.ts +141 -0
  98. package/src/sandbox/local.ts +19 -0
  99. package/src/sandbox/macos.ts +71 -0
  100. package/src/sandbox/sandbox.test.ts +386 -0
  101. package/src/sandbox/seatbelt-profile.ts +139 -0
  102. package/src/sandbox/types.ts +129 -0
  103. package/src/skills/index.ts +8 -0
  104. package/src/skills/scanner.test.ts +137 -0
  105. package/src/skills/scanner.ts +257 -0
  106. package/src/skills/triggers.test.ts +77 -0
  107. package/src/skills/triggers.ts +78 -0
  108. package/src/skills/types.ts +37 -0
  109. package/src/storage/encryption.test.ts +30 -0
  110. package/src/storage/encryption.ts +87 -0
  111. package/src/storage/factory.ts +31 -0
  112. package/src/storage/index.ts +11 -0
  113. package/src/storage/local-stub.ts +70 -0
  114. package/src/storage/sqlite.test.ts +29 -0
  115. package/src/storage/sqlite.ts +95 -0
  116. package/src/storage/types.ts +21 -0
  117. package/src/tools/escalation.test.ts +348 -0
  118. package/src/tools/escalation.ts +200 -0
  119. package/src/tools/index.ts +11 -0
  120. package/src/tools/registry.test.ts +70 -0
  121. package/src/tools/registry.ts +152 -0
  122. package/src/tools/types.ts +65 -0
  123. package/src/tools/zod-helpers.test.ts +63 -0
  124. package/src/tools/zod-helpers.ts +36 -0
  125. package/src/tools/zod-schema.ts +99 -0
  126. package/src/wallet/drain.test.ts +41 -0
  127. package/src/wallet/drain.ts +76 -0
  128. package/src/wallet/eoa.ts +61 -0
  129. package/src/wallet/index.ts +14 -0
  130. package/src/wallet/keystore.test.ts +17 -0
  131. package/src/wallet/keystore.ts +50 -0
@@ -0,0 +1,129 @@
1
+ import type { LyraEvent } from '../events/types'
2
+ import type { ToolCall, ToolSchema } from '../tools/types'
3
+
4
+ export interface BrainMessage {
5
+ role: 'system' | 'user' | 'assistant' | 'tool'
6
+ content: string
7
+ /** Required on `tool` role: the id of the assistant tool_call this responds to. */
8
+ toolCallId?: string
9
+ /**
10
+ * Required on `assistant` role messages that issued tool_calls. Without this
11
+ * the next round-trip's `tool` message has no preceding `tool_calls` to
12
+ * reference, and the OpenAI-compat endpoint rejects with HTTP 400
13
+ * "messages with role 'tool' must be a response to a preceeding message
14
+ * with 'tool_calls'".
15
+ */
16
+ toolCalls?: Array<{ id: string; name: string; args: unknown }>
17
+ }
18
+
19
+ /**
20
+ * Per-tool-call lifecycle event surfaced to the dispatcher for UI rendering.
21
+ * Distinct from the brain-construction `onToolCall` (which actually EXECUTES
22
+ * the tool); this is fire-and-forget for "show what the agent is doing right
23
+ * now" surfaces (TG progress message, future TUI bridge, etc.).
24
+ *
25
+ * Errors thrown by the observer are swallowed by the brain.
26
+ */
27
+ export interface BrainToolEvent {
28
+ /** 'start' fires BEFORE tool execution; 'end' fires AFTER. */
29
+ kind: 'start' | 'end'
30
+ /** Fully-qualified tool name, e.g. `shell.run`. */
31
+ tool: string
32
+ /** Tool-call id; correlates start ↔ end pair within the same turn. */
33
+ callId: string
34
+ /** Short stringified args preview (≤ ~80 chars). Present on 'start'. */
35
+ argsPreview?: string
36
+ /** Tool execution success. Heuristic from result content. Present on 'end'. */
37
+ ok?: boolean
38
+ }
39
+
40
+ /**
41
+ * Compaction event surfaced when the brain auto-folds older history into a
42
+ * summary message. Subscribers (TUI primarily) use this to push a system row
43
+ * so the operator knows the summary fired. Errors thrown by the observer are
44
+ * swallowed by the brain.
45
+ */
46
+ export interface BrainCompactionEvent {
47
+ /** Channel whose history was compacted. */
48
+ channelKey: string
49
+ /** Number of messages BEFORE compaction (full history length). */
50
+ from: number
51
+ /** Number of messages AFTER compaction (summary + kept recent). */
52
+ to: number
53
+ /** Token estimate of the pre-compaction history. */
54
+ promptTokens: number
55
+ }
56
+
57
+ export interface BrainInferInput {
58
+ /** The event that woke the brain. */
59
+ event: LyraEvent
60
+ /** Optional multi-turn context beyond the event payload. */
61
+ history?: BrainMessage[]
62
+ /** Optional tool allowlist override (defaults to all registered tools). */
63
+ toolWhitelist?: string[]
64
+ /**
65
+ * Channel partition for this turn's history. Each surface keeps its own
66
+ * conversation context: TUI/stdin is `'tui:stdin'`, Telegram DM is
67
+ * `agent:<name>:telegram:dm:<chatId>`, A2A drains use `a2a:<peer>`,
68
+ * marketplace uses `'marketplace'`. Missing key falls back to `'default'`.
69
+ *
70
+ * Backward-compatible: omitting the key keeps the legacy single-history
71
+ * behavior under the `'default'` channel.
72
+ */
73
+ channelKey?: string
74
+ /**
75
+ * Cancel the in-flight turn. Aborts the upstream HTTP fetch (so Sui
76
+ * Compute stops billing the round-trip immediately) and short-circuits
77
+ * the tool-call loop. The promise rejects with a DOMException whose
78
+ * `.name === 'AbortError'`. Caller should catch that and treat it as
79
+ * a clean operator-driven cancel, not an error.
80
+ */
81
+ signal?: AbortSignal
82
+ /**
83
+ * Per-turn observer of tool-call lifecycle. Fired by the brain before and
84
+ * after each tool execution. Use for UI streaming (TG progress message,
85
+ * TUI bridge) without bothering the brain-construction onToolCall (which
86
+ * is the actual tool executor). Errors swallowed by the brain.
87
+ */
88
+ onToolEvent?: (ev: BrainToolEvent) => void
89
+ /**
90
+ * Per-turn observer of compaction events. Fires when the pre-flight
91
+ * threshold check triggers a summarize-fold of older messages. TUI
92
+ * surfaces this as a system row; TG dispatchers leave it silent.
93
+ */
94
+ onCompactionEvent?: (ev: BrainCompactionEvent) => void
95
+ }
96
+
97
+ export interface BrainTurn {
98
+ content: string | null
99
+ toolCalls: ToolCall[]
100
+ reasoningContent?: string
101
+ finishReason?: string
102
+ usage?: {
103
+ promptTokens?: number
104
+ completionTokens?: number
105
+ totalTokens?: number
106
+ cachedTokens?: number
107
+ }
108
+ }
109
+
110
+ export interface Brain {
111
+ infer(input: BrainInferInput): Promise<BrainTurn>
112
+ /**
113
+ * v0.20.0: clear a channel's history. Optional so legacy non-OG brains
114
+ * (StubBrain etc) don't have to implement it.
115
+ */
116
+ clearChannel?(channelKey?: string): Promise<void> | void
117
+ }
118
+
119
+ export interface BrainProvider {
120
+ name: string
121
+ build(opts: BrainProviderOpts): Promise<Brain>
122
+ }
123
+
124
+ export interface BrainProviderOpts {
125
+ systemPrompt: string
126
+ tools: ToolSchema[]
127
+ maxTokens?: number
128
+ maxOutputTokens?: number
129
+ }
package/src/chain.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { SuiClient, getFullnodeUrl } from '@mysten/sui/client'
2
+ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
3
+ import type { LyraNetwork } from './config'
4
+
5
+ /**
6
+ * Sui chain helpers for Lyra core. One agent keypair signs and pays gas; the
7
+ * deterministic policy (enforced on-chain by `lyra::policy`) bounds what it may
8
+ * do. Mirrors the patterns in `plugin-onchain/src/client.ts`.
9
+ */
10
+
11
+ /** Canonical Sui fullnode JSON-RPC URL for a network. */
12
+ export function suiRpcUrl(network: LyraNetwork): string {
13
+ return getFullnodeUrl(network)
14
+ }
15
+
16
+ /** A Sui JSON-RPC client for the given network. */
17
+ export function makeSuiClient(network: LyraNetwork): SuiClient {
18
+ return new SuiClient({ url: getFullnodeUrl(network) })
19
+ }
20
+
21
+ /**
22
+ * Build a keypair from a secret. Accepts a Sui bech32 secret
23
+ * (`suiprivkey1...`, preferred) or a base64-encoded 32-byte seed.
24
+ */
25
+ export function keypairFromSecret(secret: string): Ed25519Keypair {
26
+ const s = secret.trim()
27
+ if (s.startsWith('suiprivkey')) return Ed25519Keypair.fromSecretKey(s)
28
+ return Ed25519Keypair.fromSecretKey(Uint8Array.from(Buffer.from(s, 'base64')))
29
+ }
30
+
31
+ /** Total SUI balance of an address, in MIST (1 SUI = 1e9 MIST). */
32
+ export async function getSuiBalanceMist(client: SuiClient, address: string): Promise<bigint> {
33
+ const { totalBalance } = await client.getBalance({ owner: address })
34
+ return BigInt(totalBalance)
35
+ }
@@ -0,0 +1,71 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { discoverClaudeExtras } from './discovery'
6
+
7
+ let scratch: string
8
+
9
+ beforeEach(async () => {
10
+ scratch = await mkdtemp(join(tmpdir(), 'lyra-claude-extras-'))
11
+ })
12
+
13
+ afterEach(async () => {
14
+ await rm(scratch, { recursive: true, force: true })
15
+ })
16
+
17
+ describe('discoverClaudeExtras', () => {
18
+ it('discovers commands + agents from plugin cache layout', async () => {
19
+ const versionDir = join(scratch, 'cache', 'mp', 'plug', '0.1.0')
20
+ await mkdir(join(versionDir, 'commands'), { recursive: true })
21
+ await mkdir(join(versionDir, 'agents'), { recursive: true })
22
+ await writeFile(
23
+ join(versionDir, 'commands', 'setup.md'),
24
+ '---\nname: setup\ndescription: Build pragma\n---\n\n# Setup body\n',
25
+ )
26
+ await writeFile(
27
+ join(versionDir, 'agents', 'thymos.md'),
28
+ '---\nname: thymos\ndescription: scalper\nmodel: sonnet\n---\n\n# Agent body\n',
29
+ )
30
+ const out = await discoverClaudeExtras({
31
+ claudePluginsCacheRoot: join(scratch, 'cache'),
32
+ importsClaudeCode: true,
33
+ })
34
+ expect(out.commands).toHaveLength(1)
35
+ expect(out.commands[0]!).toMatchObject({
36
+ id: 'plug:setup',
37
+ name: 'setup',
38
+ description: 'Build pragma',
39
+ source: { marketplace: 'mp', plugin: 'plug', version: '0.1.0' },
40
+ })
41
+ expect(out.commands[0]!.body).toContain('Setup body')
42
+ expect(out.agents).toHaveLength(1)
43
+ expect(out.agents[0]!).toMatchObject({
44
+ id: 'plug:thymos',
45
+ name: 'thymos',
46
+ model: 'sonnet',
47
+ })
48
+ expect(out.agents[0]!.body).toContain('Agent body')
49
+ })
50
+
51
+ it('returns empty when imports.claudeCode is false', async () => {
52
+ const out = await discoverClaudeExtras({
53
+ claudePluginsCacheRoot: join(scratch, 'cache'),
54
+ importsClaudeCode: false,
55
+ })
56
+ expect(out).toEqual({ commands: [], agents: [] })
57
+ })
58
+
59
+ it('skips files without frontmatter (still parses but with empty meta)', async () => {
60
+ const versionDir = join(scratch, 'cache', 'mp', 'plug', '0.1.0')
61
+ await mkdir(join(versionDir, 'commands'), { recursive: true })
62
+ await writeFile(join(versionDir, 'commands', 'foo.md'), 'no frontmatter just body')
63
+ const out = await discoverClaudeExtras({
64
+ claudePluginsCacheRoot: join(scratch, 'cache'),
65
+ importsClaudeCode: true,
66
+ })
67
+ expect(out.commands).toHaveLength(1)
68
+ expect(out.commands[0]!.name).toBe('foo')
69
+ expect(out.commands[0]!.body).toBe('no frontmatter just body')
70
+ })
71
+ })
@@ -0,0 +1,152 @@
1
+ import type { Dirent } from 'node:fs'
2
+ import { readFile, readdir, stat } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import type { ClaudeAgent, ClaudeCommand, ClaudeExtrasDiscoveryResult } from './types'
6
+
7
+ export interface ClaudeExtrasOptions {
8
+ importsClaudeCode?: boolean
9
+ /** Override for ~/.claude/plugins/cache/. */
10
+ claudePluginsCacheRoot?: string
11
+ }
12
+
13
+ export async function discoverClaudeExtras(
14
+ opts: ClaudeExtrasOptions = {},
15
+ ): Promise<ClaudeExtrasDiscoveryResult> {
16
+ const importsClaudeCode = opts.importsClaudeCode ?? true
17
+ if (!importsClaudeCode) return { commands: [], agents: [] }
18
+ const cacheRoot = opts.claudePluginsCacheRoot ?? join(homedir(), '.claude', 'plugins', 'cache')
19
+ const commands: ClaudeCommand[] = []
20
+ const agents: ClaudeAgent[] = []
21
+
22
+ let marketplaces: Dirent[]
23
+ try {
24
+ const s = await stat(cacheRoot)
25
+ if (!s.isDirectory()) return { commands, agents }
26
+ marketplaces = (await readdir(cacheRoot, { withFileTypes: true })) as Dirent[]
27
+ } catch {
28
+ return { commands, agents }
29
+ }
30
+
31
+ for (const market of marketplaces) {
32
+ if (!market.isDirectory()) continue
33
+ const marketDir = join(cacheRoot, market.name)
34
+ let plugins: Dirent[]
35
+ try {
36
+ plugins = (await readdir(marketDir, { withFileTypes: true })) as Dirent[]
37
+ } catch {
38
+ continue
39
+ }
40
+ for (const plugin of plugins) {
41
+ if (!plugin.isDirectory()) continue
42
+ const pluginDir = join(marketDir, plugin.name)
43
+ let versions: Dirent[]
44
+ try {
45
+ versions = (await readdir(pluginDir, { withFileTypes: true })) as Dirent[]
46
+ } catch {
47
+ continue
48
+ }
49
+ const versionDirs = versions
50
+ .filter(v => v.isDirectory())
51
+ .map(v => v.name)
52
+ .sort()
53
+ const latest = versionDirs[versionDirs.length - 1]
54
+ if (!latest) continue
55
+ const versionDir = join(pluginDir, latest)
56
+ const source = { marketplace: market.name, plugin: plugin.name, version: latest }
57
+ await collectFromDir(join(versionDir, 'commands'), source, 'command', commands, agents)
58
+ await collectFromDir(join(versionDir, 'agents'), source, 'agent', commands, agents)
59
+ }
60
+ }
61
+ return { commands, agents }
62
+ }
63
+
64
+ async function collectFromDir(
65
+ dir: string,
66
+ source: { marketplace: string; plugin: string; version: string },
67
+ kind: 'command' | 'agent',
68
+ commands: ClaudeCommand[],
69
+ agents: ClaudeAgent[],
70
+ ): Promise<void> {
71
+ let entries: Dirent[]
72
+ try {
73
+ const s = await stat(dir)
74
+ if (!s.isDirectory()) return
75
+ entries = (await readdir(dir, { withFileTypes: true })) as Dirent[]
76
+ } catch {
77
+ return
78
+ }
79
+ for (const entry of entries) {
80
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
81
+ const filePath = join(dir, entry.name)
82
+ let raw: string
83
+ try {
84
+ raw = await readFile(filePath, 'utf8')
85
+ } catch {
86
+ continue
87
+ }
88
+ const parsed = parseFile(raw)
89
+ if (!parsed) continue
90
+ const id = `${source.plugin}:${parsed.name ?? entry.name.replace(/\.md$/, '')}`
91
+ const name = parsed.name ?? entry.name.replace(/\.md$/, '')
92
+ if (kind === 'command') {
93
+ commands.push({
94
+ id,
95
+ name,
96
+ description: parsed.description ?? '',
97
+ argumentHint: parsed.argumentHint,
98
+ path: filePath,
99
+ body: parsed.body,
100
+ source,
101
+ })
102
+ } else {
103
+ agents.push({
104
+ id,
105
+ name,
106
+ description: parsed.description ?? '',
107
+ model: parsed.model,
108
+ path: filePath,
109
+ body: parsed.body,
110
+ source,
111
+ })
112
+ }
113
+ }
114
+ }
115
+
116
+ interface ParsedFile {
117
+ name?: string
118
+ description?: string
119
+ argumentHint?: string
120
+ model?: string
121
+ body: string
122
+ }
123
+
124
+ function parseFile(raw: string): ParsedFile | null {
125
+ if (!raw.startsWith('---')) {
126
+ return { body: raw }
127
+ }
128
+ const end = raw.indexOf('\n---', 4)
129
+ if (end === -1) return { body: raw }
130
+ const block = raw.slice(4, end)
131
+ const body = raw.slice(end + 4).replace(/^\n/, '')
132
+ const out: ParsedFile = { body }
133
+ for (const line of block.split('\n')) {
134
+ const m = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/)
135
+ if (!m?.[1]) continue
136
+ const key = m[1]
137
+ const value = unquote(m[2] ?? '')
138
+ if (key === 'name') out.name = value
139
+ else if (key === 'description') out.description = value
140
+ else if (key === 'argument-hint' || key === 'argumentHint') out.argumentHint = value
141
+ else if (key === 'model') out.model = value
142
+ }
143
+ return out
144
+ }
145
+
146
+ function unquote(s: string): string {
147
+ const t = s.trim()
148
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
149
+ return t.slice(1, -1)
150
+ }
151
+ return t
152
+ }
@@ -0,0 +1,6 @@
1
+ export { discoverClaudeExtras, type ClaudeExtrasOptions } from './discovery'
2
+ export type {
3
+ ClaudeCommand,
4
+ ClaudeAgent,
5
+ ClaudeExtrasDiscoveryResult,
6
+ } from './types'
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Phase 9.2 Bundle 8 surfaces: Claude Code commands + agents discovered from
3
+ * the local plugin cache. Both are markdown files with YAML frontmatter; the
4
+ * body is the prompt that lyra inlines when the command/agent fires.
5
+ */
6
+
7
+ export interface ClaudeCommand {
8
+ /** `<plugin>:<name>` (lyra drops the marketplace prefix; the cmd surface is flat). */
9
+ id: string
10
+ /** Bare command name (e.g. `setup`, `mode`, `commit`). */
11
+ name: string
12
+ description: string
13
+ /** Optional argument-hint shown after the slash command in help. */
14
+ argumentHint?: string
15
+ /** Absolute path to the markdown file. */
16
+ path: string
17
+ /** Body without frontmatter; this is the prompt template lyra inlines. */
18
+ body: string
19
+ /** Source plugin coordinates (marketplace, plugin, version). */
20
+ source: { marketplace: string; plugin: string; version: string }
21
+ }
22
+
23
+ export interface ClaudeAgent {
24
+ /** `<plugin>:<name>` */
25
+ id: string
26
+ name: string
27
+ description: string
28
+ /** Optional model hint from frontmatter (e.g. `sonnet`). lyra ignores it (uses configured brain). */
29
+ model?: string
30
+ path: string
31
+ body: string
32
+ source: { marketplace: string; plugin: string; version: string }
33
+ }
34
+
35
+ export interface ClaudeExtrasDiscoveryResult {
36
+ commands: ClaudeCommand[]
37
+ agents: ClaudeAgent[]
38
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ COMMAND_REGISTRY,
3
+ applyPerms,
4
+ applyYolo,
5
+ commandsForSurface,
6
+ findCommand,
7
+ parseSlash,
8
+ suggestForPrefix,
9
+ type ApplyResult,
10
+ type CommandScope,
11
+ type CommandSurface,
12
+ type ParsedSlash,
13
+ type PermissionApi,
14
+ type PermissionToggleMode,
15
+ type SlashCommand,
16
+ } from './registry'
@@ -0,0 +1,186 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ COMMAND_REGISTRY,
4
+ commandsForSurface,
5
+ findCommand,
6
+ parseSlash,
7
+ suggestForPrefix,
8
+ } from './registry'
9
+
10
+ describe('COMMAND_REGISTRY', () => {
11
+ it('has no duplicate names', () => {
12
+ const names = COMMAND_REGISTRY.map(c => c.name)
13
+ const set = new Set(names)
14
+ expect(set.size).toBe(names.length)
15
+ })
16
+
17
+ it('every entry has at least one surface', () => {
18
+ for (const c of COMMAND_REGISTRY) {
19
+ expect(c.surfaces.length).toBeGreaterThan(0)
20
+ }
21
+ })
22
+
23
+ it('every entry has a non-empty description', () => {
24
+ for (const c of COMMAND_REGISTRY) {
25
+ expect(c.description.length).toBeGreaterThan(0)
26
+ }
27
+ })
28
+
29
+ it('all names are lowercase, no leading slash', () => {
30
+ for (const c of COMMAND_REGISTRY) {
31
+ expect(c.name).toBe(c.name.toLowerCase())
32
+ expect(c.name.startsWith('/')).toBe(false)
33
+ }
34
+ })
35
+
36
+ it('contains the cross-surface bypass commands required for v0.20.0', () => {
37
+ expect(findCommand('yolo')).toBeDefined()
38
+ expect(findCommand('perms')).toBeDefined()
39
+ expect(findCommand('reset')).toBeDefined()
40
+ expect(findCommand('yolo')?.surfaces).toEqual(['tui', 'tg'])
41
+ expect(findCommand('perms')?.surfaces).toEqual(['tui', 'tg'])
42
+ expect(findCommand('reset')?.surfaces).toEqual(['tui', 'tg'])
43
+ })
44
+ })
45
+
46
+ describe('commandsForSurface', () => {
47
+ it('returns only TUI commands when surface=tui', () => {
48
+ const tui = commandsForSurface('tui')
49
+ expect(tui.length).toBeGreaterThan(0)
50
+ for (const c of tui) expect(c.surfaces).toContain('tui')
51
+ })
52
+
53
+ it('returns only TG commands when surface=tg', () => {
54
+ const tg = commandsForSurface('tg')
55
+ expect(tg.length).toBeGreaterThan(0)
56
+ for (const c of tg) expect(c.surfaces).toContain('tg')
57
+ })
58
+
59
+ it('cross-surface commands appear in both lists', () => {
60
+ const tui = commandsForSurface('tui').map(c => c.name)
61
+ const tg = commandsForSurface('tg').map(c => c.name)
62
+ expect(tui).toContain('yolo')
63
+ expect(tg).toContain('yolo')
64
+ expect(tui).toContain('perms')
65
+ expect(tg).toContain('perms')
66
+ expect(tui).toContain('reset')
67
+ expect(tg).toContain('reset')
68
+ })
69
+
70
+ it('TUI-only commands do not appear in TG', () => {
71
+ const tg = commandsForSurface('tg').map(c => c.name)
72
+ expect(tg).not.toContain('sync')
73
+ expect(tg).not.toContain('model')
74
+ expect(tg).not.toContain('jobs')
75
+ expect(tg).not.toContain('help')
76
+ })
77
+
78
+ it('TG-only commands do not appear in TUI', () => {
79
+ const tui = commandsForSurface('tui').map(c => c.name)
80
+ expect(tui).not.toContain('stop')
81
+ expect(tui).not.toContain('new')
82
+ expect(tui).not.toContain('status')
83
+ expect(tui).not.toContain('approve')
84
+ })
85
+ })
86
+
87
+ describe('findCommand', () => {
88
+ it('resolves bare names', () => {
89
+ expect(findCommand('yolo')?.name).toBe('yolo')
90
+ })
91
+
92
+ it('strips leading slashes', () => {
93
+ expect(findCommand('/yolo')?.name).toBe('yolo')
94
+ expect(findCommand('//yolo')?.name).toBe('yolo')
95
+ })
96
+
97
+ it('is case-insensitive', () => {
98
+ expect(findCommand('YOLO')?.name).toBe('yolo')
99
+ expect(findCommand('Yolo')?.name).toBe('yolo')
100
+ })
101
+
102
+ it('returns undefined for unknown names', () => {
103
+ expect(findCommand('definitely-not-a-command')).toBeUndefined()
104
+ })
105
+ })
106
+
107
+ describe('parseSlash', () => {
108
+ it('returns null for non-slash input', () => {
109
+ expect(parseSlash('hello')).toBeNull()
110
+ expect(parseSlash(' hi /yolo')).toBeNull()
111
+ })
112
+
113
+ it('returns null for empty slash', () => {
114
+ expect(parseSlash('/')).toBeNull()
115
+ expect(parseSlash(' / ')).toBeNull()
116
+ })
117
+
118
+ it('parses bare command name', () => {
119
+ const r = parseSlash('/yolo')
120
+ expect(r?.name).toBe('yolo')
121
+ expect(r?.args).toEqual([])
122
+ expect(r?.command?.name).toBe('yolo')
123
+ })
124
+
125
+ it('parses command with args', () => {
126
+ const r = parseSlash('/perms strict')
127
+ expect(r?.name).toBe('perms')
128
+ expect(r?.args).toEqual(['strict'])
129
+ expect(r?.command?.name).toBe('perms')
130
+ })
131
+
132
+ it('lowercases the command name', () => {
133
+ const r = parseSlash('/YOLO')
134
+ expect(r?.name).toBe('yolo')
135
+ })
136
+
137
+ it('preserves arg case', () => {
138
+ const r = parseSlash('/perms STRICT')
139
+ expect(r?.args).toEqual(['STRICT'])
140
+ })
141
+
142
+ it('returns parsed shape with undefined command for unknown names', () => {
143
+ const r = parseSlash('/wat is this')
144
+ expect(r?.name).toBe('wat')
145
+ expect(r?.args).toEqual(['is', 'this'])
146
+ expect(r?.command).toBeUndefined()
147
+ })
148
+
149
+ it('tolerates leading whitespace', () => {
150
+ const r = parseSlash(' /yolo on')
151
+ expect(r?.name).toBe('yolo')
152
+ expect(r?.args).toEqual(['on'])
153
+ })
154
+ })
155
+
156
+ describe('suggestForPrefix', () => {
157
+ it('returns all surface commands for empty query', () => {
158
+ const all = commandsForSurface('tui')
159
+ expect(suggestForPrefix('tui', '')).toEqual(all)
160
+ expect(suggestForPrefix('tui', '/')).toEqual(all)
161
+ })
162
+
163
+ it('filters by prefix', () => {
164
+ const out = suggestForPrefix('tui', 'y')
165
+ const names = out.map(c => c.name)
166
+ expect(names).toContain('yolo')
167
+ expect(names).not.toContain('perms')
168
+ })
169
+
170
+ it('strips leading slash from query', () => {
171
+ expect(suggestForPrefix('tui', '/y').map(c => c.name)).toContain('yolo')
172
+ })
173
+
174
+ it('is case-insensitive on query', () => {
175
+ expect(suggestForPrefix('tui', 'YO').map(c => c.name)).toContain('yolo')
176
+ })
177
+
178
+ it('returns empty when nothing matches', () => {
179
+ expect(suggestForPrefix('tui', 'xyz-no-match')).toEqual([])
180
+ })
181
+
182
+ it('respects surface filter', () => {
183
+ const tg = suggestForPrefix('tg', 'sy').map(c => c.name)
184
+ expect(tg).not.toContain('sync')
185
+ })
186
+ })