kira-glasses-bridge 1.0.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 ADDED
@@ -0,0 +1,34 @@
1
+ # kira-glasses-bridge
2
+
3
+ Connects your Mac to **Kira**, the assistant for Even Realities G2 glasses. With the bridge running, Kira can list and talk to your Claude Code sessions, browse and open files and folders, create notes and READMEs, launch apps ("open this in VS Code"), and move things to the Trash, all confined to your home folder.
4
+
5
+ ## Setup
6
+
7
+ 1. In the Kira portal, open **Connections → Claude Code → Set up** and copy the command it shows.
8
+ 2. Paste it into Terminal:
9
+
10
+ ```bash
11
+ npx kira-glasses-bridge --token <your token>
12
+ ```
13
+
14
+ That saves the token to `~/.kira/bridge.json` and installs a background service that starts at login and reconnects on its own. The portal card flips to **Online**.
15
+
16
+ Needs Node.js 20 or newer, which Claude Code already requires.
17
+
18
+ ## Commands
19
+
20
+ ```
21
+ kira-bridge status is it installed and running?
22
+ kira-bridge logs follow the log (~/.kira/bridge.log)
23
+ kira-bridge run run in the foreground instead of as a service
24
+ kira-bridge uninstall remove the background service
25
+ ```
26
+
27
+ Add `--no-service` to the setup command to skip the service and just run in the foreground.
28
+
29
+ ## What it can and cannot do
30
+
31
+ - Connects **outbound only**, over a WebSocket to the Kira API. Nothing listens on your Mac.
32
+ - File access is confined to your home folder. Deleting moves to the Trash.
33
+ - Runs Claude Code with `claude -p --resume <session>`; edits are auto-accepted, no shell commands.
34
+ - Remove the bridge from the portal at any time; the token stops working immediately.
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "kira-glasses-bridge",
3
+ "version": "1.0.0",
4
+ "description": "Connects your Mac to Kira, the assistant for Even G2 glasses: Claude Code sessions, files, folders and apps, by voice.",
5
+ "type": "module",
6
+ "bin": { "kira-bridge": "./src/cli.mjs" },
7
+ "files": ["src", "README.md"],
8
+ "engines": { "node": ">=20" },
9
+ "scripts": {
10
+ "start": "node src/cli.mjs run"
11
+ },
12
+ "dependencies": {
13
+ "ws": "^8.18.0"
14
+ },
15
+ "keywords": ["even-realities", "g2", "smart-glasses", "claude-code", "kira"],
16
+ "license": "MIT",
17
+ "homepage": "https://hackchild.xyz/kira"
18
+ }
package/src/apps.mjs ADDED
@@ -0,0 +1,124 @@
1
+ // Launch applications on this Mac. Uses macOS `open`, so nothing runs in a
2
+ // shell and no arguments are interpreted: `open -a <App> [path]`.
3
+ //
4
+ // Apps are matched by name against what is installed under /Applications,
5
+ // ~/Applications and /System/Applications. Everyday aliases ("vscode",
6
+ // "code", "chrome", "terminal") map onto the real bundle names. Paths handed
7
+ // to an app are confined to the home folder like the file tools.
8
+ import { execFile } from 'node:child_process'
9
+ import { readdir } from 'node:fs/promises'
10
+ import { homedir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { promisify } from 'node:util'
13
+ import { resolveUserPath } from './files.mjs'
14
+
15
+ const run = promisify(execFile)
16
+ const APP_DIRS = ['/Applications', join(homedir(), 'Applications'), '/System/Applications', '/System/Applications/Utilities']
17
+
18
+ const ALIASES = {
19
+ vscode: 'Visual Studio Code',
20
+ 'vs code': 'Visual Studio Code',
21
+ code: 'Visual Studio Code',
22
+ 'visual studio': 'Visual Studio Code',
23
+ chrome: 'Google Chrome',
24
+ browser: 'Google Chrome',
25
+ terminal: 'Terminal',
26
+ iterm: 'iTerm',
27
+ finder: 'Finder',
28
+ notes: 'Notes',
29
+ music: 'Music',
30
+ mail: 'Mail',
31
+ messages: 'Messages',
32
+ calendar: 'Calendar',
33
+ photos: 'Photos',
34
+ preview: 'Preview',
35
+ textedit: 'TextEdit',
36
+ xcode: 'Xcode',
37
+ 'android studio': 'Android Studio',
38
+ postman: 'Postman',
39
+ spotify: 'Spotify',
40
+ slack: 'Slack',
41
+ discord: 'Discord',
42
+ telegram: 'Telegram',
43
+ whatsapp: 'WhatsApp',
44
+ docker: 'Docker',
45
+ claude: 'Claude',
46
+ chatgpt: 'ChatGPT Classic',
47
+ '1password': '1Password',
48
+ github: 'GitHub Desktop',
49
+ 'github desktop': 'GitHub Desktop',
50
+ pycharm: 'PyCharm',
51
+ unity: 'Unity Hub',
52
+ safari: 'Safari',
53
+ }
54
+
55
+ let cache = null
56
+ export async function installedApps() {
57
+ if (cache && Date.now() - cache.at < 60_000) return cache.apps
58
+ const names = new Set()
59
+ for (const dir of APP_DIRS) {
60
+ try {
61
+ for (const f of await readdir(dir)) if (f.endsWith('.app')) names.add(f.slice(0, -4))
62
+ } catch {
63
+ /* dir may not exist */
64
+ }
65
+ }
66
+ const apps = [...names].sort((a, b) => a.localeCompare(b))
67
+ cache = { at: Date.now(), apps }
68
+ return apps
69
+ }
70
+
71
+ function norm(s) {
72
+ return String(s ?? '').toLowerCase().replace(/[^a-z0-9+ ]/g, '').replace(/\s+/g, ' ').trim()
73
+ }
74
+
75
+ /** Resolve a spoken app name to an installed bundle name, or null. */
76
+ export async function resolveApp(name) {
77
+ const apps = await installedApps()
78
+ const q = norm(name)
79
+ if (!q) return null
80
+ const alias = ALIASES[q]
81
+ if (alias && apps.includes(alias)) return alias
82
+ const exact = apps.find(a => norm(a) === q)
83
+ if (exact) return exact
84
+ const starts = apps.filter(a => norm(a).startsWith(q))
85
+ if (starts.length === 1) return starts[0]
86
+ const contains = apps.filter(a => norm(a).includes(q))
87
+ if (contains.length === 1) return contains[0]
88
+ if (alias) return alias // not installed; caller reports it
89
+ return null
90
+ }
91
+
92
+ /**
93
+ * Open an app, optionally with a file or folder (or a URL for browsers).
94
+ * With no app, open the path in its default application.
95
+ */
96
+ export async function openApp({ app, path, url }) {
97
+ const args = []
98
+ let appName = null
99
+ if (app) {
100
+ appName = await resolveApp(app)
101
+ if (!appName) {
102
+ const apps = await installedApps()
103
+ const near = apps.filter(a => norm(a).includes(norm(app).split(' ')[0] ?? '')).slice(0, 5)
104
+ return { error: `I could not find an app called "${app}" on this Mac.${near.length ? ` Did you mean: ${near.join(', ')}?` : ''}` }
105
+ }
106
+ args.push('-a', appName)
107
+ }
108
+ let target = null
109
+ if (url) {
110
+ if (!/^https?:\/\//i.test(String(url))) return { error: 'only http(s) URLs can be opened' }
111
+ target = String(url)
112
+ } else if (path) {
113
+ target = resolveUserPath(String(path)) // throws if outside home
114
+ }
115
+ if (!appName && !target) return { error: 'say which app to open, or which file or folder' }
116
+ if (target) args.push(target)
117
+ try {
118
+ await run('/usr/bin/open', args, { timeout: 15_000 })
119
+ } catch (err) {
120
+ const msg = String(err.stderr || err.message || '').trim()
121
+ return { error: `open failed: ${msg || 'unknown error'}` }
122
+ }
123
+ return { opened: true, app: appName, target: target ? (url ? target : target.replace(homedir(), '~')) : null }
124
+ }
@@ -0,0 +1,55 @@
1
+ // Runs Claude Code headless: `claude -p --resume <id> --output-format json`.
2
+ import { spawn } from 'node:child_process'
3
+ import { existsSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ /**
8
+ * Find the claude binary. The bridge may be started from a shell whose PATH
9
+ * lacks ~/.local/bin (launchd, a minimal terminal, a different shell), so
10
+ * check the usual install locations before giving up.
11
+ */
12
+ export function findClaude() {
13
+ if (process.env.KIRA_CLAUDE_BIN && existsSync(process.env.KIRA_CLAUDE_BIN)) return process.env.KIRA_CLAUDE_BIN
14
+ const candidates = [
15
+ join(homedir(), '.local', 'bin', 'claude'),
16
+ join(homedir(), '.claude', 'local', 'claude'),
17
+ '/opt/homebrew/bin/claude',
18
+ '/usr/local/bin/claude',
19
+ ]
20
+ for (const c of candidates) if (existsSync(c)) return c
21
+ return 'claude' // rely on PATH
22
+ }
23
+
24
+ const MAX_MS = 15 * 60 * 1000
25
+
26
+ export function runClaude({ prompt, sessionId, cwd }) {
27
+ return new Promise(resolve => {
28
+ const args = ['-p', '--output-format', 'json', '--permission-mode', 'acceptEdits']
29
+ if (sessionId) args.push('--resume', sessionId)
30
+ args.push(prompt)
31
+ const child = spawn(findClaude(), args, { cwd: cwd || process.env.HOME, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] })
32
+ let out = ''
33
+ let err = ''
34
+ const timer = setTimeout(() => {
35
+ child.kill('SIGTERM')
36
+ resolve({ error: `Claude did not finish within ${MAX_MS / 60000} minutes` })
37
+ }, MAX_MS)
38
+ child.stdout.on('data', d => (out += d))
39
+ child.stderr.on('data', d => (err += d))
40
+ child.on('error', e => {
41
+ clearTimeout(timer)
42
+ 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
+ resolve({ error: `could not start claude: ${hint}` })
44
+ })
45
+ child.on('close', code => {
46
+ clearTimeout(timer)
47
+ try {
48
+ const j = JSON.parse(out)
49
+ resolve({ 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
+ } catch {
51
+ resolve({ error: `claude exited with code ${code}: ${(err || out).slice(0, 500)}` })
52
+ }
53
+ })
54
+ })
55
+ }
@@ -0,0 +1,149 @@
1
+ // Reads Claude Code's own session transcripts under ~/.claude/projects.
2
+ // Each session is a .jsonl file; the entries we care about are user and
3
+ // assistant messages, the ai-title entries, and cwd.
4
+ import { readdir, readFile, stat } from 'node:fs/promises'
5
+ import { homedir } from 'node:os'
6
+ import { basename, join } from 'node:path'
7
+
8
+ const PROJECTS = join(homedir(), '.claude', 'projects')
9
+ const ACTIVE_WINDOW_MS = 3 * 60 * 1000
10
+
11
+ async function sessionFiles() {
12
+ const out = []
13
+ let dirs = []
14
+ try {
15
+ dirs = await readdir(PROJECTS)
16
+ } catch {
17
+ return out
18
+ }
19
+ for (const d of dirs) {
20
+ let files = []
21
+ try {
22
+ files = await readdir(join(PROJECTS, d))
23
+ } catch {
24
+ continue
25
+ }
26
+ for (const f of files) {
27
+ if (!f.endsWith('.jsonl')) continue
28
+ const path = join(PROJECTS, d, f)
29
+ try {
30
+ const st = await stat(path)
31
+ if (st.size > 0) out.push({ path, mtime: st.mtimeMs, size: st.size })
32
+ } catch {
33
+ /* skip */
34
+ }
35
+ }
36
+ }
37
+ return out.sort((a, b) => b.mtime - a.mtime)
38
+ }
39
+
40
+ function textOf(message) {
41
+ const c = message?.content
42
+ if (typeof c === 'string') return c
43
+ if (Array.isArray(c)) return c.filter(b => b?.type === 'text').map(b => b.text).join('\n')
44
+ return ''
45
+ }
46
+
47
+ /** Parse a transcript; only the last `tailBytes` are read for speed. */
48
+ async function summarize(file, tailBytes = 400_000) {
49
+ const st = await stat(file.path)
50
+ const buf = await readFile(file.path)
51
+ const slice = buf.length > tailBytes ? buf.subarray(buf.length - tailBytes) : buf
52
+ const lines = slice.toString('utf8').split('\n')
53
+ if (buf.length > tailBytes) lines.shift() // drop a partial first line
54
+ let sessionId = file.path.split('/').pop().replace(/\.jsonl$/, '')
55
+ let cwd = null
56
+ let title = null
57
+ const turns = []
58
+ for (const line of lines) {
59
+ if (!line) continue
60
+ let d
61
+ try {
62
+ d = JSON.parse(line)
63
+ } catch {
64
+ continue
65
+ }
66
+ if (d.sessionId) sessionId = d.sessionId
67
+ if (d.cwd) cwd = d.cwd
68
+ if (d.type === 'ai-title' && typeof d.aiTitle === 'string') title = d.aiTitle
69
+ if ((d.type === 'user' || d.type === 'assistant') && d.message) {
70
+ const text = textOf(d.message).trim()
71
+ if (!text || text.startsWith('<') ) continue // skip tool results / system blocks
72
+ turns.push({ role: d.type === 'user' ? 'user' : 'claude', text, at: d.timestamp ?? null })
73
+ }
74
+ }
75
+ return { sessionId, cwd, title, turns, lastActiveAt: new Date(st.mtimeMs).toISOString(), active: Date.now() - st.mtimeMs < ACTIVE_WINDOW_MS }
76
+ }
77
+
78
+ export async function listSessions(max = 10) {
79
+ const files = (await sessionFiles()).slice(0, Math.min(Math.max(Number(max) || 10, 1), 20))
80
+ const sessions = []
81
+ for (const f of files) {
82
+ try {
83
+ const s = await summarize(f, 120_000)
84
+ const lastUser = [...s.turns].reverse().find(t => t.role === 'user')
85
+ const lastClaude = [...s.turns].reverse().find(t => t.role === 'claude')
86
+ sessions.push({
87
+ session_id: s.sessionId,
88
+ title: s.title ?? (lastUser ? lastUser.text.slice(0, 60) : '(untitled)'),
89
+ project: s.cwd ? s.cwd.split('/').slice(-2).join('/') : null,
90
+ cwd: s.cwd,
91
+ active: s.active,
92
+ last_active_at: s.lastActiveAt,
93
+ last_user_message: lastUser ? lastUser.text.slice(0, 200) : null,
94
+ last_claude_message: lastClaude ? lastClaude.text.slice(0, 200) : null,
95
+ })
96
+ } catch {
97
+ /* unreadable transcript */
98
+ }
99
+ }
100
+ return { count: sessions.length, sessions }
101
+ }
102
+
103
+ export async function readSession(sessionId, turns = 6) {
104
+ const files = await sessionFiles()
105
+ const file = files.find(f => f.path.endsWith(`/${sessionId}.jsonl`))
106
+ if (!file) return { error: `No session ${sessionId} on this machine` }
107
+ const s = await summarize(file)
108
+ const n = Math.min(Math.max(Number(turns) || 6, 1), 30)
109
+ return { session_id: s.sessionId, title: s.title, cwd: s.cwd, active: s.active, last_active_at: s.lastActiveAt, turns: s.turns.slice(-n).map(t => ({ ...t, text: t.text.slice(0, 2000) })) }
110
+ }
111
+
112
+ /**
113
+ * Project folders Claude Code has been used in, newest first. One cheap read
114
+ * per project: the head of its newest transcript carries the cwd.
115
+ */
116
+ export async function listProjects(max = 20) {
117
+ const n = Math.min(Math.max(Number(max) || 20, 1), 50)
118
+ let dirs = []
119
+ try {
120
+ dirs = await readdir(PROJECTS)
121
+ } catch {
122
+ return { count: 0, projects: [] }
123
+ }
124
+ const projects = []
125
+ for (const d of dirs) {
126
+ const dir = join(PROJECTS, d)
127
+ let files = []
128
+ try {
129
+ files = (await readdir(dir)).filter(f => f.endsWith('.jsonl'))
130
+ } catch {
131
+ continue
132
+ }
133
+ let newest = null
134
+ for (const f of files) {
135
+ const st = await stat(join(dir, f)).catch(() => null)
136
+ if (st && st.size > 0 && (!newest || st.mtimeMs > newest.mtime)) newest = { path: join(dir, f), mtime: st.mtimeMs }
137
+ }
138
+ if (!newest) continue
139
+ const head = (await readFile(newest.path)).subarray(0, 65536).toString('utf8')
140
+ const m = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(head)
141
+ if (!m) continue
142
+ const cwd = JSON.parse(`"${m[1]}"`)
143
+ const exists = await stat(cwd).then(st => st.isDirectory()).catch(() => false)
144
+ if (!exists) continue
145
+ projects.push({ cwd, name: basename(cwd), parent: basename(join(cwd, '..')), sessions: files.length, last_active_at: new Date(newest.mtime).toISOString(), _mtime: newest.mtime })
146
+ }
147
+ projects.sort((a, b) => b._mtime - a._mtime)
148
+ return { count: Math.min(projects.length, n), projects: projects.slice(0, n).map(({ _mtime, ...p }) => p) }
149
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // kira-bridge: connect this Mac to Kira on your glasses.
3
+ //
4
+ // npx kira-glasses-bridge --token <token> save the token and install the background service
5
+ // kira-bridge run run in the foreground (what the service runs)
6
+ // kira-bridge install | uninstall | status | logs
7
+ import { createRequire } from 'node:module'
8
+ import { spawnSync } from 'node:child_process'
9
+ import { resolve as resolveConfig, writeConfig, readConfig, configExists, LOG_PATH, DEFAULT_URL } from './config.mjs'
10
+ import { install, uninstall, status } from './service.mjs'
11
+ import { startBridge } from './index.mjs'
12
+
13
+ const require = createRequire(import.meta.url)
14
+ const { version } = require('../package.json')
15
+
16
+ const args = process.argv.slice(2)
17
+ const flags = {}
18
+ const positional = []
19
+ for (let i = 0; i < args.length; i++) {
20
+ const a = args[i]
21
+ if (a === '--token' || a === '--url' || a === '--claude-bin') {
22
+ flags[a === '--claude-bin' ? 'claudeBin' : a.slice(2)] = args[++i]
23
+ } else if (a === '--no-service') flags.noService = true
24
+ else if (a === '-v' || a === '--version') {
25
+ console.log(version)
26
+ process.exit(0)
27
+ } else if (a === '-h' || a === '--help') positional.push('help')
28
+ else positional.push(a)
29
+ }
30
+ const cmd = positional[0] ?? (flags.token ? 'setup' : configExists() ? 'run' : 'help')
31
+
32
+ const help = `kira-bridge ${version}
33
+
34
+ Connect this Mac to Kira (Even G2 glasses): Claude Code sessions, files,
35
+ folders and apps, by voice.
36
+
37
+ npx kira-glasses-bridge --token <token> first-time setup: saves the token
38
+ and installs a background service
39
+ (add --no-service to skip that)
40
+ kira-bridge run run in the foreground
41
+ kira-bridge status is the service installed and running?
42
+ kira-bridge logs follow the service log
43
+ kira-bridge install | uninstall manage the background service
44
+
45
+ Get a token from the Kira portal: Connections -> Claude Code -> Set up.
46
+ Settings are kept in ~/.kira/bridge.json.`
47
+
48
+ async function main() {
49
+ switch (cmd) {
50
+ case 'help':
51
+ console.log(help)
52
+ return
53
+ case 'setup': {
54
+ const cfg = { ...readConfig(), token: flags.token, url: flags.url || readConfig().url || DEFAULT_URL }
55
+ if (flags.claudeBin) cfg.claudeBin = flags.claudeBin
56
+ writeConfig(cfg)
57
+ console.log('Saved your token to ~/.kira/bridge.json')
58
+ if (flags.noService || process.platform !== 'darwin') {
59
+ console.log('Starting in the foreground. Press Ctrl-C to stop.\n')
60
+ return startBridge(resolveConfig(flags))
61
+ }
62
+ const r = install()
63
+ console.log(`Installed the background service (${r.plist}).`)
64
+ console.log('It starts at login and reconnects on its own. Check it with: kira-bridge status')
65
+ await waitForOnline(8000)
66
+ return
67
+ }
68
+ case 'run':
69
+ return startBridge(resolveConfig(flags))
70
+ case 'install': {
71
+ if (!resolveConfig(flags).token) throw new Error('no token saved yet; run: npx kira-glasses-bridge --token <token>')
72
+ const r = install()
73
+ console.log(`Installed and started (${r.plist}). Log: ${r.log}`)
74
+ return
75
+ }
76
+ case 'uninstall':
77
+ console.log(uninstall() ? 'Removed the background service.' : 'No background service was installed.')
78
+ return
79
+ case 'status': {
80
+ const s = status()
81
+ const c = resolveConfig(flags)
82
+ console.log(`token: ${c.token ? 'saved' : 'missing'}`)
83
+ console.log(`server: ${c.url}`)
84
+ if (!s.installed) console.log('service: not installed (run `kira-bridge install`, or `kira-bridge run` for the foreground)')
85
+ else console.log(`service: ${s.running ? 'running' : 'installed but not running'}\nlog: ${s.log}${s.lastLog ? '\n\n' + s.lastLog : ''}`)
86
+ return
87
+ }
88
+ case 'logs':
89
+ spawnSync('tail', ['-n', '50', '-f', LOG_PATH], { stdio: 'inherit' })
90
+ return
91
+ default:
92
+ throw new Error(`unknown command "${cmd}"\n\n${help}`)
93
+ }
94
+ }
95
+
96
+ async function waitForOnline(ms) {
97
+ const { readFileSync } = await import('node:fs')
98
+ const start = Date.now()
99
+ while (Date.now() - start < ms) {
100
+ await new Promise(r => setTimeout(r, 500))
101
+ try {
102
+ const tail = readFileSync(LOG_PATH, 'utf8').slice(-2000)
103
+ if (/registered as bridge/.test(tail)) {
104
+ console.log('\nConnected. The Claude Code card in the portal should now show Online.')
105
+ return
106
+ }
107
+ if (/token rejected/.test(tail)) throw new Error('the server rejected this token; make a new one in the portal')
108
+ } catch (err) {
109
+ if (err.code !== 'ENOENT') throw err
110
+ }
111
+ }
112
+ console.log('\nThe service is starting; check `kira-bridge status` in a moment.')
113
+ }
114
+
115
+ main().catch(err => {
116
+ console.error(`kira-bridge: ${err.message}`)
117
+ process.exit(1)
118
+ })
package/src/config.mjs ADDED
@@ -0,0 +1,38 @@
1
+ // Bridge settings live in ~/.kira/bridge.json so the daemon can start without
2
+ // environment variables (launchd gives it none). The token is written 0600.
3
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ export const KIRA_DIR = join(homedir(), '.kira')
8
+ export const CONFIG_PATH = join(KIRA_DIR, 'bridge.json')
9
+ export const LOG_PATH = join(KIRA_DIR, 'bridge.log')
10
+ export const DEFAULT_URL = 'wss://kira.hackchild.xyz/api/bridge/ws'
11
+
12
+ export function readConfig() {
13
+ try {
14
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
15
+ } catch {
16
+ return {}
17
+ }
18
+ }
19
+
20
+ export function writeConfig(cfg) {
21
+ mkdirSync(KIRA_DIR, { recursive: true, mode: 0o700 })
22
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 })
23
+ chmodSync(CONFIG_PATH, 0o600)
24
+ }
25
+
26
+ /** Effective settings: flags > env > saved config > defaults. */
27
+ export function resolve(flags = {}) {
28
+ const saved = readConfig()
29
+ return {
30
+ url: flags.url || process.env.KIRA_BRIDGE_URL || saved.url || DEFAULT_URL,
31
+ token: flags.token || process.env.KIRA_BRIDGE_TOKEN || saved.token || '',
32
+ claudeBin: flags.claudeBin || process.env.KIRA_CLAUDE_BIN || saved.claudeBin || '',
33
+ }
34
+ }
35
+
36
+ export function configExists() {
37
+ return existsSync(CONFIG_PATH)
38
+ }
package/src/files.mjs ADDED
@@ -0,0 +1,143 @@
1
+ // Read-only file browser for this Mac. Paths are confined to the home
2
+ // folder; hidden entries and node_modules are skipped so lists stay short.
3
+ import { readdir, readFile, stat } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { basename, extname, join, relative, resolve } from 'node:path'
6
+
7
+ const HOME = homedir()
8
+ const MAX_ENTRIES = 200
9
+ const TEXT_LIMIT = 12000
10
+ const READ_LIMIT = 2 * 1024 * 1024
11
+ const SKIP = new Set(['node_modules', '.git', 'Library', '.Trash'])
12
+
13
+ /** Common starting points, shown as the top level on the glasses. */
14
+ export const ROOTS = ['Desktop', 'Documents', 'Downloads', 'Developer']
15
+
16
+ /** Expand ~ and confine to the home folder. Throws on escape. */
17
+ export function resolveUserPath(p) {
18
+ const raw = String(p ?? '').trim()
19
+ let full = raw === '' || raw === '~' ? HOME : raw.startsWith('~/') ? join(HOME, raw.slice(2)) : raw.startsWith('/') ? raw : join(HOME, raw)
20
+ full = resolve(full)
21
+ const rel = relative(HOME, full)
22
+ if (rel.startsWith('..') || resolve(full) !== full) throw new Error('only files under your home folder can be browsed')
23
+ return full
24
+ }
25
+
26
+ function display(full) {
27
+ const rel = relative(HOME, full)
28
+ return rel === '' ? '~' : `~/${rel}`
29
+ }
30
+
31
+ export async function listFiles({ path, max }) {
32
+ const full = resolveUserPath(path)
33
+ const st = await stat(full).catch(() => null)
34
+ if (!st) return { error: `${display(full)} does not exist` }
35
+ if (!st.isDirectory()) return { error: `${display(full)} is a file; use read_file` }
36
+ const names = await readdir(full)
37
+ const limit = Math.min(Math.max(Number(max) || MAX_ENTRIES, 1), MAX_ENTRIES)
38
+ const entries = []
39
+ for (const name of names) {
40
+ if (name.startsWith('.') || SKIP.has(name)) continue
41
+ const child = join(full, name)
42
+ const cs = await stat(child).catch(() => null)
43
+ if (!cs) continue
44
+ entries.push({ name, path: display(child), type: cs.isDirectory() ? 'dir' : 'file', size: cs.isDirectory() ? undefined : cs.size, modified: cs.mtime.toISOString() })
45
+ }
46
+ entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) : a.type === 'dir' ? -1 : 1))
47
+ return { path: display(full), count: entries.length, truncated: entries.length > limit, entries: entries.slice(0, limit) }
48
+ }
49
+
50
+ const TEXT_EXT = new Set(['.txt', '.md', '.markdown', '.json', '.yml', '.yaml', '.toml', '.csv', '.tsv', '.log', '.env', '.xml', '.html', '.css', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.rb', '.go', '.rs', '.swift', '.kt', '.java', '.c', '.h', '.cpp', '.m', '.sh', '.zsh', '.sql', '.ini', '.cfg', '.conf', '.plist', '.gitignore'])
51
+ const OFFICE_EXT = { '.docx': 'a Word document', '.doc': 'a Word document', '.pdf': 'a PDF', '.xlsx': 'a spreadsheet', '.pptx': 'a presentation', '.pages': 'a Pages document', '.numbers': 'a Numbers spreadsheet', '.key': 'a Keynote deck' }
52
+
53
+ export async function readUserFile({ path }) {
54
+ const full = resolveUserPath(path)
55
+ const st = await stat(full).catch(() => null)
56
+ if (!st) return { error: `${display(full)} does not exist` }
57
+ if (st.isDirectory()) return { error: `${display(full)} is a folder; use list_files` }
58
+ const ext = extname(full).toLowerCase()
59
+ if (OFFICE_EXT[ext]) return { path: display(full), size: st.size, error: `${basename(full)} is ${OFFICE_EXT[ext]}; only plain text files can be read for now` }
60
+ if (st.size > READ_LIMIT) return { path: display(full), size: st.size, error: `${basename(full)} is ${Math.round(st.size / 1024 / 1024)} MB, too large to read here` }
61
+ const buf = await readFile(full)
62
+ const sample = buf.subarray(0, 4000)
63
+ const binary = !TEXT_EXT.has(ext) && (sample.includes(0) || sample.filter(b => b < 9 || (b > 13 && b < 32)).length > sample.length / 50)
64
+ if (binary) return { path: display(full), size: st.size, binary: true, error: `${basename(full)} looks like a binary file` }
65
+ const text = buf.toString('utf8')
66
+ const lines = text.split('\n').length
67
+ const truncated = text.length > TEXT_LIMIT
68
+ return { path: display(full), name: basename(full), size: st.size, lines, modified: st.mtime.toISOString(), truncated, text: truncated ? `${text.slice(0, TEXT_LIMIT)}\n… (${lines} lines, showing the first part)` : text }
69
+ }
70
+
71
+ /**
72
+ * Create a folder under the home directory (parents included). The one
73
+ * write the file browser allows, so Kira can start projects. Refuses to
74
+ * touch anything outside ~ and never overwrites an existing file.
75
+ */
76
+ export async function createFolder({ path }) {
77
+ const full = resolveUserPath(String(path ?? ''))
78
+ if (full === HOME) throw new Error('give the new folder a name')
79
+ const { mkdir, stat } = await import('node:fs/promises')
80
+ try {
81
+ const st = await stat(full)
82
+ if (st.isDirectory()) return { path: display(full), created: false, note: 'already exists' }
83
+ throw new Error(`${display(full)} exists and is a file`)
84
+ } catch (err) {
85
+ if (err.code !== 'ENOENT') throw err
86
+ }
87
+ await mkdir(full, { recursive: true })
88
+ return { path: display(full), created: true }
89
+ }
90
+
91
+ /**
92
+ * Write a plain-text file under the home directory. Creates parent folders.
93
+ * Refuses to overwrite unless `overwrite` is true, so a slip of the tongue
94
+ * cannot clobber something.
95
+ */
96
+ export async function writeUserFile({ path, content, overwrite }) {
97
+ const full = resolveUserPath(String(path ?? ''))
98
+ if (full === HOME) throw new Error('give the file a name')
99
+ const { mkdir, stat, writeFile } = await import('node:fs/promises')
100
+ const { dirname } = await import('node:path')
101
+ try {
102
+ const st = await stat(full)
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. Say "overwrite it" to replace it.` }
105
+ } catch (err) {
106
+ if (err.code !== 'ENOENT') throw err
107
+ }
108
+ await mkdir(dirname(full), { recursive: true })
109
+ const text = String(content ?? '')
110
+ await writeFile(full, text, 'utf8')
111
+ return { path: display(full), written: true, bytes: Buffer.byteLength(text, 'utf8') }
112
+ }
113
+
114
+ /**
115
+ * "Delete" a file or folder by moving it to the Trash (via Finder), so a
116
+ * misheard name is recoverable. Confined to the home folder; refuses the
117
+ * home folder itself and its top-level folders (Desktop, Developer, ...).
118
+ */
119
+ export async function trashPath({ path }) {
120
+ const full = resolveUserPath(String(path ?? ''))
121
+ const rel = relative(HOME, full)
122
+ if (!rel || !rel.includes('/')) throw new Error(`refusing to delete ${display(full)}: only things inside a top-level folder can be deleted`)
123
+ const { stat } = await import('node:fs/promises')
124
+ let st
125
+ try {
126
+ st = await stat(full)
127
+ } catch (err) {
128
+ if (err.code === 'ENOENT') return { error: `${display(full)} does not exist` }
129
+ throw err
130
+ }
131
+ const { execFile } = await import('node:child_process')
132
+ const { promisify } = await import('node:util')
133
+ const run = promisify(execFile)
134
+ // Finder handles the move and the "Put Back" bookkeeping. The path is passed
135
+ // as an argument, never interpolated into the script.
136
+ const script = 'on run argv\n tell application "Finder" to delete (POSIX file (item 1 of argv) as alias)\nend run'
137
+ try {
138
+ await run('/usr/bin/osascript', ['-e', script, full], { timeout: 20_000 })
139
+ } catch (err) {
140
+ return { error: `could not move to Trash: ${String(err.stderr || err.message).trim()}` }
141
+ }
142
+ return { trashed: true, path: display(full), kind: st.isDirectory() ? 'folder' : 'file', note: 'Moved to the Trash; it can be put back from there.' }
143
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ // Kira bridge. Connects outbound to the Kira API and runs tools on this
3
+ // machine on the user's behalf. Env: KIRA_BRIDGE_URL, KIRA_BRIDGE_TOKEN.
4
+ import WebSocket from 'ws'
5
+ import { listProjects, listSessions, readSession } from './claude-sessions.mjs'
6
+ import { runClaude, findClaude } from './claude-run.mjs'
7
+ import { openApp, installedApps } from './apps.mjs'
8
+ import { listFiles, readUserFile, createFolder, writeUserFile, trashPath } from './files.mjs'
9
+
10
+ import { createRequire } from 'node:module'
11
+ const { version: BRIDGE_VERSION } = createRequire(import.meta.url)('../package.json')
12
+
13
+ let URL_ = ''
14
+ let TOKEN = ''
15
+
16
+ /** Start the bridge with resolved settings. Runs until the process exits. */
17
+ export function startBridge(config) {
18
+ URL_ = config.url
19
+ TOKEN = config.token
20
+ if (config.claudeBin) process.env.KIRA_CLAUDE_BIN = config.claudeBin
21
+ if (!URL_ || !TOKEN) {
22
+ console.error('No token. Run: npx kira-glasses-bridge --token <token> (from the Kira portal → Connections → Claude Code)')
23
+ process.exit(1)
24
+ }
25
+ log(`kira-bridge ${BRIDGE_VERSION} · claude binary: ${findClaude()}`)
26
+ connect()
27
+ }
28
+
29
+ const tools = {
30
+ async list_files(args) {
31
+ return listFiles(args)
32
+ },
33
+ async read_file(args) {
34
+ return readUserFile(args)
35
+ },
36
+ async create_folder(args) {
37
+ return createFolder(args)
38
+ },
39
+ async write_file(args) {
40
+ return writeUserFile(args)
41
+ },
42
+ async open_app(args) {
43
+ return openApp(args)
44
+ },
45
+ async delete_path(args) {
46
+ return trashPath(args)
47
+ },
48
+ async list_apps() {
49
+ return { apps: await installedApps() }
50
+ },
51
+ async list_claude_projects(args) {
52
+ return listProjects(args.max)
53
+ },
54
+ async list_claude_sessions(args) {
55
+ return listSessions(args.max)
56
+ },
57
+ async read_claude_session(args) {
58
+ return readSession(String(args.session_id ?? ''), args.turns)
59
+ },
60
+ // Acknowledge now, report the result as a job when Claude finishes.
61
+ async ask_claude(args, ws) {
62
+ const jobId = String(args.job_id ?? '')
63
+ const prompt = String(args.prompt ?? '')
64
+ if (!prompt) return { error: 'prompt is required' }
65
+ log(`ask_claude job=${jobId.slice(0, 8)} session=${args.session_id ?? 'new'}: ${prompt.slice(0, 80)}`)
66
+ void runClaude({ prompt, sessionId: args.session_id, cwd: args.cwd }).then(result => {
67
+ log(`job ${jobId.slice(0, 8)} ${result.error ? 'error' : 'done'}`)
68
+ send(ws, { type: 'job', jobId, status: result.error ? 'error' : 'done', result, error: result.error })
69
+ })
70
+ return { started: true }
71
+ },
72
+ }
73
+
74
+ let backoff = 1000
75
+ function connect() {
76
+ const ws = new WebSocket(`${URL_}?token=${encodeURIComponent(TOKEN)}`)
77
+ let ping
78
+ ws.on('open', () => {
79
+ backoff = 1000
80
+ log(`connected to ${URL_}`)
81
+ ping = setInterval(() => send(ws, { type: 'ping' }), 30_000)
82
+ })
83
+ ws.on('message', async raw => {
84
+ let msg
85
+ try {
86
+ msg = JSON.parse(raw.toString())
87
+ } catch {
88
+ return
89
+ }
90
+ if (msg.type === 'hello') {
91
+ // Tell the API which tools this bridge build implements, so a stale
92
+ // bridge is reported as such instead of "unknown tool".
93
+ send(ws, { type: 'capabilities', tools: Object.keys(tools), version: BRIDGE_VERSION, platform: process.platform })
94
+ return log(`registered as bridge ${msg.bridgeId} (${Object.keys(tools).length} tools)`)
95
+ }
96
+ if (msg.type !== 'call') return
97
+ const fn = tools[msg.tool]
98
+ if (!fn) return send(ws, { type: 'error', id: msg.id, error: `unknown tool ${msg.tool}` })
99
+ try {
100
+ send(ws, { type: 'result', id: msg.id, result: await fn(msg.args ?? {}, ws) })
101
+ } catch (err) {
102
+ send(ws, { type: 'error', id: msg.id, error: err.message })
103
+ }
104
+ })
105
+ ws.on('close', (code, reason) => {
106
+ clearInterval(ping)
107
+ if (code === 4001) {
108
+ log('token rejected; create a new one in the portal')
109
+ process.exit(1)
110
+ }
111
+ log(`disconnected (${code} ${reason}); retrying in ${backoff / 1000}s`)
112
+ setTimeout(connect, backoff)
113
+ backoff = Math.min(backoff * 2, 30_000)
114
+ })
115
+ ws.on('error', err => log(`socket error: ${err.message}`))
116
+ }
117
+
118
+ function send(ws, obj) {
119
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj))
120
+ }
121
+ function log(m) {
122
+ console.log(`[kira-bridge ${new Date().toLocaleTimeString()}] ${m}`)
123
+ }
124
+
@@ -0,0 +1,108 @@
1
+ // launchd integration: run the bridge at login and keep it alive.
2
+ import { execFileSync } from 'node:child_process'
3
+ import { mkdirSync, writeFileSync, existsSync, unlinkSync, readFileSync, cpSync, rmSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import { join, dirname } from 'node:path'
6
+ import { KIRA_DIR, LOG_PATH } from './config.mjs'
7
+
8
+ // The service runs a private copy of the package under ~/.kira/app, so it keeps
9
+ // working after npx's cache is pruned and survives package upgrades cleanly.
10
+ const APP_DIR = join(KIRA_DIR, 'app')
11
+
12
+ export const LABEL = 'com.kira.bridge'
13
+ const PLIST = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`)
14
+
15
+ function nodePath() {
16
+ return process.execPath
17
+ }
18
+ function packageRoot() {
19
+ return dirname(new URL('.', import.meta.url).pathname.replace(/\/$/, ''))
20
+ }
21
+
22
+ /** Copy this package (src + package.json + node_modules/ws) into ~/.kira/app. */
23
+ function stageApp() {
24
+ const root = packageRoot()
25
+ rmSync(APP_DIR, { recursive: true, force: true })
26
+ mkdirSync(APP_DIR, { recursive: true })
27
+ cpSync(join(root, 'src'), join(APP_DIR, 'src'), { recursive: true })
28
+ cpSync(join(root, 'package.json'), join(APP_DIR, 'package.json'))
29
+ // ws is the only dependency; resolve it from wherever npm put it.
30
+ const wsPkg = dirname(new URL(import.meta.resolve('ws/package.json')).pathname)
31
+ cpSync(wsPkg, join(APP_DIR, 'node_modules', 'ws'), { recursive: true })
32
+ return join(APP_DIR, 'src', 'cli.mjs')
33
+ }
34
+
35
+ export function install() {
36
+ if (process.platform !== 'darwin') throw new Error('the background service is only supported on macOS for now; run `kira-bridge run` in a terminal instead')
37
+ mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true })
38
+ mkdirSync(KIRA_DIR, { recursive: true, mode: 0o700 })
39
+ const cli = stageApp()
40
+ // PATH: launchd gives a minimal environment; include the usual tool dirs so
41
+ // `claude`, `open` and friends resolve.
42
+ const path = ['/opt/homebrew/bin', '/usr/local/bin', join(homedir(), '.local', 'bin'), '/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(':')
43
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
44
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
45
+ <plist version="1.0">
46
+ <dict>
47
+ <key>Label</key><string>${LABEL}</string>
48
+ <key>ProgramArguments</key>
49
+ <array>
50
+ <string>${nodePath()}</string>
51
+ <string>${cli}</string>
52
+ <string>run</string>
53
+ </array>
54
+ <key>RunAtLoad</key><true/>
55
+ <key>KeepAlive</key><true/>
56
+ <key>ThrottleInterval</key><integer>10</integer>
57
+ <key>StandardOutPath</key><string>${LOG_PATH}</string>
58
+ <key>StandardErrorPath</key><string>${LOG_PATH}</string>
59
+ <key>EnvironmentVariables</key>
60
+ <dict>
61
+ <key>PATH</key><string>${path}</string>
62
+ <key>HOME</key><string>${homedir()}</string>
63
+ </dict>
64
+ </dict>
65
+ </plist>
66
+ `
67
+ if (existsSync(PLIST)) {
68
+ try {
69
+ execFileSync('launchctl', ['bootout', `gui/${process.getuid()}`, PLIST], { stdio: 'ignore' })
70
+ } catch {
71
+ /* not loaded */
72
+ }
73
+ }
74
+ writeFileSync(PLIST, plist)
75
+ execFileSync('launchctl', ['bootstrap', `gui/${process.getuid()}`, PLIST], { stdio: 'inherit' })
76
+ return { plist: PLIST, log: LOG_PATH }
77
+ }
78
+
79
+ export function uninstall() {
80
+ if (!existsSync(PLIST)) return false
81
+ try {
82
+ execFileSync('launchctl', ['bootout', `gui/${process.getuid()}`, PLIST], { stdio: 'ignore' })
83
+ } catch {
84
+ /* not loaded */
85
+ }
86
+ unlinkSync(PLIST)
87
+ rmSync(APP_DIR, { recursive: true, force: true })
88
+ return true
89
+ }
90
+
91
+ export function status() {
92
+ if (!existsSync(PLIST)) return { installed: false }
93
+ let running = false
94
+ try {
95
+ const out = execFileSync('launchctl', ['print', `gui/${process.getuid()}/${LABEL}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
96
+ running = /state = running/.test(out)
97
+ } catch {
98
+ running = false
99
+ }
100
+ let lastLog = ''
101
+ try {
102
+ const lines = readFileSync(LOG_PATH, 'utf8').trim().split('\n')
103
+ lastLog = lines.slice(-3).join('\n')
104
+ } catch {
105
+ /* no log yet */
106
+ }
107
+ return { installed: true, running, plist: PLIST, log: LOG_PATH, lastLog }
108
+ }