kira-glasses-bridge 2.27.0 → 2.29.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 +2 -1
- package/src/agent-files.mjs +59 -25
- package/src/agents/codex-agent.mjs +47 -0
- package/src/agents/codex.mjs +10 -2
- package/src/appollo/asc-launch.mjs +90 -0
- package/src/appollo/asc-run.mjs +8 -4
- package/src/appollo/index.mjs +2 -1
- package/src/checkouts.mjs +74 -0
- package/src/config-folders.mjs +1 -1
- package/src/git.mjs +3 -3
- package/src/kira-plugin.mjs +26 -12
- package/src/mac-tools.mjs +9 -1
- package/src/worktrees.mjs +1 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kira-glasses-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.29.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": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@anthropic-ai/claude-agent-sdk": "^0.3.278",
|
|
22
|
+
"smol-toml": "1.9.0",
|
|
22
23
|
"ws": "^8.18.0"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|
package/src/agent-files.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// Subagents on this Mac: Claude Code's (one Markdown file each in
|
|
2
|
-
// for its settings and the body as its system prompt, with
|
|
3
|
-
// ~/.claude/agent-memory/<name>/MEMORY.md) and Codex's (
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Subagents on this Mac, in each agent's own format: Claude Code's (one Markdown file each in
|
|
2
|
+
// ~/.claude/agents, front matter for its settings and the body as its system prompt, with
|
|
3
|
+
// `memory: user` kept in ~/.claude/agent-memory/<name>/MEMORY.md) and Codex's (one TOML file each
|
|
4
|
+
// in ~/.codex/agents: name, description, developer_instructions and any config key; Codex keeps no
|
|
5
|
+
// memory per agent). Kira's agents are one or the other: she writes their files (and a Claude Code
|
|
6
|
+
// one's memory) here, and a task runs as one (agents/claude.mjs, agents/codex-agent.mjs).
|
|
6
7
|
import { readdir, unlink } from 'node:fs/promises'
|
|
7
8
|
import { join } from 'node:path'
|
|
9
|
+
import { parse } from 'smol-toml'
|
|
8
10
|
import {
|
|
9
11
|
FOLDERS,
|
|
10
12
|
agentFolders,
|
|
@@ -29,12 +31,34 @@ const LOAD_BYTES = 25 * 1024
|
|
|
29
31
|
const KIRAS_LINE = /^- \d{4}-\d{2}-\d{2}: /
|
|
30
32
|
const WHAT = 'a subagent'
|
|
31
33
|
const NAMED = { loose: true }
|
|
32
|
-
const claudeRoot = () => agentFolders()[0].root
|
|
33
34
|
|
|
34
|
-
/**
|
|
35
|
+
/** Each agent's folder and file: where Kira writes it, and what a file of it must be. */
|
|
36
|
+
const KINDS = {
|
|
37
|
+
claude: { root: () => agentFolders()[0].root, ext: '.md', check: checkMd },
|
|
38
|
+
codex: { root: () => agentFolders()[1].root, ext: '.toml', check: checkToml },
|
|
39
|
+
}
|
|
40
|
+
const kindOf = provider => KINDS[provider ?? 'claude'] ?? KINDS.claude
|
|
41
|
+
|
|
42
|
+
function checkMd(text) {
|
|
43
|
+
if (!/^---\r?\n/.test(text)) throw new Error('A subagent file starts with front matter between --- lines')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function checkToml(text) {
|
|
47
|
+
try {
|
|
48
|
+
parse(text)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new Error(`A Codex agent file is TOML: ${err.message}`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A Codex agent file's string field, or null (and null for all when it isn't TOML). */
|
|
35
55
|
const tomlField = (toml, key) => {
|
|
36
|
-
|
|
37
|
-
|
|
56
|
+
try {
|
|
57
|
+
const v = parse(toml)[key]
|
|
58
|
+
return typeof v === 'string' ? v : null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
38
62
|
}
|
|
39
63
|
|
|
40
64
|
/** The name and description a tool shows, from a subagent file (its file name when it has none). */
|
|
@@ -78,34 +102,44 @@ async function readMemory(name, root) {
|
|
|
78
102
|
}
|
|
79
103
|
}
|
|
80
104
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
105
|
+
/**
|
|
106
|
+
* One subagent, `provider`'s (Claude Code's by default), with its memory (Claude Code's only). `md`
|
|
107
|
+
* is its file's text whatever the format: Markdown for Claude Code, TOML for Codex.
|
|
108
|
+
*/
|
|
109
|
+
export async function readAgent({ slug, provider } = {}, root, memoryRoot = FOLDERS.claude.agentMemory()) {
|
|
110
|
+
const kind = kindOf(provider)
|
|
111
|
+
const file = inside(root ?? kind.root(), slug, WHAT, { ...NAMED, ext: kind.ext })
|
|
84
112
|
const md = await readSmall(file, MAX_MD)
|
|
85
113
|
if (md === null) return { error: `No subagent at ${display(file)}` }
|
|
86
|
-
const summary = agentSummary(md, slug)
|
|
87
|
-
|
|
114
|
+
const summary = agentSummary(md, slug, kind.ext)
|
|
115
|
+
const memory = kind === KINDS.claude ? await readMemory(summary.name, memoryRoot) : ''
|
|
116
|
+
return { slug, ...summary, path: display(file), md, memory }
|
|
88
117
|
}
|
|
89
118
|
|
|
90
119
|
/**
|
|
91
|
-
* Write a
|
|
92
|
-
* one (a subagent already there, Kira's or not, is never overwritten by a new one of that name).
|
|
120
|
+
* Write a subagent's file (`provider`'s), whole: replacing the one there, or with `create` only a
|
|
121
|
+
* new one (a subagent already there, Kira's or not, is never overwritten by a new one of that name).
|
|
93
122
|
*/
|
|
94
|
-
export async function writeAgent({ slug, md, create = false } = {}, root
|
|
95
|
-
const
|
|
123
|
+
export async function writeAgent({ slug, md, create = false, provider } = {}, root) {
|
|
124
|
+
const kind = kindOf(provider)
|
|
125
|
+
const dir = root ?? kind.root()
|
|
126
|
+
const file = inside(dir, slug, WHAT, { ...NAMED, ext: kind.ext })
|
|
96
127
|
const text = String(md ?? '')
|
|
97
|
-
|
|
128
|
+
kind.check(text)
|
|
98
129
|
if (Buffer.byteLength(text) > MAX_MD) throw new Error('That subagent file is too large')
|
|
99
|
-
await refuseLinks([
|
|
130
|
+
await refuseLinks([dir, file], 'edit')
|
|
100
131
|
if (create && (await exists(file))) throw new Error(`A subagent named ${slug} is already on your Mac`)
|
|
101
132
|
await writeWhole(file, text)
|
|
102
|
-
|
|
133
|
+
const providers = [kind === KINDS.codex ? 'codex' : 'claude']
|
|
134
|
+
return { slug, ...agentSummary(text, slug, kind.ext), path: display(file), providers }
|
|
103
135
|
}
|
|
104
136
|
|
|
105
|
-
/** Remove a
|
|
106
|
-
export async function removeAgent({ slug } = {}, root
|
|
107
|
-
const
|
|
108
|
-
|
|
137
|
+
/** Remove a subagent's file (`provider`'s). A Claude Code one's memory folder stays (it's what it learned). */
|
|
138
|
+
export async function removeAgent({ slug, provider } = {}, root) {
|
|
139
|
+
const kind = kindOf(provider)
|
|
140
|
+
const dir = root ?? kind.root()
|
|
141
|
+
const file = inside(dir, slug, WHAT, { ...NAMED, ext: kind.ext })
|
|
142
|
+
await refuseLinks([dir, file], 'remove')
|
|
109
143
|
const removed = await unlink(file).then(
|
|
110
144
|
() => true,
|
|
111
145
|
e => {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// A Kira agent that runs on Codex: its Codex agent file (~/.codex/agents/<handle>.toml, written by
|
|
2
|
+
// agent-files.mjs). Codex's app server has no "start this thread as agent X" (its agent roles are a
|
|
3
|
+
// config layer it applies when it spawns a subagent: codex-rs/agent-roles), so a job applies the
|
|
4
|
+
// file the way Codex applies a role: its `developer_instructions` are the thread's developer
|
|
5
|
+
// instructions (what Kira adds for the run after them) and its other settings (model,
|
|
6
|
+
// model_reasoning_effort…) the thread's config. Fields from codex-rs/agent-roles/src/agent_role_config.rs.
|
|
7
|
+
import { parse } from 'smol-toml'
|
|
8
|
+
import { FOLDERS, display, inside, readSmall } from '../config-folders.mjs'
|
|
9
|
+
|
|
10
|
+
const MAX_TOML = 256 * 1024
|
|
11
|
+
/** What names and describes the agent: Codex reads them for its spawn list, never as config. */
|
|
12
|
+
const NOT_CONFIG = new Set(['name', 'description', 'nickname_candidates', 'developer_instructions'])
|
|
13
|
+
|
|
14
|
+
/** A table's settings as Codex's config overrides take them: one dotted key per value. */
|
|
15
|
+
function flatten(table, prefix = '', out = {}) {
|
|
16
|
+
for (const [k, v] of Object.entries(table)) {
|
|
17
|
+
const key = prefix + k
|
|
18
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date)) flatten(v, `${key}.`, out)
|
|
19
|
+
else out[key] = v instanceof Date ? v.toISOString() : v
|
|
20
|
+
}
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** An agent file as a thread takes it: its instructions and the rest of its config. Throws when it isn't TOML. */
|
|
25
|
+
export function roleLayer(toml) {
|
|
26
|
+
const table = parse(String(toml ?? ''))
|
|
27
|
+
const rest = Object.fromEntries(Object.entries(table).filter(([k]) => !NOT_CONFIG.has(k)))
|
|
28
|
+
const said = table.developer_instructions
|
|
29
|
+
return { instructions: typeof said === 'string' ? said.trim() : '', config: flatten(rest) }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The thread's developer instructions: the agent's own, then what Kira adds for this run. */
|
|
33
|
+
export const developerInstructions = (role, persona) =>
|
|
34
|
+
[role?.instructions, persona?.systemPrompt].filter(Boolean).join('\n\n')
|
|
35
|
+
|
|
36
|
+
/** The Codex agent a job runs as (null for none); throws, saying where, when its file isn't there. */
|
|
37
|
+
export async function codexAgentOf(persona, root = FOLDERS.codex.agents()) {
|
|
38
|
+
if (!persona?.agent) return null
|
|
39
|
+
const file = inside(root, persona.agent, 'a Codex agent', { loose: true, ext: '.toml' })
|
|
40
|
+
const toml = await readSmall(file, MAX_TOML)
|
|
41
|
+
if (toml === null) throw new Error(`@${persona.agent}'s Codex agent isn't on this Mac (${display(file)})`)
|
|
42
|
+
try {
|
|
43
|
+
return roleLayer(toml)
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new Error(`${display(file)} isn't TOML Codex can read: ${err.message}`)
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/agents/codex.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { TEXT_CAP, short } from './claude-calls.mjs'
|
|
|
6
6
|
import { callsOf, codexOptionsFor, itemAction, itemResult, strictest } from './codex-items.mjs'
|
|
7
7
|
import { codexBin, openCodex } from './codex-rpc.mjs'
|
|
8
8
|
import { kiraDecides } from './checks.mjs'
|
|
9
|
+
import { codexAgentOf, developerInstructions } from './codex-agent.mjs'
|
|
10
|
+
import { kiraSkillRoots } from '../kira-plugin.mjs'
|
|
9
11
|
|
|
10
12
|
const COMMAND = 'item/commandExecution/requestApproval'
|
|
11
13
|
const APPROVALS = new Set([COMMAND, 'item/fileChange/requestApproval'])
|
|
@@ -111,8 +113,14 @@ async function run({ prompt, sessionId, cwd, persona, settings, abort }, io) {
|
|
|
111
113
|
try {
|
|
112
114
|
await codex.request('initialize', { clientInfo: { name: 'kira', title: 'Kira', version: '1' }, capabilities: null })
|
|
113
115
|
codex.notify('initialized')
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
+
// A Kira agent on Codex: its agent file, applied as Codex applies a role (codex-agent.mjs).
|
|
117
|
+
const role = await codexAgentOf(persona)
|
|
118
|
+
// Kira's built-in skills, as Codex's own for this app server: the folder Claude Code's plugin uses.
|
|
119
|
+
const roots = await kiraSkillRoots(persona)
|
|
120
|
+
if (roots.length) await codex.request('skills/extraRoots/set', { extraRoots: roots })
|
|
121
|
+
const config = { ...role?.config, ...(persona?.kiraTools ? kiraServer(persona.kiraTools) : {}) }
|
|
122
|
+
const said = developerInstructions(role, persona)
|
|
123
|
+
const instructions = said ? { developerInstructions: said } : {}
|
|
116
124
|
const opened = thread
|
|
117
125
|
? await codex.request('thread/resume', { threadId: thread, cwd, ...options, ...instructions, config })
|
|
118
126
|
: await codex.request('thread/start', { cwd, ...options, ...instructions, config })
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Appollo (POC): getting an app in App Store Connect to Apple's review. Reads asc's release
|
|
2
|
+
// dashboard and its submission readiness report; the only writes are three fixed fixes the person
|
|
3
|
+
// starts from the page (price Free, every country, the reviewer's sign-in). Also finds the app's
|
|
4
|
+
// code on this Mac by its bundle id.
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { run } from './asc-run.mjs'
|
|
9
|
+
|
|
10
|
+
const APP_ID = /^\d{6,12}$/
|
|
11
|
+
const UUID = /^[0-9a-f-]{36}$/i
|
|
12
|
+
const BUNDLE_ID = /^[A-Za-z0-9.-]{3,155}$/
|
|
13
|
+
const FIND_MS = 20_000
|
|
14
|
+
const FIND_ROOT = join(homedir(), 'Developer')
|
|
15
|
+
|
|
16
|
+
/** The project files that name an app's bundle id: Xcode, XcodeGen, Expo. */
|
|
17
|
+
const PROJECT_FILES = ['project.pbxproj', 'project.yml', 'app.json', 'app.config.ts', 'app.config.js']
|
|
18
|
+
const SKIP_DIRS = ['node_modules', 'Pods', 'DerivedData', '.git', 'build', 'dist', '.expo']
|
|
19
|
+
|
|
20
|
+
function appId(args) {
|
|
21
|
+
const id = String(args.app_id ?? '')
|
|
22
|
+
if (!APP_ID.test(id)) throw new Error('not an App Store Connect app id')
|
|
23
|
+
return id
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The project folder a project file belongs to (the folder holding the .xcodeproj, or app.json's). */
|
|
27
|
+
function projectFolder(file) {
|
|
28
|
+
const parts = file.split('/')
|
|
29
|
+
const xcodeproj = parts.findIndex(p => p.endsWith('.xcodeproj'))
|
|
30
|
+
return (xcodeproj > 0 ? parts.slice(0, xcodeproj) : parts.slice(0, -1)).join('/')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const ascLaunchTools = {
|
|
34
|
+
/** asc's release dashboard and readiness report for one app, as they come. */
|
|
35
|
+
async asc_launch_readiness(args) {
|
|
36
|
+
const id = appId(args)
|
|
37
|
+
const [status, report] = await Promise.all([
|
|
38
|
+
run(['status', '--app', id, '--output', 'json']),
|
|
39
|
+
run(['validate', '--app', id, '--deep', '--output', 'json'], { timeout: 120_000 }),
|
|
40
|
+
])
|
|
41
|
+
return { status: status.data ?? null, report: report.data ?? null, error: status.error ?? report.error ?? null }
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/** One of the fixes the person starts: price Free, every country, or the reviewer's sign-in. */
|
|
45
|
+
async asc_launch_fix(args) {
|
|
46
|
+
const id = appId(args)
|
|
47
|
+
const write = argv => run([...argv, '--output', 'json'], { readOnly: false })
|
|
48
|
+
if (args.action === 'free')
|
|
49
|
+
return write(['pricing', 'schedule', 'create', '--app', id, '--free', '--base-territory', 'US'])
|
|
50
|
+
if (args.action === 'everywhere')
|
|
51
|
+
return write([
|
|
52
|
+
'pricing', 'availability', 'create', '--app', id, '--all-territories', '--available', 'true',
|
|
53
|
+
'--available-in-new-territories', 'true', '--if-exists', 'update',
|
|
54
|
+
])
|
|
55
|
+
if (args.action === 'reviewer') {
|
|
56
|
+
const detail = String(args.detail_id ?? '')
|
|
57
|
+
const name = String(args.name ?? '').trim()
|
|
58
|
+
const password = String(args.password ?? '')
|
|
59
|
+
if (!UUID.test(detail) || !name || !password || password.length > 100)
|
|
60
|
+
return { error: 'the reviewer needs a username and a password (100 characters or fewer)' }
|
|
61
|
+
// The password goes to asc through its environment (@env:), never on a command line.
|
|
62
|
+
return run(
|
|
63
|
+
[
|
|
64
|
+
'review', 'details-update', '--id', detail, '--demo-account-required', 'true',
|
|
65
|
+
'--demo-account-name', name, '--demo-account-password', '@env:APPOLLO_REVIEWER_PASSWORD', '--output', 'json',
|
|
66
|
+
],
|
|
67
|
+
{ readOnly: false, extraEnv: { APPOLLO_REVIEWER_PASSWORD: password } },
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
return { error: 'not a fix Appollo knows' }
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
/** Folders under ~/Developer whose project names this bundle id. */
|
|
74
|
+
appollo_find_project(args) {
|
|
75
|
+
const bundleId = String(args.bundle_id ?? '')
|
|
76
|
+
if (!BUNDLE_ID.test(bundleId)) return Promise.resolve({ error: 'not a bundle id' })
|
|
77
|
+
const grep = [
|
|
78
|
+
'-rlF', ...PROJECT_FILES.map(f => `--include=${f}`), ...SKIP_DIRS.map(d => `--exclude-dir=${d}`),
|
|
79
|
+
'-e', `"${bundleId}"`, '-e', `= ${bundleId};`, '-e', `: ${bundleId}`, FIND_ROOT,
|
|
80
|
+
]
|
|
81
|
+
return new Promise(resolve => {
|
|
82
|
+
execFile('grep', grep, { timeout: FIND_MS, maxBuffer: 1024 * 1024 }, (_err, stdout) => {
|
|
83
|
+
const all = [...new Set(String(stdout ?? '').split('\n').filter(Boolean).map(projectFolder))]
|
|
84
|
+
// An Expo app's ios/ folder names the bundle id too: keep only the outermost project.
|
|
85
|
+
const folders = all.filter(f => !all.some(o => o !== f && f.startsWith(`${o}/`)))
|
|
86
|
+
resolve({ folders: folders.slice(0, 5).map(f => f.replace(homedir(), '~')) })
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
},
|
|
90
|
+
}
|
package/src/appollo/asc-run.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Running the asc CLI on this Mac (Appollo's way to App Store Connect): reads with ASC_READ_ONLY=1
|
|
2
|
-
// unless told otherwise, answered as JSON.
|
|
2
|
+
// unless told otherwise, answered as JSON. A report that fails on purpose (asc validate exits 1 when
|
|
3
|
+
// something blocks submission) still answers with its JSON.
|
|
3
4
|
import { execFile } from 'node:child_process'
|
|
4
5
|
import { existsSync } from 'node:fs'
|
|
5
6
|
|
|
@@ -23,12 +24,15 @@ export function run(args, { readOnly = true, extraEnv = {}, timeout = READ_TIMEO
|
|
|
23
24
|
const out = String(stdout ?? '').trim()
|
|
24
25
|
if (err && err.code === 'ENOENT')
|
|
25
26
|
return resolve({ error: 'asc is not installed on this Mac (brew install asc)', missing: true })
|
|
26
|
-
|
|
27
|
+
let data = out
|
|
27
28
|
try {
|
|
28
|
-
|
|
29
|
+
data = out ? JSON.parse(out) : null
|
|
29
30
|
} catch {
|
|
30
|
-
|
|
31
|
+
// Not JSON: asc's text.
|
|
31
32
|
}
|
|
33
|
+
if (err && !(data && typeof data === 'object'))
|
|
34
|
+
return resolve({ error: (String(stderr ?? '').trim() || err.message).slice(0, 2000) })
|
|
35
|
+
resolve({ data })
|
|
32
36
|
},
|
|
33
37
|
)
|
|
34
38
|
})
|
package/src/appollo/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Appollo (POC) on this Mac: App Store Connect through asc, each app's workspace in ~/Appollo, and
|
|
2
2
|
// the Doctor's checks. One export, so the bridge registers Appollo in one line (and drops it in one).
|
|
3
3
|
import { ascTools } from './asc.mjs'
|
|
4
|
+
import { ascLaunchTools } from './asc-launch.mjs'
|
|
4
5
|
import { doctorTools } from './doctor.mjs'
|
|
5
6
|
import { appolloTools } from './workspace.mjs'
|
|
6
7
|
|
|
7
|
-
export const appolloBridgeTools = { ...ascTools, ...appolloTools, ...doctorTools }
|
|
8
|
+
export const appolloBridgeTools = { ...ascTools, ...ascLaunchTools, ...appolloTools, ...doctorTools }
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Where a GitHub repo is already checked out on this Mac, so Kira links that folder instead of guessing a
|
|
2
|
+
// path or cloning another copy: the folders Claude Code has worked in (and the checkouts they sit in),
|
|
3
|
+
// then a shallow look through the usual places people keep code. Reads .git/config, runs nothing.
|
|
4
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
import { listProjects } from './claude-sessions.mjs'
|
|
8
|
+
import { display, remoteToRepo } from './git.mjs'
|
|
9
|
+
|
|
10
|
+
const ROOTS = ['Developer', 'code', 'src', 'Projects', 'dev', 'repos', 'work']
|
|
11
|
+
const DEPTH = 3
|
|
12
|
+
const BUDGET_MS = 4000
|
|
13
|
+
const SKIP = new Set(['node_modules', 'Library', 'Applications', 'Pictures', 'Music', 'Movies'])
|
|
14
|
+
|
|
15
|
+
/** The origin's URL in a .git/config's text, or null. */
|
|
16
|
+
export function originOf(config) {
|
|
17
|
+
let inOrigin = false
|
|
18
|
+
for (const raw of String(config ?? '').split('\n')) {
|
|
19
|
+
const line = raw.trim()
|
|
20
|
+
if (line.startsWith('[')) inOrigin = /^\[remote\s+"origin"\]$/.test(line)
|
|
21
|
+
else if (inOrigin) {
|
|
22
|
+
const m = /^url\s*=\s*(.+)$/.exec(line)
|
|
23
|
+
if (m) return m[1].trim()
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sameRepo = (a, b) => !!a && !!b && a.toLowerCase() === b.toLowerCase()
|
|
30
|
+
const isDir = p => stat(p).then(s => s.isDirectory()).catch(() => false)
|
|
31
|
+
|
|
32
|
+
/** The repo a checkout's origin points at (a main checkout: .git is a folder, not a worktree's file). */
|
|
33
|
+
async function repoAt(dir) {
|
|
34
|
+
if (!(await isDir(join(dir, '.git')))) return null
|
|
35
|
+
return remoteToRepo(originOf(await readFile(join(dir, '.git', 'config'), 'utf8').catch(() => '')))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The checkout a folder sits in (itself or a parent), or null. */
|
|
39
|
+
async function checkoutAround(dir) {
|
|
40
|
+
for (let d = dir; d && d !== dirname(d); d = dirname(d)) {
|
|
41
|
+
if (await isDir(join(d, '.git'))) return d
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Checkouts under a folder, a few levels down; a checkout isn't looked inside. */
|
|
47
|
+
async function scan(dir, depth, until, found) {
|
|
48
|
+
if (depth < 0 || Date.now() > until) return
|
|
49
|
+
if (await isDir(join(dir, '.git'))) return found.add(dir)
|
|
50
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
if (!e.isDirectory() || e.name.startsWith('.') || SKIP.has(e.name)) continue
|
|
53
|
+
await scan(join(dir, e.name), depth - 1, until, found)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every checkout of `repo` ("owner/name") on this Mac, as ~ paths; Kira's own worktrees aren't. */
|
|
58
|
+
export async function findCheckouts(repo, { home = homedir(), known = null } = {}) {
|
|
59
|
+
const candidates = new Set()
|
|
60
|
+
const worked = known ?? ((await listProjects(50).catch(() => ({ projects: [] }))).projects ?? []).map(p => p.cwd)
|
|
61
|
+
for (const cwd of worked) {
|
|
62
|
+
const top = await checkoutAround(cwd)
|
|
63
|
+
if (top) candidates.add(top)
|
|
64
|
+
}
|
|
65
|
+
const until = Date.now() + BUDGET_MS
|
|
66
|
+
for (const root of ROOTS) await scan(join(home, root), DEPTH, until, candidates)
|
|
67
|
+
const kira = join(home, '.kira')
|
|
68
|
+
const matches = []
|
|
69
|
+
for (const dir of candidates) {
|
|
70
|
+
if (dir.startsWith(kira)) continue
|
|
71
|
+
if (sameRepo(await repoAt(dir), repo)) matches.push(dir)
|
|
72
|
+
}
|
|
73
|
+
return { repo, checkouts: [...new Set(matches)].sort().map(display) }
|
|
74
|
+
}
|
package/src/config-folders.mjs
CHANGED
|
@@ -38,7 +38,7 @@ export const skillFolders = () => [
|
|
|
38
38
|
{ root: FOLDERS.codex.skills(), loadedBy: ['codex'] },
|
|
39
39
|
]
|
|
40
40
|
|
|
41
|
-
/** The subagent folders, their file format and who loads each; Kira writes
|
|
41
|
+
/** The subagent folders, their file format and who loads each; Kira writes both (agent-files.mjs). */
|
|
42
42
|
export const agentFolders = () => [
|
|
43
43
|
{ root: FOLDERS.claude.agents(), loadedBy: ['claude'], ext: '.md' },
|
|
44
44
|
{ root: FOLDERS.codex.agents(), loadedBy: ['codex'], ext: '.toml' },
|
package/src/git.mjs
CHANGED
|
@@ -65,9 +65,9 @@ export async function snapshot(dir) {
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const m = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)
|
|
68
|
+
/** "owner/name" from a GitHub remote URL (ssh or https), or null. */
|
|
69
|
+
export function remoteToRepo(url) {
|
|
70
|
+
const m = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(String(url ?? '').trim())
|
|
71
71
|
return m ? m[1] : null
|
|
72
72
|
}
|
|
73
73
|
|
package/src/kira-plugin.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// Kira's built-in skills
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// with the job (the Agent SDK's `plugins
|
|
1
|
+
// Kira's built-in skills, one folder on this Mac that both agents load as their own. The API sends
|
|
2
|
+
// the ones a person's Kira has with each job (persona.skills); this brings the folder in line with
|
|
3
|
+
// them (each written whole, the ones gone removed). It is a Claude Code plugin
|
|
4
|
+
// (~/.kira/claude-plugin: .claude-plugin/plugin.json and skills/<name>/SKILL.md), which Claude Code
|
|
5
|
+
// loads with the job (the Agent SDK's `plugins`: /kira:draft-page). Its skills/ folder is plain
|
|
6
|
+
// Agent Skills (<name>/SKILL.md), which Codex loads as an extra skills root for the job
|
|
7
|
+
// (`skills/extraRoots/set` on its app server: $draft-page).
|
|
6
8
|
import { readdir, rm } from 'node:fs/promises'
|
|
7
9
|
import { join } from 'node:path'
|
|
8
10
|
import { KIRA_DIR } from './config.mjs'
|
|
@@ -47,15 +49,27 @@ export function writeKiraPlugin(skills, root = pluginRoot()) {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
/**
|
|
50
|
-
* The
|
|
51
|
-
*
|
|
52
|
+
* The folder written for a job's skills, or null when it has none or it can't be written (the job
|
|
53
|
+
* still runs; `warn` says why, naming the agent that goes without them).
|
|
52
54
|
*/
|
|
53
|
-
|
|
54
|
-
if (!Array.isArray(persona?.skills) || !persona.skills.length) return
|
|
55
|
+
async function writtenFor(persona, agent, warn, root) {
|
|
56
|
+
if (!Array.isArray(persona?.skills) || !persona.skills.length) return null
|
|
55
57
|
try {
|
|
56
|
-
return
|
|
58
|
+
return await writeKiraPlugin(persona.skills, root)
|
|
57
59
|
} catch (err) {
|
|
58
|
-
warn(`Kira's skills couldn't be written for
|
|
59
|
-
return
|
|
60
|
+
warn(`Kira's skills couldn't be written for ${agent}: ${err.message}`)
|
|
61
|
+
return null
|
|
60
62
|
}
|
|
61
63
|
}
|
|
64
|
+
|
|
65
|
+
/** The Agent SDK query options that load Kira's skills for a Claude Code job (none without them). */
|
|
66
|
+
export async function kiraPluginOptions(persona, warn = log, root = pluginRoot()) {
|
|
67
|
+
const path = await writtenFor(persona, 'Claude Code', warn, root)
|
|
68
|
+
return path ? { plugins: [{ type: 'local', path }] } : {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The skills roots Codex is given for a job (`skills/extraRoots/set`): the plugin's skills/ folder, or none. */
|
|
72
|
+
export async function kiraSkillRoots(persona, warn = log, root = pluginRoot()) {
|
|
73
|
+
const path = await writtenFor(persona, 'Codex', warn, root)
|
|
74
|
+
return path ? [join(path, 'skills')] : []
|
|
75
|
+
}
|
package/src/mac-tools.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// The bridge's tools for this Mac itself: its apps, a terminal, a
|
|
1
|
+
// The bridge's tools for this Mac itself: its apps, a terminal, a project's folder (found or new), and
|
|
2
2
|
// (inside the Mac app only) its shells for other devices and its browser dock.
|
|
3
3
|
import { execFile } from 'node:child_process'
|
|
4
4
|
import { openApp, installedApps } from './apps.mjs'
|
|
5
5
|
import { resolveUserPath } from './files.mjs'
|
|
6
6
|
import { display, setupProjectFolder } from './git.mjs'
|
|
7
|
+
import { findCheckouts } from './checkouts.mjs'
|
|
7
8
|
import { terminalCall } from './terminals.mjs'
|
|
8
9
|
import { safeCommand, terminalScript } from './terminal-command.mjs'
|
|
9
10
|
import { host } from './host.mjs'
|
|
@@ -28,6 +29,13 @@ export const macTools = {
|
|
|
28
29
|
})
|
|
29
30
|
},
|
|
30
31
|
|
|
32
|
+
/** Where a repo ("owner/name") is already checked out here, so a project links it instead of cloning. */
|
|
33
|
+
async find_checkouts(args) {
|
|
34
|
+
const repo = String(args.repo ?? '').trim()
|
|
35
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) return { error: 'repo must be "owner/name"' }
|
|
36
|
+
return findCheckouts(repo)
|
|
37
|
+
},
|
|
38
|
+
|
|
31
39
|
/** This Mac's shells for a browser elsewhere (only inside the Mac app, only when switched on there). */
|
|
32
40
|
terminal: args => terminalCall(args),
|
|
33
41
|
|
package/src/worktrees.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { mkdir, stat, readFile, writeFile, appendFile, rm } from 'node:fs/promis
|
|
|
9
9
|
import { pipeline } from 'node:stream/promises'
|
|
10
10
|
import { createWriteStream } from 'node:fs'
|
|
11
11
|
import { Readable } from 'node:stream'
|
|
12
|
-
import { display } from './git.mjs'
|
|
12
|
+
import { display, remoteToRepo } from './git.mjs'
|
|
13
13
|
import { resolveUserPath } from './files.mjs'
|
|
14
14
|
|
|
15
15
|
const ROOT = join(homedir(), '.kira', 'worktrees')
|
|
@@ -24,11 +24,6 @@ function git(cwd, args, { timeout = 60_000 } = {}) {
|
|
|
24
24
|
})
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function remoteToRepo(url) {
|
|
28
|
-
const m = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/.exec(url ?? '')
|
|
29
|
-
return m ? m[1] : null
|
|
30
|
-
}
|
|
31
|
-
|
|
32
27
|
const sameRepo = (a, b) => !!a && !!b && a.toLowerCase() === b.toLowerCase()
|
|
33
28
|
|
|
34
29
|
/** The GitHub repo a folder's origin points at, or null. */
|