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.
- package/README.md +24 -0
- package/package.json +65 -0
- package/src/brain/compaction.test.ts +156 -0
- package/src/brain/compaction.ts +131 -0
- package/src/brain/demo-endpoint.ts +13 -0
- package/src/brain/frozen-prefix.test.ts +235 -0
- package/src/brain/frozen-prefix.ts +320 -0
- package/src/brain/history-persist.test.ts +129 -0
- package/src/brain/history-persist.ts +154 -0
- package/src/brain/index.ts +44 -0
- package/src/brain/openai-brain.test.ts +61 -0
- package/src/brain/openai-brain.ts +544 -0
- package/src/brain/sanitize.test.ts +27 -0
- package/src/brain/sanitize.ts +23 -0
- package/src/brain/stub.ts +20 -0
- package/src/brain/types.ts +129 -0
- package/src/chain.ts +35 -0
- package/src/claude-plugins/discovery.test.ts +71 -0
- package/src/claude-plugins/discovery.ts +152 -0
- package/src/claude-plugins/index.ts +6 -0
- package/src/claude-plugins/types.ts +38 -0
- package/src/commands/index.ts +16 -0
- package/src/commands/registry.test.ts +186 -0
- package/src/commands/registry.ts +255 -0
- package/src/config.ts +201 -0
- package/src/economy/index.ts +6 -0
- package/src/events/index.ts +4 -0
- package/src/events/listeners.ts +37 -0
- package/src/events/queue.test.ts +43 -0
- package/src/events/queue.ts +63 -0
- package/src/events/router.ts +42 -0
- package/src/events/types.ts +28 -0
- package/src/format.ts +13 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +314 -0
- package/src/locks.test.ts +259 -0
- package/src/locks.ts +233 -0
- package/src/mcp/discovery.test.ts +97 -0
- package/src/mcp/discovery.ts +150 -0
- package/src/mcp/index.ts +10 -0
- package/src/mcp/manager.test.ts +97 -0
- package/src/mcp/manager.ts +110 -0
- package/src/mcp/stdio-client.ts +154 -0
- package/src/mcp/types.ts +44 -0
- package/src/memory/edit.test.ts +41 -0
- package/src/memory/edit.ts +53 -0
- package/src/memory/encryption.test.ts +68 -0
- package/src/memory/encryption.ts +94 -0
- package/src/memory/fs-util.ts +15 -0
- package/src/memory/index-file.ts +74 -0
- package/src/memory/index-sync.test.ts +81 -0
- package/src/memory/index-sync.ts +99 -0
- package/src/memory/index.ts +58 -0
- package/src/memory/list-tool.ts +105 -0
- package/src/memory/pack-blob.test.ts +90 -0
- package/src/memory/pack-blob.ts +120 -0
- package/src/memory/pack-gather.test.ts +110 -0
- package/src/memory/pack-gather.ts +112 -0
- package/src/memory/parser.test.ts +29 -0
- package/src/memory/parser.ts +20 -0
- package/src/memory/path-drift.test.ts +71 -0
- package/src/memory/read-tool-fallback.test.ts +54 -0
- package/src/memory/read-tool.ts +198 -0
- package/src/memory/save-tool.test.ts +193 -0
- package/src/memory/save-tool.ts +189 -0
- package/src/memory/scan.test.ts +24 -0
- package/src/memory/scan.ts +63 -0
- package/src/memory/topic.ts +32 -0
- package/src/memory/types.ts +49 -0
- package/src/migration/index.ts +6 -0
- package/src/migration/option3-crypto.test.ts +106 -0
- package/src/migration/option3-crypto.ts +143 -0
- package/src/pairing.test.ts +212 -0
- package/src/pairing.ts +285 -0
- package/src/paths.ts +70 -0
- package/src/permission/dangerous.ts +105 -0
- package/src/permission/env-redact.ts +54 -0
- package/src/permission/index.ts +16 -0
- package/src/permission/path-guard.ts +114 -0
- package/src/permission/permission.test.ts +299 -0
- package/src/permission/service.ts +191 -0
- package/src/plugins/context.ts +226 -0
- package/src/plugins/hooks.ts +81 -0
- package/src/plugins/index.ts +24 -0
- package/src/plugins/plugins.test.ts +196 -0
- package/src/plugins/tool-search.ts +49 -0
- package/src/public/card.test.ts +70 -0
- package/src/public/card.ts +67 -0
- package/src/runtime/activity.ts +29 -0
- package/src/runtime/index.ts +7 -0
- package/src/runtime/runtime.test.ts +55 -0
- package/src/runtime/runtime.ts +126 -0
- package/src/sandbox/credentials.ts +25 -0
- package/src/sandbox/docker.ts +389 -0
- package/src/sandbox/factory.ts +99 -0
- package/src/sandbox/index.ts +15 -0
- package/src/sandbox/linux.ts +141 -0
- package/src/sandbox/local.ts +19 -0
- package/src/sandbox/macos.ts +71 -0
- package/src/sandbox/sandbox.test.ts +386 -0
- package/src/sandbox/seatbelt-profile.ts +139 -0
- package/src/sandbox/types.ts +129 -0
- package/src/skills/index.ts +8 -0
- package/src/skills/scanner.test.ts +137 -0
- package/src/skills/scanner.ts +257 -0
- package/src/skills/triggers.test.ts +77 -0
- package/src/skills/triggers.ts +78 -0
- package/src/skills/types.ts +37 -0
- package/src/storage/encryption.test.ts +30 -0
- package/src/storage/encryption.ts +87 -0
- package/src/storage/factory.ts +31 -0
- package/src/storage/index.ts +11 -0
- package/src/storage/local-stub.ts +70 -0
- package/src/storage/sqlite.test.ts +29 -0
- package/src/storage/sqlite.ts +95 -0
- package/src/storage/types.ts +21 -0
- package/src/tools/escalation.test.ts +348 -0
- package/src/tools/escalation.ts +200 -0
- package/src/tools/index.ts +11 -0
- package/src/tools/registry.test.ts +70 -0
- package/src/tools/registry.ts +152 -0
- package/src/tools/types.ts +65 -0
- package/src/tools/zod-helpers.test.ts +63 -0
- package/src/tools/zod-helpers.ts +36 -0
- package/src/tools/zod-schema.ts +99 -0
- package/src/wallet/drain.test.ts +41 -0
- package/src/wallet/drain.ts +76 -0
- package/src/wallet/eoa.ts +61 -0
- package/src/wallet/index.ts +14 -0
- package/src/wallet/keystore.test.ts +17 -0
- 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,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
|
+
})
|