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
+ /**
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
+ }