kira-glasses-bridge 2.23.0 → 2.25.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 +1 -1
- package/package.json +1 -1
- package/src/agent-files.mjs +171 -0
- package/src/agents/claude.mjs +12 -2
- package/src/cli.mjs +1 -1
- package/src/config-folders.mjs +144 -0
- package/src/index.mjs +5 -2
- package/src/skill-files.mjs +138 -0
- package/src/claude-config.mjs +0 -153
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Connects your Mac to **Kira**, the assistant for Even Realities G2 glasses. With
|
|
|
4
4
|
|
|
5
5
|
## Setup
|
|
6
6
|
|
|
7
|
-
1. In the Kira portal, open **
|
|
7
|
+
1. In the Kira portal, open **Settings → Devices** and choose **Set up with Terminal**, then copy the command it shows.
|
|
8
8
|
2. Paste it into Terminal:
|
|
9
9
|
|
|
10
10
|
```bash
|
package/package.json
CHANGED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Subagents on this Mac: Claude Code's (one Markdown file each in ~/.claude/agents, front matter
|
|
2
|
+
// for its settings and the body as its system prompt, with `memory: user` kept in
|
|
3
|
+
// ~/.claude/agent-memory/<name>/MEMORY.md) and Codex's (~/.codex/agents/<name>.toml, listed only:
|
|
4
|
+
// the bridge doesn't run Codex yet). Kira's agents are Claude Code subagents: she writes their
|
|
5
|
+
// files and memory here, and a task runs as one (agents/claude.mjs).
|
|
6
|
+
import { readdir, unlink } from 'node:fs/promises'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import {
|
|
9
|
+
FOLDERS,
|
|
10
|
+
agentFolders,
|
|
11
|
+
display,
|
|
12
|
+
exists,
|
|
13
|
+
field,
|
|
14
|
+
inside,
|
|
15
|
+
oneAtATime,
|
|
16
|
+
readSmall,
|
|
17
|
+
refuseLinks,
|
|
18
|
+
splitFront,
|
|
19
|
+
writeWhole,
|
|
20
|
+
} from './config-folders.mjs'
|
|
21
|
+
|
|
22
|
+
const MAX_MD = 256 * 1024
|
|
23
|
+
/** A MEMORY.md Kira reads or replaces whole (from the agent's editor) is at most this. */
|
|
24
|
+
const MAX_MEMORY = 64 * 1024
|
|
25
|
+
/** Claude Code loads the first 200 lines or 25KB of MEMORY.md: what Kira adds stays inside that. */
|
|
26
|
+
const LOAD_LINES = 200
|
|
27
|
+
const LOAD_BYTES = 25 * 1024
|
|
28
|
+
/** A line Kira added after a task (rememberAgent): "- 2026-10-12: …". The only lines she ever drops. */
|
|
29
|
+
const KIRAS_LINE = /^- \d{4}-\d{2}-\d{2}: /
|
|
30
|
+
const WHAT = 'a subagent'
|
|
31
|
+
const NAMED = { loose: true }
|
|
32
|
+
const claudeRoot = () => agentFolders()[0].root
|
|
33
|
+
|
|
34
|
+
/** A TOML string value on its own line (`name = "x"`), enough for Codex's name and description. */
|
|
35
|
+
const tomlField = (toml, key) => {
|
|
36
|
+
const m = new RegExp(`^${key}\\s*=\\s*(?:"((?:[^"\\\\]|\\\\.)*)"|'([^']*)')`, 'm').exec(toml)
|
|
37
|
+
return m ? (m[1] ?? m[2]).replace(/\\"/g, '"') : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The name and description a tool shows, from a subagent file (its file name when it has none). */
|
|
41
|
+
function agentSummary(text, slug, ext = '.md') {
|
|
42
|
+
if (ext === '.toml') {
|
|
43
|
+
return { name: tomlField(text, 'name') || slug, description: tomlField(text, 'description') || '' }
|
|
44
|
+
}
|
|
45
|
+
const { front } = splitFront(text)
|
|
46
|
+
return { name: field(front, 'name') || slug, description: field(front, 'description') || '' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const memoryFile = (name, root = FOLDERS.claude.agentMemory()) => join(inside(root, name, WHAT, NAMED), 'MEMORY.md')
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every subagent file: its slug (the file name), name, description, where it is, its text, which
|
|
53
|
+
* tool loads it, and for Claude Code's its MEMORY.md (empty when it has none).
|
|
54
|
+
*/
|
|
55
|
+
export async function listAgents(folders = agentFolders(), memoryRoot = FOLDERS.claude.agentMemory()) {
|
|
56
|
+
const agents = []
|
|
57
|
+
for (const { root, loadedBy, ext } of folders) {
|
|
58
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => [])
|
|
59
|
+
for (const e of entries) {
|
|
60
|
+
if (!e.name.endsWith(ext) || !(e.isFile() || e.isSymbolicLink())) continue
|
|
61
|
+
const slug = e.name.slice(0, -ext.length)
|
|
62
|
+
const text = await readSmall(join(root, e.name), MAX_MD)
|
|
63
|
+
if (text === null) continue
|
|
64
|
+
const summary = agentSummary(text, slug, ext)
|
|
65
|
+
const memory = loadedBy.includes('claude') ? await readMemory(summary.name, memoryRoot) : ''
|
|
66
|
+
agents.push({ slug, ...summary, path: display(join(root, e.name)), md: text, providers: loadedBy, memory })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
agents.sort((a, b) => a.slug.localeCompare(b.slug) || a.path.localeCompare(b.path))
|
|
70
|
+
return { root: display(folders[0].root), agents }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readMemory(name, root) {
|
|
74
|
+
try {
|
|
75
|
+
return (await readSmall(memoryFile(name, root), MAX_MEMORY)) ?? ''
|
|
76
|
+
} catch {
|
|
77
|
+
return '' // a name Kira can't make a folder of has no memory she can read
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One of Claude Code's subagents, with its memory. */
|
|
82
|
+
export async function readAgent({ slug } = {}, root = claudeRoot(), memoryRoot = FOLDERS.claude.agentMemory()) {
|
|
83
|
+
const file = inside(root, slug, WHAT, { ...NAMED, ext: '.md' })
|
|
84
|
+
const md = await readSmall(file, MAX_MD)
|
|
85
|
+
if (md === null) return { error: `No subagent at ${display(file)}` }
|
|
86
|
+
const summary = agentSummary(md, slug)
|
|
87
|
+
return { slug, ...summary, path: display(file), md, memory: await readMemory(summary.name, memoryRoot) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Write a Claude Code subagent's file, whole: replacing the one there, or with `create` only a new
|
|
92
|
+
* one (a subagent already there, Kira's or not, is never overwritten by a new one of that name).
|
|
93
|
+
*/
|
|
94
|
+
export async function writeAgent({ slug, md, create = false } = {}, root = claudeRoot()) {
|
|
95
|
+
const file = inside(root, slug, WHAT, { ...NAMED, ext: '.md' })
|
|
96
|
+
const text = String(md ?? '')
|
|
97
|
+
if (!/^---\r?\n/.test(text)) throw new Error('A subagent file starts with front matter between --- lines')
|
|
98
|
+
if (Buffer.byteLength(text) > MAX_MD) throw new Error('That subagent file is too large')
|
|
99
|
+
await refuseLinks([root, file], 'edit')
|
|
100
|
+
if (create && (await exists(file))) throw new Error(`A subagent named ${slug} is already on your Mac`)
|
|
101
|
+
await writeWhole(file, text)
|
|
102
|
+
return { slug, ...agentSummary(text, slug), path: display(file), providers: ['claude'] }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Remove a Claude Code subagent's file. Its memory folder stays (it's what it learned). */
|
|
106
|
+
export async function removeAgent({ slug } = {}, root = claudeRoot()) {
|
|
107
|
+
const file = inside(root, slug, WHAT, { ...NAMED, ext: '.md' })
|
|
108
|
+
await refuseLinks([root, file], 'remove')
|
|
109
|
+
const removed = await unlink(file).then(
|
|
110
|
+
() => true,
|
|
111
|
+
e => {
|
|
112
|
+
if (e.code === 'ENOENT') return false
|
|
113
|
+
throw e
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
return { slug, removed, path: display(file) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Replace a subagent's MEMORY.md, whole (`name` is its front-matter name). */
|
|
120
|
+
export async function writeAgentMemory({ name, md } = {}, root = FOLDERS.claude.agentMemory()) {
|
|
121
|
+
const file = memoryFile(name, root)
|
|
122
|
+
return oneAtATime(file, () => writeMemoryFile(name, file, String(md ?? ''), root))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function writeMemoryFile(name, file, text, root) {
|
|
126
|
+
if (Buffer.byteLength(text) > MAX_MEMORY) throw new Error('That memory is too large')
|
|
127
|
+
await refuseLinks([root, join(file, '..'), file], 'edit')
|
|
128
|
+
await writeWhole(file, text)
|
|
129
|
+
return { name, path: display(file), memory: text }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* MEMORY.md with `entry` added at the end, kept inside what Claude Code loads (200 lines, 25KB) by
|
|
134
|
+
* dropping Kira's own oldest dated lines, never the person's. Null when it can't fit without
|
|
135
|
+
* dropping theirs (then it isn't added: past the window Claude Code wouldn't read it anyway).
|
|
136
|
+
*/
|
|
137
|
+
export function withEntry(text, entry) {
|
|
138
|
+
const lines = text.trimEnd() ? text.trimEnd().split('\n') : []
|
|
139
|
+
lines.push(entry)
|
|
140
|
+
const fits = () => lines.length <= LOAD_LINES && Buffer.byteLength(`${lines.join('\n')}\n`) <= LOAD_BYTES
|
|
141
|
+
while (!fits()) {
|
|
142
|
+
const oldest = lines.findIndex((l, i) => i < lines.length - 1 && KIRAS_LINE.test(l))
|
|
143
|
+
if (oldest < 0) return null
|
|
144
|
+
lines.splice(oldest, 1)
|
|
145
|
+
}
|
|
146
|
+
return `${lines.join('\n')}\n`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Add a line to a subagent's MEMORY.md (what Kira keeps after a task); see withEntry. */
|
|
150
|
+
export async function rememberAgent({ name, line } = {}, root = FOLDERS.claude.agentMemory()) {
|
|
151
|
+
const file = memoryFile(name, root)
|
|
152
|
+
const entry = `- ${String(line ?? '').replace(/\s+/g, ' ').trim().slice(0, 400)}`
|
|
153
|
+
return oneAtATime(file, async () => {
|
|
154
|
+
const was = await readSmall(file, MAX_MEMORY)
|
|
155
|
+
// One there that's too big to read is theirs, whole: left as it is.
|
|
156
|
+
if (was === null && (await exists(file))) return { name, path: display(file), memory: null, full: true }
|
|
157
|
+
const text = withEntry(was ?? '', entry)
|
|
158
|
+
if (text === null) return { name, path: display(file), memory: was, full: true }
|
|
159
|
+
return writeMemoryFile(name, file, text, root)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The bridge tools (index.mjs). */
|
|
164
|
+
export const agentTools = {
|
|
165
|
+
list_subagents: () => listAgents(),
|
|
166
|
+
read_subagent: args => readAgent(args),
|
|
167
|
+
write_subagent: args => writeAgent(args),
|
|
168
|
+
remove_subagent: args => removeAgent(args),
|
|
169
|
+
write_subagent_memory: args => writeAgentMemory(args),
|
|
170
|
+
remember_subagent: args => rememberAgent(args),
|
|
171
|
+
}
|
package/src/agents/claude.mjs
CHANGED
|
@@ -128,6 +128,11 @@ function applyAnswer(name, input, request, answer, suggestions) {
|
|
|
128
128
|
return { behavior: 'deny', message: `The user declined this${answer.text ? ` ("${answer.text}")` : ''}. Do not achieve the same effect another way; if the task cannot proceed without it, stop and say what you could not do.` }
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/** Claude Code's own prompt, with what Kira adds for this run after it. */
|
|
132
|
+
function systemPromptFor(persona) {
|
|
133
|
+
return { type: 'preset', preset: 'claude_code', ...(persona.systemPrompt ? { append: persona.systemPrompt } : {}) }
|
|
134
|
+
}
|
|
135
|
+
|
|
131
136
|
/**
|
|
132
137
|
* Run one instruction in a Claude session.
|
|
133
138
|
* @param io.emit(event) a protocol event
|
|
@@ -221,8 +226,13 @@ async function run({ prompt, sessionId, cwd, policy, persona, settings, abort },
|
|
|
221
226
|
// The model, effort and permission mode the person chose in the composer (plan mode: plan first).
|
|
222
227
|
...sdkOptionsFor(settings),
|
|
223
228
|
...(abort ? { abortController: abort } : {}),
|
|
224
|
-
// A
|
|
225
|
-
|
|
229
|
+
// A Kira agent runs as its Claude Code subagent (~/.claude/agents/<name>.md, agent-files.mjs): the
|
|
230
|
+
// file's body takes the place of Claude Code's default instructions, and what Kira adds for the
|
|
231
|
+
// run (its preset's conventions, the team) is appended. The claude_code preset has to be asked
|
|
232
|
+
// for: without it the SDK's bare prompt leaves the subagent's out (checked with SDK 0.3.278).
|
|
233
|
+
...(persona?.agent ? { agent: persona.agent } : {}),
|
|
234
|
+
...(persona?.systemPrompt || persona?.agent ? { systemPrompt: systemPromptFor(persona) } : {}),
|
|
235
|
+
// Its preset says which tools it does without (on top of any its file lists).
|
|
226
236
|
...(persona?.disallowedTools?.length ? { disallowedTools: persona.disallowedTools } : {}),
|
|
227
237
|
// Claude as the person's Kira: her tools come from her own MCP server, with a token the API
|
|
228
238
|
// made for this conversation. Claude Code loads them as it needs them (no tool ceiling).
|
package/src/cli.mjs
CHANGED
|
@@ -42,7 +42,7 @@ folders and apps, by voice.
|
|
|
42
42
|
kira-bridge logs follow the service log
|
|
43
43
|
kira-bridge install | uninstall manage the background service
|
|
44
44
|
|
|
45
|
-
Get a token from the Kira portal:
|
|
45
|
+
Get a token from the Kira portal: Settings -> Devices -> Set up with Terminal.
|
|
46
46
|
Settings are kept in ~/.kira/bridge.json.`
|
|
47
47
|
|
|
48
48
|
async function main() {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Where the coding agents on this Mac keep the things Kira reads and writes (skills, subagents and
|
|
2
|
+
// their memory), and the one way to touch them: a name is one plain file or folder name inside its
|
|
3
|
+
// folder, a folder that links elsewhere is never written through, and every write is whole and
|
|
4
|
+
// atomic (a temporary file renamed over the old one).
|
|
5
|
+
//
|
|
6
|
+
// Which tool loads which folder, from their docs (2026-10-12):
|
|
7
|
+
// Claude Code (code.claude.com/docs/en/skills, /sub-agents): ~/.claude/skills/<name>/SKILL.md,
|
|
8
|
+
// ~/.claude/agents/<name>.md, and a subagent's `memory: user` in ~/.claude/agent-memory/<name>/.
|
|
9
|
+
// It doesn't read ~/.agents/skills.
|
|
10
|
+
// Codex (developers.openai.com/codex: skills, subagents): ~/.agents/skills/<name>/SKILL.md (the
|
|
11
|
+
// shared Agent Skills folder) and ~/.codex/agents/<name>.toml. ~/.codex/skills isn't in its docs,
|
|
12
|
+
// but the Codex app keeps its own skills there, so Kira lists them as Codex's.
|
|
13
|
+
import { randomBytes } from 'node:crypto'
|
|
14
|
+
import { lstat, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { join, relative } from 'node:path'
|
|
17
|
+
|
|
18
|
+
const claudeHome = () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')
|
|
19
|
+
const codexHome = () => process.env.CODEX_HOME || join(homedir(), '.codex')
|
|
20
|
+
|
|
21
|
+
/** Each provider's folders. `shared` is the cross-tool Agent Skills folder. */
|
|
22
|
+
export const FOLDERS = {
|
|
23
|
+
claude: {
|
|
24
|
+
skills: () => join(claudeHome(), 'skills'),
|
|
25
|
+
agents: () => join(claudeHome(), 'agents'),
|
|
26
|
+
agentMemory: () => join(claudeHome(), 'agent-memory'),
|
|
27
|
+
},
|
|
28
|
+
codex: { skills: () => join(codexHome(), 'skills'), agents: () => join(codexHome(), 'agents') },
|
|
29
|
+
shared: { skills: () => join(homedir(), '.agents', 'skills') },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The skills folders and who loads each; Kira writes only the first (Claude Code's). */
|
|
33
|
+
export const skillFolders = () => [
|
|
34
|
+
{ root: FOLDERS.claude.skills(), loadedBy: ['claude'] },
|
|
35
|
+
{ root: FOLDERS.shared.skills(), loadedBy: ['codex'] },
|
|
36
|
+
{ root: FOLDERS.codex.skills(), loadedBy: ['codex'] },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/** The subagent folders, their file format and who loads each; Kira writes only Claude Code's. */
|
|
40
|
+
export const agentFolders = () => [
|
|
41
|
+
{ root: FOLDERS.claude.agents(), loadedBy: ['claude'], ext: '.md' },
|
|
42
|
+
{ root: FOLDERS.codex.agents(), loadedBy: ['codex'], ext: '.toml' },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
/** One plain name: lowercase letters, digits and dashes (and dots and underscores where `loose`). */
|
|
46
|
+
const STRICT = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|
47
|
+
const LOOSE = /^[a-z0-9][a-z0-9._-]{0,63}$/
|
|
48
|
+
export const isName = (s, loose = false) => (loose ? LOOSE : STRICT).test(String(s ?? ''))
|
|
49
|
+
|
|
50
|
+
/** The path for `name` in `root`, after checking it can't name anything else. */
|
|
51
|
+
export function inside(root, name, what, { loose = false, ext = '' } = {}) {
|
|
52
|
+
const s = String(name ?? '')
|
|
53
|
+
if (!isName(s, loose)) {
|
|
54
|
+
const allowed = loose
|
|
55
|
+
? 'lowercase letters, digits, dots, dashes and underscores'
|
|
56
|
+
: 'lowercase letters, digits and dashes'
|
|
57
|
+
throw new Error(`“${s}” isn’t ${what} name (${allowed})`)
|
|
58
|
+
}
|
|
59
|
+
return join(root, s + ext)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** As people read it: under the home folder with ~. */
|
|
63
|
+
export function display(full) {
|
|
64
|
+
const rel = relative(homedir(), full)
|
|
65
|
+
return rel && !rel.startsWith('..') ? `~/${rel}` : full
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isSymlink = p =>
|
|
69
|
+
lstat(p).then(
|
|
70
|
+
s => s.isSymbolicLink(),
|
|
71
|
+
() => false,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
/** Anything on the way that links elsewhere is changed where it really is, never through the link. */
|
|
75
|
+
export async function refuseLinks(paths, verb) {
|
|
76
|
+
for (const p of paths) {
|
|
77
|
+
if (await isSymlink(p)) throw new Error(`${display(p)} is a link to somewhere else; ${verb} it there`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A file's text when it's a file no bigger than `max`, else null. */
|
|
82
|
+
export async function readSmall(file, max) {
|
|
83
|
+
const st = await stat(file).catch(() => null)
|
|
84
|
+
if (!st?.isFile() || st.size > max) return null
|
|
85
|
+
return readFile(file, 'utf8').catch(() => null)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Whether something is at `p`. */
|
|
89
|
+
export const exists = p => stat(p).then(
|
|
90
|
+
() => true,
|
|
91
|
+
() => false,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Write `text` to `file` whole: its folder made, through a temporary file renamed over the old one
|
|
96
|
+
* (named for this write alone, so two writes never share one).
|
|
97
|
+
*/
|
|
98
|
+
export async function writeWhole(file, text) {
|
|
99
|
+
const dir = join(file, '..')
|
|
100
|
+
await mkdir(dir, { recursive: true })
|
|
101
|
+
const tmp = join(dir, `.${file.slice(dir.length + 1)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`)
|
|
102
|
+
await writeFile(tmp, text, 'utf8')
|
|
103
|
+
await rename(tmp, file)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Per file: the change running on it, so a read-modify-write never interleaves with another. */
|
|
107
|
+
const queues = new Map()
|
|
108
|
+
|
|
109
|
+
/** Run `fn` once every change queued before it on `key` (a file's path) is done. */
|
|
110
|
+
export function oneAtATime(key, fn) {
|
|
111
|
+
const before = queues.get(key) ?? Promise.resolve()
|
|
112
|
+
const run = before.then(fn, fn)
|
|
113
|
+
const settled = run.then(
|
|
114
|
+
() => {},
|
|
115
|
+
() => {},
|
|
116
|
+
)
|
|
117
|
+
queues.set(key, settled)
|
|
118
|
+
settled.then(() => queues.get(key) === settled && queues.delete(key))
|
|
119
|
+
return run
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Front matter between --- lines, and the rest. */
|
|
123
|
+
export function splitFront(md) {
|
|
124
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(md)
|
|
125
|
+
return m ? { front: m[1].replace(/\r\n/g, '\n'), body: md.slice(m[0].length) } : { front: '', body: md }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A front-matter value: quoted or plain on its line, or a `|` / `>` block below it. */
|
|
129
|
+
export function field(front, key) {
|
|
130
|
+
const lines = front.split('\n')
|
|
131
|
+
const at = lines.findIndex(l => l.startsWith(`${key}:`))
|
|
132
|
+
if (at < 0) return null
|
|
133
|
+
const raw = lines[at].slice(key.length + 1).trim()
|
|
134
|
+
if (/^[|>][+-]?$/.test(raw)) {
|
|
135
|
+
const block = []
|
|
136
|
+
for (const l of lines.slice(at + 1)) {
|
|
137
|
+
if (l.trim() && !/^\s/.test(l)) break
|
|
138
|
+
block.push(l.trim())
|
|
139
|
+
}
|
|
140
|
+
return block.join(raw.startsWith('|') ? '\n' : ' ').trim() || null
|
|
141
|
+
}
|
|
142
|
+
const quoted = /^(["'])(.*)\1$/.exec(raw)
|
|
143
|
+
return (quoted ? quoted[2] : raw.replace(/\s+#.*$/, '')).trim() || null
|
|
144
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// Kira bridge. Connects outbound to the Kira API and runs tools on this
|
|
3
3
|
// machine on the user's behalf. Env: KIRA_BRIDGE_URL, KIRA_BRIDGE_TOKEN.
|
|
4
4
|
import { createRequire } from 'node:module'
|
|
5
|
+
import { agentTools } from './agent-files.mjs'
|
|
5
6
|
import { AGENTS } from './agents/index.mjs'
|
|
6
7
|
import { claudeTools } from './agents/claude-local.mjs'
|
|
7
|
-
import { skillTools } from './claude-config.mjs'
|
|
8
8
|
import { connect, disconnect, sendIfLive } from './connection.mjs'
|
|
9
9
|
import { fileTools } from './file-tools.mjs'
|
|
10
10
|
import { gitTools } from './git-tools.mjs'
|
|
@@ -12,6 +12,7 @@ import { host, log, setHooks } from './host.mjs'
|
|
|
12
12
|
import { jobTools } from './job-tools.mjs'
|
|
13
13
|
import { macTools } from './mac-tools.mjs'
|
|
14
14
|
import { sessionTools } from './session-tools.mjs'
|
|
15
|
+
import { skillTools } from './skill-files.mjs'
|
|
15
16
|
import { initTerminals } from './terminals.mjs'
|
|
16
17
|
|
|
17
18
|
const { version: BRIDGE_VERSION } = createRequire(import.meta.url)('../package.json')
|
|
@@ -24,6 +25,7 @@ const tools = {
|
|
|
24
25
|
...gitTools,
|
|
25
26
|
...claudeTools,
|
|
26
27
|
...skillTools,
|
|
28
|
+
...agentTools,
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** What this bridge build implements, told to the API after its hello. */
|
|
@@ -41,7 +43,8 @@ export function startBridge(config, hostHooks = {}) {
|
|
|
41
43
|
if (config.claudeBin) process.env.KIRA_CLAUDE_BIN = config.claudeBin
|
|
42
44
|
if (!config.url || !config.token) {
|
|
43
45
|
throw new Error(
|
|
44
|
-
'No token. Run: npx kira-glasses-bridge --token <token>
|
|
46
|
+
'No token. Run: npx kira-glasses-bridge --token <token> ' +
|
|
47
|
+
'(from the Kira portal → Settings → Devices → Set up with Terminal)',
|
|
45
48
|
)
|
|
46
49
|
}
|
|
47
50
|
// Terminal output is live or nothing: never queued for later like job events.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Skills on this Mac: one SKILL.md each in a folder of its own, in Claude Code's skills folder and
|
|
2
|
+
// in Codex's (config-folders.mjs says which loads which). Kira lists all of them, each with the
|
|
3
|
+
// tools that load it (a skill linked into both folders is both's), and writes and removes the ones
|
|
4
|
+
// a person makes in Kira, in Claude Code's folder.
|
|
5
|
+
import { readdir, realpath, rename, rmdir, unlink } from 'node:fs/promises'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import {
|
|
8
|
+
display,
|
|
9
|
+
exists,
|
|
10
|
+
field,
|
|
11
|
+
inside,
|
|
12
|
+
isName,
|
|
13
|
+
readSmall,
|
|
14
|
+
refuseLinks,
|
|
15
|
+
skillFolders,
|
|
16
|
+
splitFront,
|
|
17
|
+
writeWhole,
|
|
18
|
+
} from './config-folders.mjs'
|
|
19
|
+
|
|
20
|
+
/** A SKILL.md past this isn't one: skipped when listing, refused when writing. */
|
|
21
|
+
const MAX_MD = 256 * 1024
|
|
22
|
+
const WHAT = 'a skill folder'
|
|
23
|
+
/** Where Kira writes: Claude Code's folder. */
|
|
24
|
+
const writeRoot = () => skillFolders()[0].root
|
|
25
|
+
|
|
26
|
+
/** The name and description a tool shows, from a SKILL.md (the folder's name when it has none). */
|
|
27
|
+
export function skillSummary(md, slug) {
|
|
28
|
+
const { front, body } = splitFront(md)
|
|
29
|
+
const firstLine = body.split('\n').map(l => l.trim()).find(Boolean) ?? ''
|
|
30
|
+
return { name: field(front, 'name') || slug, description: field(front, 'description') || firstLine }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The skills in one folder. */
|
|
34
|
+
async function skillsIn(root) {
|
|
35
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(e => {
|
|
36
|
+
if (e.code === 'ENOENT') return []
|
|
37
|
+
throw e
|
|
38
|
+
})
|
|
39
|
+
const out = []
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
if (!isName(e.name) || !(e.isDirectory() || e.isSymbolicLink())) continue
|
|
42
|
+
const dir = join(root, e.name)
|
|
43
|
+
const md = await readSmall(join(dir, 'SKILL.md'), MAX_MD)
|
|
44
|
+
if (md !== null) out.push({ slug: e.name, dir, real: await realpath(dir).catch(() => dir), md })
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every skill in every folder: its slug, name, description, where it is, its SKILL.md, and which
|
|
51
|
+
* tools load it (`providers`). One folder linked into another is one skill, loaded by both.
|
|
52
|
+
*/
|
|
53
|
+
export async function listSkills(folders = skillFolders()) {
|
|
54
|
+
const byReal = new Map()
|
|
55
|
+
for (const { root, loadedBy } of folders) {
|
|
56
|
+
for (const s of await skillsIn(root)) {
|
|
57
|
+
const had = byReal.get(s.real)
|
|
58
|
+
if (had) had.providers = [...new Set([...had.providers, ...loadedBy])]
|
|
59
|
+
else {
|
|
60
|
+
const skill = { slug: s.slug, ...skillSummary(s.md, s.slug), path: display(s.dir), md: s.md }
|
|
61
|
+
byReal.set(s.real, { ...skill, providers: [...loadedBy] })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// By name; a name in two folders keeps the folders' order (Claude Code's first).
|
|
66
|
+
const skills = [...byReal.values()].sort((a, b) => a.slug.localeCompare(b.slug))
|
|
67
|
+
return { root: display(folders[0].root), skills }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One skill's SKILL.md, from Claude Code's folder. */
|
|
71
|
+
export async function readSkill({ slug } = {}, root = writeRoot()) {
|
|
72
|
+
const dir = inside(root, slug, WHAT)
|
|
73
|
+
const md = await readSmall(join(dir, 'SKILL.md'), MAX_MD)
|
|
74
|
+
if (md === null) return { error: `No skill in ${display(dir)}` }
|
|
75
|
+
return { slug, ...skillSummary(md, slug), path: display(dir), md }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Write a skill's SKILL.md, whole: replacing the one there, or with `create` only a new one (a skill
|
|
80
|
+
* already in that folder, Kira's or not, is never overwritten by a new one of the same name).
|
|
81
|
+
*/
|
|
82
|
+
export async function writeSkill({ slug, md, create = false } = {}, root = writeRoot()) {
|
|
83
|
+
const dir = inside(root, slug, WHAT)
|
|
84
|
+
const text = String(md ?? '')
|
|
85
|
+
if (!/^---\r?\n/.test(text)) throw new Error('A SKILL.md starts with front matter between --- lines')
|
|
86
|
+
if (Buffer.byteLength(text) > MAX_MD) throw new Error('That SKILL.md is too large')
|
|
87
|
+
await refuseLinks([root, dir], 'edit')
|
|
88
|
+
const file = join(dir, 'SKILL.md')
|
|
89
|
+
if (create && (await exists(file))) throw new Error(`A skill named ${slug} is already on your Mac`)
|
|
90
|
+
await writeWhole(file, text)
|
|
91
|
+
return { slug, ...skillSummary(text, slug), path: display(dir), providers: ['claude'] }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Remove a skill: its SKILL.md goes (so Claude Code stops loading it), and the folder too when
|
|
96
|
+
* nothing else is in it. Anything else a skill folder holds (scripts, references) stays.
|
|
97
|
+
*/
|
|
98
|
+
export async function removeSkill({ slug } = {}, root = writeRoot()) {
|
|
99
|
+
const dir = inside(root, slug, WHAT)
|
|
100
|
+
await refuseLinks([root, dir], 'remove')
|
|
101
|
+
const gone = await unlink(join(dir, 'SKILL.md')).then(
|
|
102
|
+
() => true,
|
|
103
|
+
e => {
|
|
104
|
+
if (e.code === 'ENOENT') return false
|
|
105
|
+
throw e
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
const left = await readdir(dir).catch(() => [])
|
|
109
|
+
if (!left.length) await rmdir(dir).catch(() => {})
|
|
110
|
+
return { slug, removed: gone, path: display(dir), kept: left.length ? left : undefined }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Rename a skill: its folder moved whole (scripts, references and all) from `from` to `to`, then its
|
|
115
|
+
* SKILL.md rewritten there. Both names stay inside the folder, neither may be a link, and a skill
|
|
116
|
+
* already called `to` is never replaced.
|
|
117
|
+
*/
|
|
118
|
+
export async function renameSkill({ from, to, md } = {}, root = writeRoot()) {
|
|
119
|
+
const [was, dir] = [inside(root, from, WHAT), inside(root, to, WHAT)]
|
|
120
|
+
const text = String(md ?? '')
|
|
121
|
+
if (!/^---\r?\n/.test(text)) throw new Error('A SKILL.md starts with front matter between --- lines')
|
|
122
|
+
if (Buffer.byteLength(text) > MAX_MD) throw new Error('That SKILL.md is too large')
|
|
123
|
+
await refuseLinks([root, was, dir], 'rename')
|
|
124
|
+
if (!(await exists(join(was, 'SKILL.md')))) throw new Error(`No skill in ${display(was)}`)
|
|
125
|
+
if (await exists(dir)) throw new Error(`A skill named ${to} is already on your Mac`)
|
|
126
|
+
await rename(was, dir)
|
|
127
|
+
await writeWhole(join(dir, 'SKILL.md'), text)
|
|
128
|
+
return { slug: to, ...skillSummary(text, to), path: display(dir), providers: ['claude'], from: display(was) }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The bridge tools (index.mjs). */
|
|
132
|
+
export const skillTools = {
|
|
133
|
+
list_skills: () => listSkills(),
|
|
134
|
+
read_skill: args => readSkill(args),
|
|
135
|
+
write_skill: args => writeSkill(args),
|
|
136
|
+
remove_skill: args => removeSkill(args),
|
|
137
|
+
rename_skill: args => renameSkill(args),
|
|
138
|
+
}
|
package/src/claude-config.mjs
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
// Claude Code's own skills on this Mac: one SKILL.md each in ~/.claude/skills/<slug>/ (or under
|
|
2
|
-
// $CLAUDE_CONFIG_DIR), which Claude Code and the bridge's Agent SDK jobs load natively. Kira lists
|
|
3
|
-
// all of them (hers and any other), and writes and removes the ones a person makes in Kira. Every
|
|
4
|
-
// path is confined to that folder: a slug is one plain folder name, and a symlinked folder is never
|
|
5
|
-
// written through.
|
|
6
|
-
import { lstat, mkdir, readdir, readFile, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
|
|
7
|
-
import { homedir } from 'node:os'
|
|
8
|
-
import { join, relative } from 'node:path'
|
|
9
|
-
|
|
10
|
-
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/
|
|
11
|
-
/** A SKILL.md past this isn't one: skipped when listing, refused when writing. */
|
|
12
|
-
const MAX_MD = 256 * 1024
|
|
13
|
-
|
|
14
|
-
/** Where Claude Code keeps personal skills. */
|
|
15
|
-
function skillsRoot() {
|
|
16
|
-
return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'skills')
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** The folder for a skill, after checking its slug can't name anything else. */
|
|
20
|
-
function folder(root, slug) {
|
|
21
|
-
const s = String(slug ?? '')
|
|
22
|
-
if (!SLUG.test(s)) throw new Error(`“${s}” isn’t a skill folder name (lowercase letters, digits and dashes)`)
|
|
23
|
-
return join(root, s)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** As people read it: under the home folder with ~. */
|
|
27
|
-
function display(full) {
|
|
28
|
-
const rel = relative(homedir(), full)
|
|
29
|
-
return rel && !rel.startsWith('..') ? `~/${rel}` : full
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** A front-matter value: quoted or plain on its line, or a `|` / `>` block below it. */
|
|
33
|
-
function field(front, key) {
|
|
34
|
-
const lines = front.split('\n')
|
|
35
|
-
const at = lines.findIndex(l => l.startsWith(`${key}:`))
|
|
36
|
-
if (at < 0) return null
|
|
37
|
-
const raw = lines[at].slice(key.length + 1).trim()
|
|
38
|
-
if (/^[|>][+-]?$/.test(raw)) {
|
|
39
|
-
const block = []
|
|
40
|
-
for (const l of lines.slice(at + 1)) {
|
|
41
|
-
if (l.trim() && !/^\s/.test(l)) break
|
|
42
|
-
block.push(l.trim())
|
|
43
|
-
}
|
|
44
|
-
return block.join(raw.startsWith('|') ? '\n' : ' ').trim() || null
|
|
45
|
-
}
|
|
46
|
-
const quoted = /^(["'])(.*)\1$/.exec(raw)
|
|
47
|
-
return (quoted ? quoted[2] : raw.replace(/\s+#.*$/, '')).trim() || null
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** The name and description Claude Code shows, from a SKILL.md (the folder's name when it has none). */
|
|
51
|
-
export function skillSummary(md, slug) {
|
|
52
|
-
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)
|
|
53
|
-
const front = m ? m[1].replace(/\r\n/g, '\n') : ''
|
|
54
|
-
const body = m ? md.slice(m[0].length) : md
|
|
55
|
-
const firstLine = body.split('\n').map(l => l.trim()).find(Boolean) ?? ''
|
|
56
|
-
return { name: field(front, 'name') || slug, description: field(front, 'description') || firstLine }
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function isSymlink(p) {
|
|
60
|
-
return lstat(p).then(
|
|
61
|
-
s => s.isSymbolicLink(),
|
|
62
|
-
() => false,
|
|
63
|
-
)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** A folder that links elsewhere is changed where it really is, never through the link. */
|
|
67
|
-
async function refuseLinks(root, dir, verb) {
|
|
68
|
-
if ((await isSymlink(root)) || (await isSymlink(dir))) {
|
|
69
|
-
throw new Error(`${display(dir)} is a link to somewhere else; ${verb} it there`)
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Every skill in the folder: its slug, name, description, where it is, and its SKILL.md. */
|
|
74
|
-
export async function listSkills(root = skillsRoot()) {
|
|
75
|
-
const entries = await readdir(root, { withFileTypes: true }).catch(e => {
|
|
76
|
-
if (e.code === 'ENOENT') return []
|
|
77
|
-
throw e
|
|
78
|
-
})
|
|
79
|
-
const skills = []
|
|
80
|
-
for (const e of entries) {
|
|
81
|
-
if (!SLUG.test(e.name) || !(e.isDirectory() || e.isSymbolicLink())) continue
|
|
82
|
-
const file = join(root, e.name, 'SKILL.md')
|
|
83
|
-
const st = await stat(file).catch(() => null)
|
|
84
|
-
if (!st?.isFile() || st.size > MAX_MD) continue
|
|
85
|
-
const md = await readFile(file, 'utf8').catch(() => null)
|
|
86
|
-
if (md === null) continue
|
|
87
|
-
skills.push({ slug: e.name, ...skillSummary(md, e.name), path: display(join(root, e.name)), md })
|
|
88
|
-
}
|
|
89
|
-
skills.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
90
|
-
return { root: display(root), skills }
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** One skill's SKILL.md. */
|
|
94
|
-
export async function readSkill({ slug } = {}, root = skillsRoot()) {
|
|
95
|
-
const dir = folder(root, slug)
|
|
96
|
-
const md = await readFile(join(dir, 'SKILL.md'), 'utf8').catch(() => null)
|
|
97
|
-
if (md === null) return { error: `No skill in ${display(dir)}` }
|
|
98
|
-
return { slug, ...skillSummary(md, slug), path: display(dir), md }
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Write a skill's SKILL.md, whole: replacing the one there, or with `create` only a new one (a skill
|
|
103
|
-
* already in that folder, Kira's or not, is never overwritten by a new one of the same name).
|
|
104
|
-
*/
|
|
105
|
-
export async function writeSkill({ slug, md, create = false } = {}, root = skillsRoot()) {
|
|
106
|
-
const dir = folder(root, slug)
|
|
107
|
-
const text = String(md ?? '')
|
|
108
|
-
if (!/^---\r?\n/.test(text)) throw new Error('A SKILL.md starts with front matter between --- lines')
|
|
109
|
-
if (Buffer.byteLength(text) > MAX_MD) throw new Error('That SKILL.md is too large')
|
|
110
|
-
await refuseLinks(root, dir, 'edit')
|
|
111
|
-
const file = join(dir, 'SKILL.md')
|
|
112
|
-
if (create && (await stat(file).catch(() => null))) throw new Error(`A skill named ${slug} is already on your Mac`)
|
|
113
|
-
await mkdir(dir, { recursive: true })
|
|
114
|
-
const tmp = join(dir, `.SKILL.md.${process.pid}.tmp`)
|
|
115
|
-
await writeFile(tmp, text, 'utf8')
|
|
116
|
-
await rename(tmp, file)
|
|
117
|
-
return { slug, ...skillSummary(text, slug), path: display(dir) }
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Remove a skill: its SKILL.md goes (so Claude Code stops loading it), and the folder too when
|
|
122
|
-
* nothing else is in it. Anything else a skill folder holds (scripts, references) stays.
|
|
123
|
-
*/
|
|
124
|
-
export async function removeSkill({ slug } = {}, root = skillsRoot()) {
|
|
125
|
-
const dir = folder(root, slug)
|
|
126
|
-
await refuseLinks(root, dir, 'remove')
|
|
127
|
-
const gone = await unlink(join(dir, 'SKILL.md')).then(
|
|
128
|
-
() => true,
|
|
129
|
-
e => {
|
|
130
|
-
if (e.code === 'ENOENT') return false
|
|
131
|
-
throw e
|
|
132
|
-
},
|
|
133
|
-
)
|
|
134
|
-
const left = await readdir(dir).catch(() => [])
|
|
135
|
-
if (!left.length) await rmdir(dir).catch(() => {})
|
|
136
|
-
return { slug, removed: gone, path: display(dir), kept: left.length ? left : undefined }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** The bridge tools (index.mjs). */
|
|
140
|
-
export const skillTools = {
|
|
141
|
-
async list_skills() {
|
|
142
|
-
return listSkills()
|
|
143
|
-
},
|
|
144
|
-
async read_skill(args) {
|
|
145
|
-
return readSkill(args)
|
|
146
|
-
},
|
|
147
|
-
async write_skill(args) {
|
|
148
|
-
return writeSkill(args)
|
|
149
|
-
},
|
|
150
|
-
async remove_skill(args) {
|
|
151
|
-
return removeSkill(args)
|
|
152
|
-
},
|
|
153
|
-
}
|