kira-glasses-bridge 2.13.0 → 2.14.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 +1 -1
- package/src/claude-designs.mjs +180 -0
- package/src/index.mjs +4 -0
package/package.json
CHANGED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Designs Claude Code made on this Mac: artifacts it published to claude.ai, and the HTML/SVG
|
|
2
|
+
// mockups it wrote (design/ folders, Claude Design projects, scratchpad pages). Read through the
|
|
3
|
+
// Agent SDK like claude-sessions.mjs. A design's HTML comes from the file when it's still on disk,
|
|
4
|
+
// and otherwise from the transcript itself (its Write, then its Edits replayed), since most were
|
|
5
|
+
// written to a scratchpad that macOS has since cleared.
|
|
6
|
+
import { getSessionMessages, getSubagentMessages, listSessions as sdkListSessions, listSubagents } from '@anthropic-ai/claude-agent-sdk'
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { basename, dirname, extname, relative } from 'node:path'
|
|
11
|
+
|
|
12
|
+
const MAX_BYTES = 2 * 1024 * 1024
|
|
13
|
+
const DESIGN_DIR = /^(design|designs|mock|mocks|mockups?|prototypes?|wireframes?)$/i
|
|
14
|
+
const DESIGN_NAME = /(mock|design|prototype|wireframe|preview)/i
|
|
15
|
+
const NEVER = /^(node_modules|dist|build|out|\.git|\.next|coverage)$/
|
|
16
|
+
|
|
17
|
+
const sha = s => createHash('sha256').update(s).digest('hex')
|
|
18
|
+
|
|
19
|
+
/** Whether a file Claude wrote is a design rather than app source. */
|
|
20
|
+
function isDesignPath(path) {
|
|
21
|
+
const ext = extname(path).toLowerCase()
|
|
22
|
+
if (ext !== '.html' && ext !== '.svg') return false
|
|
23
|
+
const parts = path.split('/')
|
|
24
|
+
if (parts.some(p => NEVER.test(p))) return false
|
|
25
|
+
if (path.includes('/scratchpad/') || path.endsWith('.dc.html')) return true
|
|
26
|
+
return parts.slice(0, -1).some(p => DESIGN_DIR.test(p)) || DESIGN_NAME.test(basename(path))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The folder a design sits in, as a short label that groups a set of screens ("kira-cards", "design/assignee-menu"). */
|
|
30
|
+
function collectionOf(path, cwd) {
|
|
31
|
+
const pad = path.indexOf('/scratchpad/')
|
|
32
|
+
let dir = pad >= 0 ? dirname(path.slice(pad + '/scratchpad/'.length)) : cwd && path.startsWith(cwd + '/') ? dirname(relative(cwd, path)) : basename(dirname(path))
|
|
33
|
+
dir = dir.replace(/\/project$/, '')
|
|
34
|
+
return dir && dir !== '.' ? dir : null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The repo a session worked in: Kira task worktrees are ~/.kira/worktrees/<owner>/<repo>/<task>. */
|
|
38
|
+
function repoOf(cwd) {
|
|
39
|
+
if (!cwd) return null
|
|
40
|
+
if (cwd === homedir()) return 'Home'
|
|
41
|
+
const m = /\/\.kira\/worktrees\/[^/]+\/([^/]+)\//.exec(cwd + '/')
|
|
42
|
+
return m ? m[1] : basename(cwd)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function titleOf(path, html) {
|
|
46
|
+
const t = /<title[^>]*>([^<]{1,120})<\/title>/i.exec(html ?? '')?.[1]?.trim()
|
|
47
|
+
if (t) return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
|
48
|
+
return basename(path).replace(/(\.dc)?\.(html|svg)$/i, '').replace(/^\d+[-_]/, '').replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function applyEdit(text, e) {
|
|
52
|
+
if (text == null || typeof e?.old_string !== 'string' || !text.includes(e.old_string)) return null
|
|
53
|
+
return e.replace_all ? text.split(e.old_string).join(e.new_string ?? '') : text.replace(e.old_string, () => e.new_string ?? '')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const resultText = c => (typeof c === 'string' ? c : Array.isArray(c) ? c.map(b => b?.text ?? '').join('\n') : '')
|
|
57
|
+
|
|
58
|
+
/** Every design one session made, with its latest HTML as of the end of the transcript. */
|
|
59
|
+
async function designsOf(s) {
|
|
60
|
+
const opts = s.cwd ? { dir: s.cwd } : undefined
|
|
61
|
+
// Subagents write designs too (a design pass is often delegated), so read their transcripts after the session's own.
|
|
62
|
+
const messages = await getSessionMessages(s.sessionId, opts)
|
|
63
|
+
for (const agent of await listSubagents(s.sessionId, opts).catch(() => [])) messages.push(...(await getSubagentMessages(s.sessionId, agent, opts).catch(() => [])))
|
|
64
|
+
const files = new Map() // path -> { content, created, updated }
|
|
65
|
+
const publishes = new Map() // tool_use id -> Artifact publish input
|
|
66
|
+
const artifacts = new Map() // url -> { path, description, icon, title, version, created, updated }
|
|
67
|
+
for (const m of messages) {
|
|
68
|
+
const at = m.timestamp ?? null
|
|
69
|
+
const content = m.message?.content
|
|
70
|
+
if (!Array.isArray(content)) continue
|
|
71
|
+
for (const b of content) {
|
|
72
|
+
if (b?.type === 'tool_use') {
|
|
73
|
+
const i = b.input ?? {}
|
|
74
|
+
const path = typeof i.file_path === 'string' ? i.file_path : null
|
|
75
|
+
if (b.name === 'Write' && path) {
|
|
76
|
+
const f = files.get(path)
|
|
77
|
+
files.set(path, { content: typeof i.content === 'string' ? i.content : null, created: f?.created ?? at, updated: at })
|
|
78
|
+
} else if ((b.name === 'Edit' || b.name === 'MultiEdit') && path && files.has(path)) {
|
|
79
|
+
const f = files.get(path)
|
|
80
|
+
for (const e of b.name === 'Edit' ? [i] : (i.edits ?? [])) f.content = applyEdit(f.content, e)
|
|
81
|
+
f.updated = at
|
|
82
|
+
} else if (b.name === 'Artifact' && (i.action ?? 'publish') === 'publish' && !i.asset) {
|
|
83
|
+
publishes.set(b.id, i)
|
|
84
|
+
}
|
|
85
|
+
} else if (b?.type === 'tool_result' && publishes.has(b.tool_use_id)) {
|
|
86
|
+
const i = publishes.get(b.tool_use_id)
|
|
87
|
+
const m2 = /at (https:\/\/claude\.ai\/\S*artifact\/[\w-]+)(?: \(Version (\d+)\))?/.exec(resultText(b.content))
|
|
88
|
+
if (!m2 || b.is_error) continue
|
|
89
|
+
const prev = artifacts.get(m2[1])
|
|
90
|
+
artifacts.set(m2[1], {
|
|
91
|
+
path: i.file_path ?? prev?.path ?? null,
|
|
92
|
+
description: i.description ?? prev?.description ?? null,
|
|
93
|
+
icon: i.icon ?? prev?.icon ?? null,
|
|
94
|
+
title: i.title ?? prev?.title ?? null,
|
|
95
|
+
version: Number(m2[2]) || prev?.version || 1,
|
|
96
|
+
created: prev?.created ?? at,
|
|
97
|
+
updated: at,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const out = []
|
|
104
|
+
const published = new Map([...artifacts].filter(([, a]) => a.path).map(([url, a]) => [a.path, { url, ...a }]))
|
|
105
|
+
const base = { session_id: s.sessionId, session_title: s.customTitle || s.summary || s.firstPrompt?.slice(0, 80) || null, cwd: s.cwd ?? null, repo: repoOf(s.cwd) }
|
|
106
|
+
for (const [path, f] of files) {
|
|
107
|
+
const art = published.get(path)
|
|
108
|
+
if (!art && !isDesignPath(path)) continue
|
|
109
|
+
const onDisk = await stat(path).then(st => st.isFile() && st.size <= MAX_BYTES, () => false)
|
|
110
|
+
const html = onDisk ? await readFile(path, 'utf8').catch(() => f.content) : f.content
|
|
111
|
+
if (!html || Buffer.byteLength(html) > MAX_BYTES) continue
|
|
112
|
+
out.push({
|
|
113
|
+
...base,
|
|
114
|
+
key: sha(`${s.sessionId}:${path}`).slice(0, 32),
|
|
115
|
+
kind: art ? 'artifact' : path.endsWith('.dc.html') ? 'claude_design' : extname(path).slice(1).toLowerCase(),
|
|
116
|
+
title: art?.title || titleOf(path, html),
|
|
117
|
+
description: art?.description ?? null,
|
|
118
|
+
icon: art?.icon ?? null,
|
|
119
|
+
path: path.replace(homedir(), '~'),
|
|
120
|
+
collection: collectionOf(path, s.cwd),
|
|
121
|
+
artifact_url: art?.url ?? null,
|
|
122
|
+
artifact_version: art?.version ?? null,
|
|
123
|
+
on_disk: onDisk,
|
|
124
|
+
created_at: art?.created ?? f.created,
|
|
125
|
+
updated_at: art?.updated && art.updated > f.updated ? art.updated : f.updated,
|
|
126
|
+
content_hash: sha(html),
|
|
127
|
+
bytes: Buffer.byteLength(html),
|
|
128
|
+
html,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
// Published without a file Claude wrote in this session (a deck, a doc, or a page from an earlier one).
|
|
132
|
+
for (const [url, a] of artifacts) {
|
|
133
|
+
if (a.path && files.has(a.path)) continue
|
|
134
|
+
const html = a.path ? await readFile(a.path, 'utf8').catch(() => null) : null
|
|
135
|
+
out.push({
|
|
136
|
+
...base,
|
|
137
|
+
key: sha(url).slice(0, 32),
|
|
138
|
+
kind: 'artifact',
|
|
139
|
+
title: a.title || (a.path ? titleOf(a.path, html) : 'Untitled artifact'),
|
|
140
|
+
description: a.description,
|
|
141
|
+
icon: a.icon,
|
|
142
|
+
path: a.path ? a.path.replace(homedir(), '~') : null,
|
|
143
|
+
collection: a.path ? collectionOf(a.path, s.cwd) : null,
|
|
144
|
+
artifact_url: url,
|
|
145
|
+
artifact_version: a.version,
|
|
146
|
+
on_disk: !!html,
|
|
147
|
+
created_at: a.created,
|
|
148
|
+
updated_at: a.updated,
|
|
149
|
+
content_hash: html ? sha(html) : null,
|
|
150
|
+
bytes: html ? Buffer.byteLength(html) : 0,
|
|
151
|
+
html,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Scan every session changed since `since` (ISO time; all of them when omitted). Returns the
|
|
159
|
+
* designs without their HTML, plus the HTML of those whose hash isn't in `known` (hashes the API
|
|
160
|
+
* already holds), so a rescan sends only what changed.
|
|
161
|
+
*/
|
|
162
|
+
export async function scanDesigns({ since, known = [] } = {}) {
|
|
163
|
+
const after = since ? Date.parse(since) || 0 : 0
|
|
164
|
+
const have = new Set(known)
|
|
165
|
+
const sessions = (await sdkListSessions()).filter(s => s.lastModified > after)
|
|
166
|
+
const designs = []
|
|
167
|
+
let failed = 0
|
|
168
|
+
for (const s of sessions) {
|
|
169
|
+
try {
|
|
170
|
+
designs.push(...(await designsOf(s)))
|
|
171
|
+
} catch {
|
|
172
|
+
failed++
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
scanned: sessions.length,
|
|
177
|
+
failed,
|
|
178
|
+
designs: designs.map(({ html, ...d }) => ({ ...d, html: html && !have.has(d.content_hash) ? html : undefined })),
|
|
179
|
+
}
|
|
180
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
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 { scanDesigns } from './claude-designs.mjs'
|
|
6
7
|
import { snapshot, changesSince, display, setupProjectFolder } from './git.mjs'
|
|
7
8
|
import { resolveCwd, folderExists } from './claude-run.mjs'
|
|
8
9
|
import { agentById, AGENTS } from './agents/index.mjs'
|
|
@@ -110,6 +111,9 @@ const tools = {
|
|
|
110
111
|
async list_claude_projects(args) {
|
|
111
112
|
return listProjects(args.max)
|
|
112
113
|
},
|
|
114
|
+
async scan_claude_designs(args) {
|
|
115
|
+
return scanDesigns({ since: args.since, known: Array.isArray(args.known) ? args.known : [] })
|
|
116
|
+
},
|
|
113
117
|
async list_claude_sessions(args) {
|
|
114
118
|
return listSessions(args.max)
|
|
115
119
|
},
|