kira-glasses-bridge 2.22.0 → 2.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.22.0",
3
+ "version": "2.23.0",
4
4
  "description": "Connects your Mac to Kira, the assistant for Even G2 glasses: Claude Code sessions, files, folders and apps, by voice.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,153 @@
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
+ }
package/src/index.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  import { createRequire } from 'node:module'
5
5
  import { AGENTS } from './agents/index.mjs'
6
6
  import { claudeTools } from './agents/claude-local.mjs'
7
+ import { skillTools } from './claude-config.mjs'
7
8
  import { connect, disconnect, sendIfLive } from './connection.mjs'
8
9
  import { fileTools } from './file-tools.mjs'
9
10
  import { gitTools } from './git-tools.mjs'
@@ -22,6 +23,7 @@ const tools = {
22
23
  ...jobTools,
23
24
  ...gitTools,
24
25
  ...claudeTools,
26
+ ...skillTools,
25
27
  }
26
28
 
27
29
  /** What this bridge build implements, told to the API after its hello. */