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
|
+
/**
|
|
2
|
+
* Sandbox abstraction for limb execution.
|
|
3
|
+
*
|
|
4
|
+
* Phase 9.5 (Apr 28 2026 incident response). Lyra's limbs run on the operator's
|
|
5
|
+
* host. Permission floors (PathGuard + dangerous-pattern modal + strict/prompt/yolo)
|
|
6
|
+
* caught the rm correctly during the v0.9.3 benchmark, but once the modal granted
|
|
7
|
+
* `s` (allow session), the command ran on the real host with full FS access. The
|
|
8
|
+
* cascade (tmux socket → daemon detach → orphan name-slot blocking) was severe.
|
|
9
|
+
*
|
|
10
|
+
* This module adds a structural layer BENEATH the permission floor: every
|
|
11
|
+
* spawn() call from a tool is routed through a `SandboxBackend` which can wrap
|
|
12
|
+
* the command in an OS sandbox before execution. Even if the permission floor
|
|
13
|
+
* is bypassed (yolo, allow-session, allow-once), the sandbox profile prevents
|
|
14
|
+
* writes outside an allowlist.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors hermes-agent's TERMINAL_ENV pattern (local | docker | modal | daytona |
|
|
17
|
+
* singularity | ssh) but starts smaller: `none` (passthrough) and `os` (macOS
|
|
18
|
+
* sandbox-exec / future Linux bubblewrap). Docker mode is a separate followup
|
|
19
|
+
* bundle.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { SpawnOptions } from 'node:child_process'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Mode selector. Lives under `sandbox.mode` in `~/.lyra/config.ts`.
|
|
26
|
+
*
|
|
27
|
+
* - `none`: passthrough (today's behaviour). No sandboxing applied. Default
|
|
28
|
+
* for backward compatibility while Tier 2 stabilizes.
|
|
29
|
+
* - `os`: native OS sandbox. macOS uses sandbox-exec with a deny-default
|
|
30
|
+
* seatbelt profile. Linux uses bubblewrap (post-MVP). On unsupported
|
|
31
|
+
* platforms falls back to `none` with a startup warning.
|
|
32
|
+
* - `docker`: long-lived container per session, every spawn goes through
|
|
33
|
+
* `docker exec`. Future bundle.
|
|
34
|
+
*/
|
|
35
|
+
export type SandboxMode = 'none' | 'os' | 'docker'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Inputs the factory needs to construct a backend.
|
|
39
|
+
* - `agentDir`: write-allowed (lyra writes activity log, mcp debug, etc.).
|
|
40
|
+
* - `workspaceRoot`: write-allowed (where the operator launched lyra from;
|
|
41
|
+
* fs.write/fs.patch authorized through the modal land here).
|
|
42
|
+
* - `homedir`: used by the seatbelt profile to deny secret-bearing subdirs
|
|
43
|
+
* (`~/.ssh`, `~/.aws`, `~/Library/Keychains`, `~/.config/gcloud`).
|
|
44
|
+
* - `extraWriteAllow`: optional extra subpaths to allow writes under (test
|
|
45
|
+
* sandbox dirs, custom workspaces).
|
|
46
|
+
* - `extraWriteDeny`: optional extra subpaths to explicitly block writes.
|
|
47
|
+
*/
|
|
48
|
+
export interface SandboxBackendOpts {
|
|
49
|
+
agentDir: string
|
|
50
|
+
workspaceRoot: string
|
|
51
|
+
homedir: string
|
|
52
|
+
extraWriteAllow?: string[]
|
|
53
|
+
extraWriteDeny?: string[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One spawn request, fully described before the backend wraps it. We pass
|
|
58
|
+
* `argv` rather than the legacy `(command, options)` form because backends
|
|
59
|
+
* that prepend `sandbox-exec -p ...` need to construct an explicit argv;
|
|
60
|
+
* mixing `shell: true` with a wrapper produces confused quoting.
|
|
61
|
+
*/
|
|
62
|
+
export interface WrappedSpawn {
|
|
63
|
+
/** The binary that should actually be exec'd. May be the original, may be a wrapper. */
|
|
64
|
+
command: string
|
|
65
|
+
/** Args to pass. Wrapper backends prepend their own. */
|
|
66
|
+
args: string[]
|
|
67
|
+
/** SpawnOptions to pass through. `shell` is intentionally omitted because the wrapper builds the explicit argv. */
|
|
68
|
+
options: SpawnOptions
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Inputs the tool layer hands the backend per spawn. The backend wraps the
|
|
73
|
+
* argv (e.g. prepend `sandbox-exec -p <profile>` or rewrite as `docker exec
|
|
74
|
+
* <containerId> ...`). For shell.run-style tools, the caller MUST pass an
|
|
75
|
+
* explicit argv (`command='/bin/sh', args=['-c', userCommand]`) — the backend
|
|
76
|
+
* cannot use `shell: true` because the wrapper builds the argv itself.
|
|
77
|
+
*/
|
|
78
|
+
export interface SandboxSpawnRequest {
|
|
79
|
+
command: string
|
|
80
|
+
args: string[]
|
|
81
|
+
options: SpawnOptions
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Environment hint surfaced to the brain via the frozen prefix's # Environment
|
|
86
|
+
* block. Lets the brain skip the "run pwd + ls / + uname to figure out where
|
|
87
|
+
* I am" empirical-discovery dance — saves wasted tool calls when the brain
|
|
88
|
+
* defaults to host-style commands inside a Linux container (BSD sed, fs.read
|
|
89
|
+
* /workspace ENOENT, etc.).
|
|
90
|
+
*
|
|
91
|
+
* Each non-passthrough backend implements `envHint()` to surface its specific
|
|
92
|
+
* shape. `LocalBackend` returns null (no sandbox, no hint).
|
|
93
|
+
*/
|
|
94
|
+
export interface SandboxEnvHint {
|
|
95
|
+
mode: SandboxMode
|
|
96
|
+
label: string
|
|
97
|
+
innerOs?: 'linux' | 'darwin' | null
|
|
98
|
+
workspaceMount?: string | null
|
|
99
|
+
scope?: string | null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The backend interface. Implementations: LocalBackend (passthrough),
|
|
104
|
+
* MacOSSandboxExecBackend (sandbox-exec wrapper), LinuxBubblewrapBackend
|
|
105
|
+
* (bwrap wrapper), DockerBackend (per-session container).
|
|
106
|
+
*
|
|
107
|
+
* `wrapSpawn` is async to allow lifecycle work (e.g. DockerBackend lazy-starts
|
|
108
|
+
* the container on first call). Sync backends just `return Promise.resolve(...)`.
|
|
109
|
+
* Optional `dispose` lets backends clean up (DockerBackend kills its container).
|
|
110
|
+
* Optional `envHint` returns a brain-facing description of the sandbox shape.
|
|
111
|
+
*/
|
|
112
|
+
export interface SandboxBackend {
|
|
113
|
+
/** Backend identifier surfaced in logs / debug output. */
|
|
114
|
+
readonly mode: SandboxMode
|
|
115
|
+
/** Backend label including platform detail (e.g. 'os:darwin', 'docker:oven/bun:1'). */
|
|
116
|
+
readonly label: string
|
|
117
|
+
/**
|
|
118
|
+
* Wrap a spawn request. Returns (a Promise of) the argv that should be
|
|
119
|
+
* passed to `spawn(command, args, options)`. For `none`, returns the request
|
|
120
|
+
* unchanged. For `os`, returns a sandbox-exec wrapper. For `docker`, returns
|
|
121
|
+
* `docker exec <containerId> <orig-command>`, awaiting container start on
|
|
122
|
+
* the first call.
|
|
123
|
+
*/
|
|
124
|
+
wrapSpawn(req: SandboxSpawnRequest): Promise<WrappedSpawn>
|
|
125
|
+
/** Optional cleanup (kill long-lived containers, remove temp files). Called on lyra exit. */
|
|
126
|
+
dispose?(): Promise<void>
|
|
127
|
+
/** Optional brain-facing description of the sandbox shape. Null for passthrough. */
|
|
128
|
+
envHint?(): SandboxEnvHint | null
|
|
129
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { SkillFrontmatter, SkillRef, SkillSource } from './types'
|
|
2
|
+
export { scanSkills, parseFrontmatter, type SkillScannerOptions } from './scanner'
|
|
3
|
+
export {
|
|
4
|
+
matchTriggers,
|
|
5
|
+
matchFilePattern,
|
|
6
|
+
matchBashPattern,
|
|
7
|
+
type SkillTriggerMatch,
|
|
8
|
+
} from './triggers'
|
|
@@ -0,0 +1,137 @@
|
|
|
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 { parseFrontmatter, scanSkills } from './scanner'
|
|
6
|
+
|
|
7
|
+
let scratch: string
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
scratch = await mkdtemp(join(tmpdir(), 'lyra-skills-scanner-'))
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await rm(scratch, { recursive: true, force: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
async function plant(absDir: string, fm: string, body = '# body\n'): Promise<void> {
|
|
18
|
+
await mkdir(absDir, { recursive: true })
|
|
19
|
+
await writeFile(join(absDir, 'SKILL.md'), `${fm}\n\n${body}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('parseFrontmatter', () => {
|
|
23
|
+
it('parses top-level fields', () => {
|
|
24
|
+
const fm = parseFrontmatter('---\nname: foo\ndescription: hi\nversion: 1.0.0\n---\n\nbody')
|
|
25
|
+
expect(fm.name).toBe('foo')
|
|
26
|
+
expect(fm.description).toBe('hi')
|
|
27
|
+
expect(fm.version).toBe('1.0.0')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('parses metadata.filePattern + metadata.bashPattern', () => {
|
|
31
|
+
const raw =
|
|
32
|
+
'---\nname: dogfood\ndescription: tests\nmetadata:\n filePattern: "*.test.ts,*.spec.ts"\n bashPattern: "playwright|jest"\n---\n\nbody'
|
|
33
|
+
const fm = parseFrontmatter(raw)
|
|
34
|
+
expect(fm.filePattern).toBe('*.test.ts,*.spec.ts')
|
|
35
|
+
expect(fm.bashPattern).toBe('playwright|jest')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('parses argument-hint as argumentHint', () => {
|
|
39
|
+
const fm = parseFrontmatter('---\nname: c\nargument-hint: <message>\n---\n\nbody')
|
|
40
|
+
expect(fm.argumentHint).toBe('<message>')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns empty object for non-frontmatter content', () => {
|
|
44
|
+
expect(parseFrontmatter('# just a heading\nblah')).toEqual({})
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('scanSkills', () => {
|
|
49
|
+
it('finds lyra + claude-code skills + claude plugin cache layouts', async () => {
|
|
50
|
+
const lyraSkillsRoot = join(scratch, '.lyra', 'skills')
|
|
51
|
+
const claudeSkillsRoot = join(scratch, '.claude', 'skills')
|
|
52
|
+
const claudePluginsCacheRoot = join(scratch, '.claude', 'plugins', 'cache')
|
|
53
|
+
|
|
54
|
+
await plant(join(lyraSkillsRoot, 'dogfood'), '---\nname: dogfood\ndescription: lyra skill\n---')
|
|
55
|
+
await plant(
|
|
56
|
+
join(claudeSkillsRoot, 'commit'),
|
|
57
|
+
'---\nname: commit\ndescription: claude skill\n---',
|
|
58
|
+
)
|
|
59
|
+
await plant(
|
|
60
|
+
join(claudePluginsCacheRoot, 'awesome', 'pdf', '1.0.0', 'skills', 'extract'),
|
|
61
|
+
'---\nname: extract\ndescription: pdf skill\n---',
|
|
62
|
+
)
|
|
63
|
+
// Plugin with direct SKILL.md (no skills/ subdir)
|
|
64
|
+
await plant(
|
|
65
|
+
join(claudePluginsCacheRoot, 'awesome', 'docx', '1.0.0'),
|
|
66
|
+
'---\nname: docx\ndescription: docx skill\n---',
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const skills = await scanSkills({
|
|
70
|
+
lyraSkillsRoot,
|
|
71
|
+
lyraPluginsRoot: join(scratch, '.lyra', 'plugins'),
|
|
72
|
+
claudeSkillsRoot,
|
|
73
|
+
claudePluginsCacheRoot,
|
|
74
|
+
importsClaudeCode: true,
|
|
75
|
+
})
|
|
76
|
+
const ids = skills.map(s => s.id).sort()
|
|
77
|
+
expect(ids).toContain('lyra:dogfood')
|
|
78
|
+
expect(ids).toContain('claude-code:commit')
|
|
79
|
+
expect(ids).toContain('claude-plugin:awesome:pdf:extract')
|
|
80
|
+
expect(ids).toContain('claude-plugin:awesome:docx')
|
|
81
|
+
const pdf = skills.find(s => s.id === 'claude-plugin:awesome:pdf:extract')
|
|
82
|
+
expect(pdf?.pluginCoord?.marketplace).toBe('awesome')
|
|
83
|
+
expect(pdf?.pluginCoord?.plugin).toBe('pdf')
|
|
84
|
+
expect(pdf?.pluginCoord?.version).toBe('1.0.0')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('skips claude paths when imports.claudeCode is false', async () => {
|
|
88
|
+
const claudeSkillsRoot = join(scratch, '.claude', 'skills')
|
|
89
|
+
await plant(join(claudeSkillsRoot, 'foo'), '---\nname: foo\ndescription: x\n---')
|
|
90
|
+
const skills = await scanSkills({
|
|
91
|
+
lyraSkillsRoot: join(scratch, 'doesnotexist'),
|
|
92
|
+
lyraPluginsRoot: join(scratch, 'doesnotexist'),
|
|
93
|
+
claudeSkillsRoot,
|
|
94
|
+
claudePluginsCacheRoot: join(scratch, 'doesnotexist'),
|
|
95
|
+
importsClaudeCode: false,
|
|
96
|
+
})
|
|
97
|
+
expect(skills).toEqual([])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('discovers lyra-plugin skills', async () => {
|
|
101
|
+
const lyraPluginsRoot = join(scratch, '.lyra', 'plugins')
|
|
102
|
+
await plant(
|
|
103
|
+
join(lyraPluginsRoot, 'system', 'skills', 'sweep'),
|
|
104
|
+
'---\nname: sweep\ndescription: plugin-sourced skill\n---',
|
|
105
|
+
)
|
|
106
|
+
const skills = await scanSkills({
|
|
107
|
+
lyraSkillsRoot: join(scratch, 'doesnotexist'),
|
|
108
|
+
lyraPluginsRoot,
|
|
109
|
+
claudeSkillsRoot: join(scratch, 'doesnotexist'),
|
|
110
|
+
claudePluginsCacheRoot: join(scratch, 'doesnotexist'),
|
|
111
|
+
importsClaudeCode: false,
|
|
112
|
+
})
|
|
113
|
+
expect(skills.map(s => s.id)).toEqual(['lyra-plugin:system:sweep'])
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('skills without YAML frontmatter', () => {
|
|
118
|
+
it('still surfaces skills whose SKILL.md has no frontmatter (fallback to dir name + first body line)', async () => {
|
|
119
|
+
const lyraSkillsRoot = join(scratch, '.lyra', 'skills')
|
|
120
|
+
await mkdir(join(lyraSkillsRoot, 'no-fm'), { recursive: true })
|
|
121
|
+
await writeFile(
|
|
122
|
+
join(lyraSkillsRoot, 'no-fm', 'SKILL.md'),
|
|
123
|
+
'# no-fm skill\n\nA skill without yaml frontmatter that should still be discoverable.\n',
|
|
124
|
+
)
|
|
125
|
+
const skills = await scanSkills({
|
|
126
|
+
lyraSkillsRoot,
|
|
127
|
+
lyraPluginsRoot: join(scratch, 'doesnotexist'),
|
|
128
|
+
claudeSkillsRoot: join(scratch, 'doesnotexist'),
|
|
129
|
+
claudePluginsCacheRoot: join(scratch, 'doesnotexist'),
|
|
130
|
+
importsClaudeCode: false,
|
|
131
|
+
})
|
|
132
|
+
const found = skills.find(s => s.id === 'lyra:no-fm')
|
|
133
|
+
expect(found).toBeDefined()
|
|
134
|
+
expect(found!.name).toBe('no-fm')
|
|
135
|
+
expect(found!.description).toContain('A skill without yaml frontmatter')
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,257 @@
|
|
|
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 { agentPaths } from '../paths'
|
|
6
|
+
import type { SkillFrontmatter, SkillRef, SkillSource } from './types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Scan the canonical skill locations and return parsed SkillRefs. Skips paths
|
|
10
|
+
* that don't exist or aren't directories so callers can register all sources
|
|
11
|
+
* eagerly without needing to probe individually.
|
|
12
|
+
*/
|
|
13
|
+
export interface SkillScannerOptions {
|
|
14
|
+
/** Whether to scan ~/.claude/skills/ + ~/.claude/plugins/cache/. Default true. */
|
|
15
|
+
importsClaudeCode?: boolean
|
|
16
|
+
/** Override for ~/.lyra/skills/ (test seam). Defaults to agentPaths.skills. */
|
|
17
|
+
lyraSkillsRoot?: string
|
|
18
|
+
/** Override for ~/.lyra/plugins/ (test seam). Defaults to agentPaths.plugins. */
|
|
19
|
+
lyraPluginsRoot?: string
|
|
20
|
+
/** Override for ~/.claude/skills/ (test seam). Defaults to ~/.claude/skills. */
|
|
21
|
+
claudeSkillsRoot?: string
|
|
22
|
+
/** Override for ~/.claude/plugins/cache/ (test seam). Defaults to ~/.claude/plugins/cache. */
|
|
23
|
+
claudePluginsCacheRoot?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function scanSkills(opts: SkillScannerOptions = {}): Promise<SkillRef[]> {
|
|
27
|
+
const importsClaudeCode = opts.importsClaudeCode ?? true
|
|
28
|
+
const lyraSkillsRoot = opts.lyraSkillsRoot ?? agentPaths.skills
|
|
29
|
+
const lyraPluginsRoot = opts.lyraPluginsRoot ?? agentPaths.plugins
|
|
30
|
+
const claudeSkillsRoot = opts.claudeSkillsRoot ?? join(homedir(), '.claude', 'skills')
|
|
31
|
+
const claudePluginsCacheRoot =
|
|
32
|
+
opts.claudePluginsCacheRoot ?? join(homedir(), '.claude', 'plugins', 'cache')
|
|
33
|
+
|
|
34
|
+
const refs: SkillRef[] = []
|
|
35
|
+
await collectSimple(lyraSkillsRoot, 'lyra', refs)
|
|
36
|
+
await collectLyraPluginSkills(lyraPluginsRoot, refs)
|
|
37
|
+
if (importsClaudeCode) {
|
|
38
|
+
await collectSimple(claudeSkillsRoot, 'claude-code', refs)
|
|
39
|
+
await collectClaudePluginCacheSkills(claudePluginsCacheRoot, refs)
|
|
40
|
+
}
|
|
41
|
+
return refs
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function dirEntries(path: string): Promise<Dirent[] | null> {
|
|
45
|
+
try {
|
|
46
|
+
const s = await stat(path)
|
|
47
|
+
if (!s.isDirectory()) return null
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return (await readdir(path, { withFileTypes: true })) as Dirent[]
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
59
|
+
try {
|
|
60
|
+
const s = await stat(path)
|
|
61
|
+
return s.isFile()
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function collectSimple(
|
|
68
|
+
root: string,
|
|
69
|
+
source: Extract<SkillSource, 'lyra' | 'claude-code'>,
|
|
70
|
+
out: SkillRef[],
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
const entries = await dirEntries(root)
|
|
73
|
+
if (!entries) return
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry.isDirectory()) continue
|
|
76
|
+
const skillPath = join(root, entry.name, 'SKILL.md')
|
|
77
|
+
if (!(await fileExists(skillPath))) continue
|
|
78
|
+
const ref = await loadSkill(skillPath, entry.name, source)
|
|
79
|
+
if (ref) out.push(ref)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function collectLyraPluginSkills(pluginsRoot: string, out: SkillRef[]): Promise<void> {
|
|
84
|
+
const plugins = await dirEntries(pluginsRoot)
|
|
85
|
+
if (!plugins) return
|
|
86
|
+
for (const plugin of plugins) {
|
|
87
|
+
if (!plugin.isDirectory()) continue
|
|
88
|
+
const skillsRoot = join(pluginsRoot, plugin.name, 'skills')
|
|
89
|
+
const skills = await dirEntries(skillsRoot)
|
|
90
|
+
if (!skills) continue
|
|
91
|
+
for (const skill of skills) {
|
|
92
|
+
if (!skill.isDirectory()) continue
|
|
93
|
+
const skillPath = join(skillsRoot, skill.name, 'SKILL.md')
|
|
94
|
+
if (!(await fileExists(skillPath))) continue
|
|
95
|
+
const ref = await loadSkill(skillPath, `${plugin.name}:${skill.name}`, 'lyra-plugin')
|
|
96
|
+
if (ref) out.push(ref)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function collectClaudePluginCacheSkills(cacheRoot: string, out: SkillRef[]): Promise<void> {
|
|
102
|
+
const marketplaces = await dirEntries(cacheRoot)
|
|
103
|
+
if (!marketplaces) return
|
|
104
|
+
for (const marketplace of marketplaces) {
|
|
105
|
+
if (!marketplace.isDirectory()) continue
|
|
106
|
+
const marketDir = join(cacheRoot, marketplace.name)
|
|
107
|
+
const plugins = await dirEntries(marketDir)
|
|
108
|
+
if (!plugins) continue
|
|
109
|
+
for (const plugin of plugins) {
|
|
110
|
+
if (!plugin.isDirectory()) continue
|
|
111
|
+
// Layer 1: <market>/<plugin>/<version>/skills/<skill>/SKILL.md
|
|
112
|
+
// Layer 2: <market>/<plugin>/skills/<skill>/SKILL.md (no version dir)
|
|
113
|
+
// Try the version layer first; fall back to direct.
|
|
114
|
+
const versions = await dirEntries(join(marketDir, plugin.name))
|
|
115
|
+
if (!versions) continue
|
|
116
|
+
for (const versionEntry of versions) {
|
|
117
|
+
if (!versionEntry.isDirectory()) continue
|
|
118
|
+
const versionDir = join(marketDir, plugin.name, versionEntry.name)
|
|
119
|
+
// Two valid shapes; both checked.
|
|
120
|
+
await collectClaudeSkillsFromVersion(
|
|
121
|
+
versionDir,
|
|
122
|
+
marketplace.name,
|
|
123
|
+
plugin.name,
|
|
124
|
+
versionEntry.name,
|
|
125
|
+
out,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function collectClaudeSkillsFromVersion(
|
|
133
|
+
versionDir: string,
|
|
134
|
+
marketplace: string,
|
|
135
|
+
plugin: string,
|
|
136
|
+
version: string,
|
|
137
|
+
out: SkillRef[],
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const skillsDir = join(versionDir, 'skills')
|
|
140
|
+
const direct = await fileExists(join(versionDir, 'SKILL.md'))
|
|
141
|
+
if (direct) {
|
|
142
|
+
const ref = await loadSkill(
|
|
143
|
+
join(versionDir, 'SKILL.md'),
|
|
144
|
+
`${marketplace}:${plugin}`,
|
|
145
|
+
'claude-plugin',
|
|
146
|
+
)
|
|
147
|
+
if (ref) {
|
|
148
|
+
ref.pluginCoord = { marketplace, plugin, version }
|
|
149
|
+
out.push(ref)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const skills = await dirEntries(skillsDir)
|
|
153
|
+
if (!skills) return
|
|
154
|
+
for (const skill of skills) {
|
|
155
|
+
if (!skill.isDirectory()) continue
|
|
156
|
+
const skillPath = join(skillsDir, skill.name, 'SKILL.md')
|
|
157
|
+
if (!(await fileExists(skillPath))) continue
|
|
158
|
+
const id = `${marketplace}:${plugin}:${skill.name}`
|
|
159
|
+
const ref = await loadSkill(skillPath, id, 'claude-plugin')
|
|
160
|
+
if (ref) {
|
|
161
|
+
ref.pluginCoord = { marketplace, plugin, version }
|
|
162
|
+
out.push(ref)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function loadSkill(
|
|
168
|
+
path: string,
|
|
169
|
+
fallbackId: string,
|
|
170
|
+
source: SkillSource,
|
|
171
|
+
): Promise<SkillRef | null> {
|
|
172
|
+
let raw: string
|
|
173
|
+
try {
|
|
174
|
+
raw = await readFile(path, 'utf8')
|
|
175
|
+
} catch {
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
const fm = parseFrontmatter(raw)
|
|
179
|
+
// Skills without YAML frontmatter are still surfaced; we derive name from
|
|
180
|
+
// the directory and description from the first heading or paragraph so the
|
|
181
|
+
// brain can find them via `skills.list`.
|
|
182
|
+
const name = fm.name ?? fallbackId
|
|
183
|
+
const description = fm.description ?? deriveDescription(raw)
|
|
184
|
+
return {
|
|
185
|
+
id: `${source}:${fallbackId}`,
|
|
186
|
+
name,
|
|
187
|
+
description,
|
|
188
|
+
path,
|
|
189
|
+
source,
|
|
190
|
+
frontmatter: { name, description, ...fm },
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function deriveDescription(raw: string): string {
|
|
195
|
+
const body = raw.startsWith('---') ? raw.slice(raw.indexOf('\n---', 4) + 4) : raw
|
|
196
|
+
for (const line of body.split('\n')) {
|
|
197
|
+
const trimmed = line.trim()
|
|
198
|
+
if (!trimmed) continue
|
|
199
|
+
if (trimmed.startsWith('#')) continue
|
|
200
|
+
return trimmed.slice(0, 200)
|
|
201
|
+
}
|
|
202
|
+
return ''
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const KEY_RE = /^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Minimal YAML frontmatter parser (top-level + one nested level for `metadata:`).
|
|
209
|
+
* We avoid pulling in a full YAML lib because skills only need a tiny subset and
|
|
210
|
+
* scan time runs on every chat boot.
|
|
211
|
+
*/
|
|
212
|
+
export function parseFrontmatter(raw: string): Partial<SkillFrontmatter> {
|
|
213
|
+
if (!raw.startsWith('---')) return {}
|
|
214
|
+
const end = raw.indexOf('\n---', 4)
|
|
215
|
+
if (end === -1) return {}
|
|
216
|
+
const block = raw.slice(4, end)
|
|
217
|
+
const out: Partial<SkillFrontmatter> = {}
|
|
218
|
+
let inMetadata = false
|
|
219
|
+
for (const rawLine of block.split('\n')) {
|
|
220
|
+
if (rawLine.trim() === '') {
|
|
221
|
+
inMetadata = false
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
const indented = rawLine.startsWith(' ') || rawLine.startsWith('\t')
|
|
225
|
+
const trimmed = rawLine.trim()
|
|
226
|
+
if (!indented) {
|
|
227
|
+
inMetadata = false
|
|
228
|
+
const m = trimmed.match(KEY_RE)
|
|
229
|
+
if (!m?.[1]) continue
|
|
230
|
+
const key = m[1]
|
|
231
|
+
const value = unquote(m[2] ?? '')
|
|
232
|
+
if (key === 'name') out.name = value
|
|
233
|
+
else if (key === 'description') out.description = value
|
|
234
|
+
else if (key === 'version') out.version = value
|
|
235
|
+
else if (key === 'license') out.license = value
|
|
236
|
+
else if (key === 'argument-hint' || key === 'argumentHint') out.argumentHint = value
|
|
237
|
+
else if (key === 'metadata') inMetadata = true
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
if (!inMetadata) continue
|
|
241
|
+
const m = trimmed.match(KEY_RE)
|
|
242
|
+
if (!m?.[1]) continue
|
|
243
|
+
const key = m[1]
|
|
244
|
+
const value = unquote(m[2] ?? '')
|
|
245
|
+
if (key === 'filePattern') out.filePattern = value
|
|
246
|
+
else if (key === 'bashPattern') out.bashPattern = value
|
|
247
|
+
}
|
|
248
|
+
return out
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function unquote(s: string): string {
|
|
252
|
+
const t = s.trim()
|
|
253
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
254
|
+
return t.slice(1, -1)
|
|
255
|
+
}
|
|
256
|
+
return t
|
|
257
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { matchBashPattern, matchFilePattern, matchTriggers } from './triggers'
|
|
3
|
+
import type { SkillRef } from './types'
|
|
4
|
+
|
|
5
|
+
describe('matchFilePattern', () => {
|
|
6
|
+
it('matches single glob against basename', () => {
|
|
7
|
+
expect(matchFilePattern('*.test.ts', '/tmp/foo.test.ts')).toBe(true)
|
|
8
|
+
expect(matchFilePattern('*.test.ts', '/tmp/foo.ts')).toBe(false)
|
|
9
|
+
})
|
|
10
|
+
it('matches comma-separated globs', () => {
|
|
11
|
+
expect(matchFilePattern('*.test.ts,*.spec.ts', '/tmp/foo.spec.ts')).toBe(true)
|
|
12
|
+
expect(matchFilePattern('*.test.ts,*.spec.ts', '/tmp/foo.md')).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('matchBashPattern', () => {
|
|
17
|
+
it('matches regex anywhere in command', () => {
|
|
18
|
+
expect(matchBashPattern('playwright|jest', 'bun run jest tests/')).toBe(true)
|
|
19
|
+
expect(matchBashPattern('playwright|jest', 'go test ./...')).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
it('returns false on invalid regex', () => {
|
|
22
|
+
expect(matchBashPattern('[invalid', 'foo')).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
function ref(filePattern?: string, bashPattern?: string): SkillRef {
|
|
27
|
+
return {
|
|
28
|
+
id: 'lyra:t',
|
|
29
|
+
name: 't',
|
|
30
|
+
description: '',
|
|
31
|
+
path: '/tmp/SKILL.md',
|
|
32
|
+
source: 'lyra',
|
|
33
|
+
frontmatter: {
|
|
34
|
+
name: 't',
|
|
35
|
+
description: '',
|
|
36
|
+
...(filePattern ? { filePattern } : {}),
|
|
37
|
+
...(bashPattern ? { bashPattern } : {}),
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('matchTriggers', () => {
|
|
43
|
+
it('matches fs.write paths against filePattern', () => {
|
|
44
|
+
const skills = [ref('*.test.ts')]
|
|
45
|
+
const out = matchTriggers(
|
|
46
|
+
{ name: 'fs.write', args: { path: '/tmp/foo.test.ts', text: 'x' } },
|
|
47
|
+
skills,
|
|
48
|
+
)
|
|
49
|
+
expect(out).toHaveLength(1)
|
|
50
|
+
expect(out[0]!.reason).toBe('filePattern')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('matches shell.run commands against bashPattern', () => {
|
|
54
|
+
const skills = [ref(undefined, 'jest')]
|
|
55
|
+
const out = matchTriggers(
|
|
56
|
+
{ name: 'shell.run', args: { command: 'bun run jest tests/' } },
|
|
57
|
+
skills,
|
|
58
|
+
)
|
|
59
|
+
expect(out).toHaveLength(1)
|
|
60
|
+
expect(out[0]!.reason).toBe('bashPattern')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns empty when nothing matches', () => {
|
|
64
|
+
const skills = [ref('*.spec.ts'), ref(undefined, 'rspec')]
|
|
65
|
+
const out = matchTriggers(
|
|
66
|
+
{ name: 'fs.write', args: { path: '/tmp/foo.md', text: 'x' } },
|
|
67
|
+
skills,
|
|
68
|
+
)
|
|
69
|
+
expect(out).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('ignores non-matching tool names', () => {
|
|
73
|
+
const skills = [ref('*.md')]
|
|
74
|
+
const out = matchTriggers({ name: 'memory.save', args: { path: '/tmp/foo.md' } }, skills)
|
|
75
|
+
expect(out).toEqual([])
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { SkillRef } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Match a comma-separated glob list (`*.test.ts,*.spec.ts`) against an
|
|
5
|
+
* absolute path. Globs are matched against the basename and against the
|
|
6
|
+
* trailing path segment (e.g. `tests/foo.spec.ts` matches `tests/*.spec.ts`).
|
|
7
|
+
*/
|
|
8
|
+
export function matchFilePattern(pattern: string, absPath: string): boolean {
|
|
9
|
+
const globs = pattern
|
|
10
|
+
.split(',')
|
|
11
|
+
.map(s => s.trim())
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
if (globs.length === 0) return false
|
|
14
|
+
const basename = absPath.split('/').pop() ?? absPath
|
|
15
|
+
for (const g of globs) {
|
|
16
|
+
if (globToRegex(g).test(basename) || globToRegex(g).test(absPath)) return true
|
|
17
|
+
}
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function matchBashPattern(pattern: string, command: string): boolean {
|
|
22
|
+
try {
|
|
23
|
+
return new RegExp(pattern).test(command)
|
|
24
|
+
} catch {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Find skills whose triggers match the given tool call. Returns the matched
|
|
31
|
+
* skills paired with the reason (so the brain sees "auto-loaded by file
|
|
32
|
+
* pattern" or "auto-loaded by bash pattern" in the injected context).
|
|
33
|
+
*/
|
|
34
|
+
export interface SkillTriggerMatch {
|
|
35
|
+
skill: SkillRef
|
|
36
|
+
reason: 'filePattern' | 'bashPattern'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function matchTriggers(
|
|
40
|
+
call: { name: string; args: unknown },
|
|
41
|
+
skills: readonly SkillRef[],
|
|
42
|
+
): SkillTriggerMatch[] {
|
|
43
|
+
const args = (call.args ?? {}) as { path?: unknown; command?: unknown }
|
|
44
|
+
const matches: SkillTriggerMatch[] = []
|
|
45
|
+
if (
|
|
46
|
+
typeof args.path === 'string' &&
|
|
47
|
+
(call.name === 'fs.read' || call.name === 'fs.write' || call.name === 'fs.patch')
|
|
48
|
+
) {
|
|
49
|
+
for (const skill of skills) {
|
|
50
|
+
const fp = skill.frontmatter.filePattern
|
|
51
|
+
if (fp && matchFilePattern(fp, args.path)) {
|
|
52
|
+
matches.push({ skill, reason: 'filePattern' })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (typeof args.command === 'string' && call.name === 'shell.run') {
|
|
57
|
+
for (const skill of skills) {
|
|
58
|
+
const bp = skill.frontmatter.bashPattern
|
|
59
|
+
if (bp && matchBashPattern(bp, args.command)) {
|
|
60
|
+
matches.push({ skill, reason: 'bashPattern' })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return matches
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function globToRegex(glob: string): RegExp {
|
|
68
|
+
let pat = ''
|
|
69
|
+
for (let i = 0; i < glob.length; i++) {
|
|
70
|
+
const c = glob[i]
|
|
71
|
+
if (c === '*') pat += '.*'
|
|
72
|
+
else if (c === '?') pat += '.'
|
|
73
|
+
else if (c === '.') pat += '\\.'
|
|
74
|
+
else if (c === '/') pat += '/'
|
|
75
|
+
else pat += c
|
|
76
|
+
}
|
|
77
|
+
return new RegExp(`^${pat}$`)
|
|
78
|
+
}
|