kira-glasses-bridge 1.0.0 → 1.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/package.json +18 -5
- package/src/claude-run.mjs +16 -7
- package/src/files.mjs +9 -2
- package/src/git.mjs +136 -0
- package/src/index.mjs +51 -11
package/package.json
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kira-glasses-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
"bin": {
|
|
7
|
+
"kira-bridge": "src/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
9
16
|
"scripts": {
|
|
10
17
|
"start": "node src/cli.mjs run"
|
|
11
18
|
},
|
|
12
19
|
"dependencies": {
|
|
13
20
|
"ws": "^8.18.0"
|
|
14
21
|
},
|
|
15
|
-
"keywords": [
|
|
22
|
+
"keywords": [
|
|
23
|
+
"even-realities",
|
|
24
|
+
"g2",
|
|
25
|
+
"smart-glasses",
|
|
26
|
+
"claude-code",
|
|
27
|
+
"kira"
|
|
28
|
+
],
|
|
16
29
|
"license": "MIT",
|
|
17
30
|
"homepage": "https://hackchild.xyz/kira"
|
|
18
31
|
}
|
package/src/claude-run.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { spawn } from 'node:child_process'
|
|
3
3
|
import { existsSync } from 'node:fs'
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
|
-
import { join } from 'node:path'
|
|
5
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Find the claude binary. The bridge may be started from a shell whose PATH
|
|
@@ -23,32 +23,41 @@ export function findClaude() {
|
|
|
23
23
|
|
|
24
24
|
const MAX_MS = 15 * 60 * 1000
|
|
25
25
|
|
|
26
|
+
/** Kira's paths are relative to home unless absolute, like the file tools. */
|
|
27
|
+
export function resolveCwd(cwd) {
|
|
28
|
+
if (!cwd) return homedir()
|
|
29
|
+
const s = String(cwd).replace(/^~(?=\/|$)/, homedir())
|
|
30
|
+
return isAbsolute(s) ? resolve(s) : resolve(homedir(), s)
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
export function runClaude({ prompt, sessionId, cwd }) {
|
|
27
|
-
return new Promise(
|
|
34
|
+
return new Promise(done => {
|
|
35
|
+
const dir = resolveCwd(cwd)
|
|
36
|
+
if (!existsSync(dir)) return done({ error: `folder not found: ${dir}` })
|
|
28
37
|
const args = ['-p', '--output-format', 'json', '--permission-mode', 'acceptEdits']
|
|
29
38
|
if (sessionId) args.push('--resume', sessionId)
|
|
30
39
|
args.push(prompt)
|
|
31
|
-
const child = spawn(findClaude(), args, { cwd:
|
|
40
|
+
const child = spawn(findClaude(), args, { cwd: dir, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
32
41
|
let out = ''
|
|
33
42
|
let err = ''
|
|
34
43
|
const timer = setTimeout(() => {
|
|
35
44
|
child.kill('SIGTERM')
|
|
36
|
-
|
|
45
|
+
done({ error: `Claude did not finish within ${MAX_MS / 60000} minutes` })
|
|
37
46
|
}, MAX_MS)
|
|
38
47
|
child.stdout.on('data', d => (out += d))
|
|
39
48
|
child.stderr.on('data', d => (err += d))
|
|
40
49
|
child.on('error', e => {
|
|
41
50
|
clearTimeout(timer)
|
|
42
51
|
const hint = e.code === 'ENOENT' ? 'Claude Code is not installed or not on the bridge\'s PATH. Install it, or set KIRA_CLAUDE_BIN=/path/to/claude when starting the bridge.' : e.message
|
|
43
|
-
|
|
52
|
+
done({ error: `could not start claude: ${hint}` })
|
|
44
53
|
})
|
|
45
54
|
child.on('close', code => {
|
|
46
55
|
clearTimeout(timer)
|
|
47
56
|
try {
|
|
48
57
|
const j = JSON.parse(out)
|
|
49
|
-
|
|
58
|
+
done({ session_id: j.session_id, result: j.result, is_error: !!j.is_error, cost_usd: j.total_cost_usd, duration_ms: j.duration_ms, num_turns: j.num_turns })
|
|
50
59
|
} catch {
|
|
51
|
-
|
|
60
|
+
done({ error: `claude exited with code ${code}: ${(err || out).slice(0, 500)}` })
|
|
52
61
|
}
|
|
53
62
|
})
|
|
54
63
|
})
|
package/src/files.mjs
CHANGED
|
@@ -93,7 +93,7 @@ export async function createFolder({ path }) {
|
|
|
93
93
|
* Refuses to overwrite unless `overwrite` is true, so a slip of the tongue
|
|
94
94
|
* cannot clobber something.
|
|
95
95
|
*/
|
|
96
|
-
export async function writeUserFile({ path, content, overwrite }) {
|
|
96
|
+
export async function writeUserFile({ path, content, overwrite, append }) {
|
|
97
97
|
const full = resolveUserPath(String(path ?? ''))
|
|
98
98
|
if (full === HOME) throw new Error('give the file a name')
|
|
99
99
|
const { mkdir, stat, writeFile } = await import('node:fs/promises')
|
|
@@ -101,12 +101,19 @@ export async function writeUserFile({ path, content, overwrite }) {
|
|
|
101
101
|
try {
|
|
102
102
|
const st = await stat(full)
|
|
103
103
|
if (st.isDirectory()) throw new Error(`${display(full)} is a folder`)
|
|
104
|
-
if (!overwrite) return { path: display(full), written: false, error: `${display(full)} already exists.
|
|
104
|
+
if (!overwrite && !append) return { path: display(full), written: false, error: `${display(full)} already exists. Use append to add to it, or overwrite to replace it.` }
|
|
105
105
|
} catch (err) {
|
|
106
106
|
if (err.code !== 'ENOENT') throw err
|
|
107
107
|
}
|
|
108
108
|
await mkdir(dirname(full), { recursive: true })
|
|
109
109
|
const text = String(content ?? '')
|
|
110
|
+
if (append) {
|
|
111
|
+
const { appendFile, readFile } = await import('node:fs/promises')
|
|
112
|
+
const existing = await readFile(full, 'utf8').catch(() => '')
|
|
113
|
+
const sep = existing && !existing.endsWith('\n') ? '\n' : ''
|
|
114
|
+
await appendFile(full, sep + text + (text.endsWith('\n') ? '' : '\n'), 'utf8')
|
|
115
|
+
return { path: display(full), written: true, appended: true, bytes: Buffer.byteLength(text, 'utf8') }
|
|
116
|
+
}
|
|
110
117
|
await writeFile(full, text, 'utf8')
|
|
111
118
|
return { path: display(full), written: true, bytes: Buffer.byteLength(text, 'utf8') }
|
|
112
119
|
}
|
package/src/git.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// What Claude changed on disk, from git: a snapshot before a job and the
|
|
2
|
+
// difference after it. Everything is read-only; the working tree is never
|
|
3
|
+
// touched. Missing git or a folder outside a repo simply yields null.
|
|
4
|
+
import { execFile } from 'node:child_process'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { relative } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const DIFF_CAP = 400 * 1024 // bytes of unified diff kept per change
|
|
9
|
+
const UNTRACKED_CAP = 30 // new files whose content is included
|
|
10
|
+
const COMMIT_CAP = 20
|
|
11
|
+
|
|
12
|
+
function git(cwd, args, { ok = [0] } = {}) {
|
|
13
|
+
return new Promise(resolve => {
|
|
14
|
+
execFile('git', args, { cwd, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } }, (err, stdout) => {
|
|
15
|
+
if (err && !ok.includes(err.code)) return resolve(null)
|
|
16
|
+
resolve(String(stdout ?? ''))
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function display(full) {
|
|
22
|
+
const rel = relative(homedir(), full)
|
|
23
|
+
return rel && !rel.startsWith('..') ? `~/${rel}` : full
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Repo identity plus the head and dirty files right now; null when not a git repo. */
|
|
27
|
+
export async function snapshot(dir) {
|
|
28
|
+
const root = (await git(dir, ['rev-parse', '--show-toplevel']))?.trim()
|
|
29
|
+
if (!root) return null
|
|
30
|
+
const [head, branch, remote, status] = await Promise.all([
|
|
31
|
+
git(root, ['rev-parse', 'HEAD']),
|
|
32
|
+
git(root, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
33
|
+
git(root, ['remote', 'get-url', 'origin']),
|
|
34
|
+
git(root, ['status', '--porcelain=v1', '--untracked-files=all']),
|
|
35
|
+
])
|
|
36
|
+
return {
|
|
37
|
+
root,
|
|
38
|
+
cwd: display(root),
|
|
39
|
+
head: head?.trim() || null, // null on an empty repo
|
|
40
|
+
branch: branch?.trim() || null,
|
|
41
|
+
repo: remoteToRepo(remote?.trim()),
|
|
42
|
+
dirty: parseStatus(status ?? ''),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function remoteToRepo(url) {
|
|
47
|
+
if (!url) return null
|
|
48
|
+
const m = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/.exec(url)
|
|
49
|
+
return m ? m[1] : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseStatus(out) {
|
|
53
|
+
return out
|
|
54
|
+
.split('\n')
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.map(l => ({ status: l.slice(0, 2).trim() || 'M', path: l.slice(3).replace(/^"|"$/g, '') }))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Everything different between the pre-job head and the working tree now:
|
|
61
|
+
* commits made since, per-file stats, and one unified diff (committed and
|
|
62
|
+
* uncommitted alike, new files included). Files that were already dirty
|
|
63
|
+
* before the job are flagged so a reader can discount them.
|
|
64
|
+
*/
|
|
65
|
+
export async function changesSince(before) {
|
|
66
|
+
const { root } = before
|
|
67
|
+
const base = before.head
|
|
68
|
+
const dirtyBefore = new Set(before.dirty.map(d => d.path))
|
|
69
|
+
const [headNow, numstat, nameStatus, diff, untrackedOut] = await Promise.all([
|
|
70
|
+
git(root, ['rev-parse', 'HEAD']),
|
|
71
|
+
base ? git(root, ['diff', '--numstat', '-M', base]) : Promise.resolve(''),
|
|
72
|
+
base ? git(root, ['diff', '--name-status', '-M', base]) : Promise.resolve(''),
|
|
73
|
+
base ? git(root, ['diff', '-M', '--no-color', base]) : Promise.resolve(''),
|
|
74
|
+
git(root, ['ls-files', '--others', '--exclude-standard']),
|
|
75
|
+
])
|
|
76
|
+
const files = new Map()
|
|
77
|
+
for (const line of (numstat ?? '').split('\n').filter(Boolean)) {
|
|
78
|
+
const [a, d, ...rest] = line.split('\t')
|
|
79
|
+
const path = rest.join('\t').replace(/^.*\{(.*) => (.*)\}.*$/, (m, o, n) => m.replace(`{${o} => ${n}}`, n)).replace(/^(.*) => (.*)$/, '$2')
|
|
80
|
+
files.set(path, { path, adds: a === '-' ? 0 : Number(a), dels: d === '-' ? 0 : Number(d), binary: a === '-', status: 'M' })
|
|
81
|
+
}
|
|
82
|
+
for (const line of (nameStatus ?? '').split('\n').filter(Boolean)) {
|
|
83
|
+
const [st, ...rest] = line.split('\t')
|
|
84
|
+
const path = rest[rest.length - 1]
|
|
85
|
+
const f = files.get(path)
|
|
86
|
+
if (f) f.status = st[0]
|
|
87
|
+
}
|
|
88
|
+
let text = diff ?? ''
|
|
89
|
+
const untracked = (untrackedOut ?? '').split('\n').filter(Boolean)
|
|
90
|
+
let shown = 0
|
|
91
|
+
for (const path of untracked) {
|
|
92
|
+
const isNew = !dirtyBefore.has(path)
|
|
93
|
+
const content = shown < UNTRACKED_CAP ? await git(root, ['diff', '--no-index', '--no-color', '--', '/dev/null', path], { ok: [0, 1] }) : null
|
|
94
|
+
let adds = 0
|
|
95
|
+
if (content) {
|
|
96
|
+
adds = (content.match(/^\+(?!\+\+)/gm) ?? []).length
|
|
97
|
+
if (text.length < DIFF_CAP) text += content
|
|
98
|
+
shown++
|
|
99
|
+
}
|
|
100
|
+
files.set(path, { path, adds, dels: 0, binary: content === null && shown < UNTRACKED_CAP, status: 'A', untracked: true, preexisting: !isNew })
|
|
101
|
+
}
|
|
102
|
+
for (const f of files.values()) if (dirtyBefore.has(f.path)) f.preexisting = true
|
|
103
|
+
const truncated = text.length > DIFF_CAP
|
|
104
|
+
if (truncated) text = text.slice(0, DIFF_CAP) + '\n… diff truncated …\n'
|
|
105
|
+
|
|
106
|
+
const commits = []
|
|
107
|
+
if (base && headNow && headNow.trim() !== base) {
|
|
108
|
+
const log = await git(root, ['log', '--format=%H%x1f%s%x1f%cI', `${base}..HEAD`, `--max-count=${COMMIT_CAP}`])
|
|
109
|
+
for (const line of (log ?? '').split('\n').filter(Boolean)) {
|
|
110
|
+
const [sha, title, at] = line.split('\x1f')
|
|
111
|
+
const ns = await git(root, ['show', '--numstat', '--format=', sha])
|
|
112
|
+
let adds = 0, dels = 0, n = 0
|
|
113
|
+
for (const l of (ns ?? '').split('\n').filter(Boolean)) {
|
|
114
|
+
const [a, d] = l.split('\t')
|
|
115
|
+
if (a !== '-') adds += Number(a)
|
|
116
|
+
if (d !== '-') dels += Number(d)
|
|
117
|
+
n++
|
|
118
|
+
}
|
|
119
|
+
commits.push({ sha, short: sha.slice(0, 7), title, at, files: n, adds, dels })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const list = [...files.values()]
|
|
123
|
+
if (list.length === 0 && commits.length === 0) return null
|
|
124
|
+
return {
|
|
125
|
+
cwd: before.cwd,
|
|
126
|
+
repo: before.repo,
|
|
127
|
+
branch: (await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']))?.trim() || before.branch,
|
|
128
|
+
head_before: base,
|
|
129
|
+
head_after: headNow?.trim() || null,
|
|
130
|
+
commits,
|
|
131
|
+
files: list,
|
|
132
|
+
stats: { files: list.length, adds: list.reduce((s, f) => s + f.adds, 0), dels: list.reduce((s, f) => s + f.dels, 0) },
|
|
133
|
+
diff: text,
|
|
134
|
+
truncated,
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// machine on the user's behalf. Env: KIRA_BRIDGE_URL, KIRA_BRIDGE_TOKEN.
|
|
4
4
|
import WebSocket from 'ws'
|
|
5
5
|
import { listProjects, listSessions, readSession } from './claude-sessions.mjs'
|
|
6
|
-
import {
|
|
6
|
+
import { snapshot, changesSince, display } from './git.mjs'
|
|
7
|
+
import { runClaude, findClaude, resolveCwd } from './claude-run.mjs'
|
|
7
8
|
import { openApp, installedApps } from './apps.mjs'
|
|
8
|
-
import { listFiles, readUserFile, createFolder, writeUserFile, trashPath } from './files.mjs'
|
|
9
|
+
import { listFiles, readUserFile, createFolder, writeUserFile, trashPath, resolveUserPath } from './files.mjs'
|
|
9
10
|
|
|
10
11
|
import { createRequire } from 'node:module'
|
|
11
12
|
const { version: BRIDGE_VERSION } = createRequire(import.meta.url)('../package.json')
|
|
@@ -34,16 +35,16 @@ const tools = {
|
|
|
34
35
|
return readUserFile(args)
|
|
35
36
|
},
|
|
36
37
|
async create_folder(args) {
|
|
37
|
-
return createFolder(args)
|
|
38
|
+
return withChange(args.path, () => createFolder(args))
|
|
38
39
|
},
|
|
39
40
|
async write_file(args) {
|
|
40
|
-
return writeUserFile(args)
|
|
41
|
+
return withChange(args.path, () => writeUserFile(args))
|
|
41
42
|
},
|
|
42
43
|
async open_app(args) {
|
|
43
44
|
return openApp(args)
|
|
44
45
|
},
|
|
45
46
|
async delete_path(args) {
|
|
46
|
-
return trashPath(args)
|
|
47
|
+
return withChange(args.path, () => trashPath(args))
|
|
47
48
|
},
|
|
48
49
|
async list_apps() {
|
|
49
50
|
return { apps: await installedApps() }
|
|
@@ -60,17 +61,56 @@ const tools = {
|
|
|
60
61
|
// Acknowledge now, report the result as a job when Claude finishes.
|
|
61
62
|
async ask_claude(args, ws) {
|
|
62
63
|
const jobId = String(args.job_id ?? '')
|
|
63
|
-
const prompt = String(args.prompt ?? '')
|
|
64
|
+
const prompt = String(args.prompt ?? args.message ?? args.text ?? args.instruction ?? '').trim()
|
|
64
65
|
if (!prompt) return { error: 'prompt is required' }
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
// A resumed session runs in its own folder even when the caller did not say which.
|
|
67
|
+
let cwd = args.cwd
|
|
68
|
+
if (!cwd && args.session_id) {
|
|
69
|
+
const s = await readSession(String(args.session_id), 1)
|
|
70
|
+
if (s?.cwd) cwd = s.cwd
|
|
71
|
+
}
|
|
72
|
+
const dir = resolveCwd(cwd)
|
|
73
|
+
log(`ask_claude job=${jobId.slice(0, 8)} session=${args.session_id ?? 'new'} in ${display(dir)}: ${prompt.slice(0, 80)}`)
|
|
74
|
+
// Git before and after, so the API can show exactly what this job changed.
|
|
75
|
+
const before = await snapshot(dir).catch(() => null)
|
|
76
|
+
void runClaude({ prompt, sessionId: args.session_id, cwd: dir }).then(async result => {
|
|
77
|
+
let change = null
|
|
78
|
+
if (before) change = await changesSince(before).catch(err => (log(`git after job failed: ${err.message}`), null))
|
|
79
|
+
log(`job ${jobId.slice(0, 8)} ${result.error ? 'error' : 'done'}${change ? ` · ${change.stats.files} files +${change.stats.adds} -${change.stats.dels}, ${change.commits.length} commits` : ''}`)
|
|
80
|
+
send(ws, { type: 'job', jobId, status: result.error ? 'error' : 'done', result: { ...result, cwd: display(dir), change }, error: result.error })
|
|
69
81
|
})
|
|
70
|
-
return { started: true }
|
|
82
|
+
return { started: true, cwd: display(dir), git: before ? { repo: before.repo, branch: before.branch, head: before.head?.slice(0, 7) ?? null } : null }
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/** Read-only git facts for a folder: repo, branch, head, dirty files. */
|
|
86
|
+
async git_status(args) {
|
|
87
|
+
const s = await snapshot(resolveCwd(args.path ?? args.cwd))
|
|
88
|
+
if (!s) return { error: 'not a git repository' }
|
|
89
|
+
return { cwd: s.cwd, repo: s.repo, branch: s.branch, head: s.head?.slice(0, 7) ?? null, dirty: s.dirty.slice(0, 50), dirty_count: s.dirty.length }
|
|
71
90
|
},
|
|
72
91
|
}
|
|
73
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Run a file-changing tool with a git snapshot around it, so the API can show
|
|
95
|
+
* the diff the same way it does for Claude jobs. Outside a repo: no change.
|
|
96
|
+
*/
|
|
97
|
+
async function withChange(pathArg, fn) {
|
|
98
|
+
let before = null
|
|
99
|
+
try {
|
|
100
|
+
const full = resolveUserPath(String(pathArg ?? ''))
|
|
101
|
+
const { dirname } = await import('node:path')
|
|
102
|
+
const { stat } = await import('node:fs/promises')
|
|
103
|
+
const isDir = await stat(full).then(s => s.isDirectory()).catch(() => false)
|
|
104
|
+
before = await snapshot(isDir ? full : dirname(full))
|
|
105
|
+
} catch {
|
|
106
|
+
before = null
|
|
107
|
+
}
|
|
108
|
+
const result = await fn()
|
|
109
|
+
if (!before || (result && result.error)) return result
|
|
110
|
+
const change = await changesSince(before).catch(() => null)
|
|
111
|
+
return change ? { ...result, change } : result
|
|
112
|
+
}
|
|
113
|
+
|
|
74
114
|
let backoff = 1000
|
|
75
115
|
function connect() {
|
|
76
116
|
const ws = new WebSocket(`${URL_}?token=${encodeURIComponent(TOKEN)}`)
|