openrune 0.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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/.claude-plugin/plugin.json +24 -0
  3. package/LICENSE +21 -0
  4. package/README.md +257 -0
  5. package/bin/rune.js +718 -0
  6. package/bootstrap.js +4 -0
  7. package/channel/rune-channel.ts +467 -0
  8. package/electron-builder.yml +61 -0
  9. package/finder-extension/FinderSync.swift +47 -0
  10. package/finder-extension/RuneFinderSync.appex/Contents/Info.plist +27 -0
  11. package/finder-extension/RuneFinderSync.appex/Contents/MacOS/RuneFinderSync +0 -0
  12. package/finder-extension/main.swift +5 -0
  13. package/package.json +53 -0
  14. package/renderer/index.html +12 -0
  15. package/renderer/src/App.tsx +43 -0
  16. package/renderer/src/features/chat/activity-block.tsx +152 -0
  17. package/renderer/src/features/chat/chat-header.tsx +58 -0
  18. package/renderer/src/features/chat/chat-input.tsx +190 -0
  19. package/renderer/src/features/chat/chat-panel.tsx +150 -0
  20. package/renderer/src/features/chat/markdown-renderer.tsx +26 -0
  21. package/renderer/src/features/chat/message-bubble.tsx +79 -0
  22. package/renderer/src/features/chat/message-list.tsx +178 -0
  23. package/renderer/src/features/chat/types.ts +32 -0
  24. package/renderer/src/features/chat/use-chat.ts +251 -0
  25. package/renderer/src/features/terminal/terminal-panel.tsx +132 -0
  26. package/renderer/src/global.d.ts +29 -0
  27. package/renderer/src/globals.css +92 -0
  28. package/renderer/src/hooks/use-ipc.ts +24 -0
  29. package/renderer/src/lib/markdown.ts +83 -0
  30. package/renderer/src/lib/utils.ts +6 -0
  31. package/renderer/src/main.tsx +10 -0
  32. package/renderer/tsconfig.json +16 -0
  33. package/renderer/vite.config.ts +23 -0
  34. package/src/main.ts +782 -0
  35. package/src/preload.ts +58 -0
  36. package/tsconfig.json +14 -0
package/bootstrap.js ADDED
@@ -0,0 +1,4 @@
1
+ // Simple entry point — just load the bundled main process
2
+ // Note: require('electron') works because Electron intercepts
3
+ // the npm package resolution and returns its built-in module
4
+ require('./dist/main.js')
@@ -0,0 +1,467 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
4
+ import * as http from 'http'
5
+
6
+ import * as fs from 'fs'
7
+
8
+ const PORT = Number(process.env.RUNE_CHANNEL_PORT || 51234)
9
+ const FOLDER_PATH = process.env.RUNE_FOLDER_PATH || ''
10
+ const AGENT_ROLE = process.env.RUNE_AGENT_ROLE || ''
11
+ const RUNE_FILE_PATH = process.env.RUNE_FILE_PATH || ''
12
+
13
+ // ── .rune File I/O ──────────────────────────────
14
+ interface RuneFile {
15
+ name: string
16
+ role: string
17
+ history?: { role: 'user' | 'assistant'; text: string; ts: number }[]
18
+ memory?: string[]
19
+ [key: string]: unknown
20
+ }
21
+
22
+ function readRuneFile(): RuneFile | null {
23
+ if (!RUNE_FILE_PATH) return null
24
+ try {
25
+ return JSON.parse(fs.readFileSync(RUNE_FILE_PATH, 'utf-8'))
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
31
+ function writeRuneFile(data: RuneFile) {
32
+ if (!RUNE_FILE_PATH) return
33
+ try {
34
+ fs.writeFileSync(RUNE_FILE_PATH, JSON.stringify(data, null, 2), 'utf-8')
35
+ } catch (e: any) {
36
+ console.error(`[rune-channel] Failed to write .rune file: ${e.message}`)
37
+ }
38
+ }
39
+
40
+ function buildSessionContext(): string {
41
+ const rune = readRuneFile()
42
+ if (!rune) return ''
43
+
44
+ const parts: string[] = []
45
+
46
+ // Memory
47
+ if (rune.memory && rune.memory.length > 0) {
48
+ parts.push('## Saved Memory')
49
+ parts.push('These are notes you saved from previous sessions:')
50
+ rune.memory.forEach((m, i) => parts.push(`${i + 1}. ${m}`))
51
+ }
52
+
53
+ // History summary (last 20 messages condensed)
54
+ if (rune.history && rune.history.length > 0) {
55
+ const recent = rune.history.slice(-20)
56
+ parts.push('\n## Recent Conversation History')
57
+ parts.push(`(${rune.history.length} total messages, showing last ${recent.length})`)
58
+ for (const msg of recent) {
59
+ const who = msg.role === 'user' ? 'User' : 'You'
60
+ const text = msg.text.length > 200 ? msg.text.slice(0, 200) + '...' : msg.text
61
+ parts.push(`- **${who}**: ${text}`)
62
+ }
63
+ }
64
+
65
+ return parts.join('\n')
66
+ }
67
+
68
+ // ── MCP Server ──────────────────────────────────
69
+
70
+ const mcp = new Server(
71
+ { name: 'rune-channel', version: '0.1.0' },
72
+ {
73
+ capabilities: {
74
+ experimental: { 'claude/channel': {} },
75
+ tools: {},
76
+ },
77
+ instructions: `You are an AI agent running inside the Rune desktop app.
78
+
79
+ ## System Info
80
+ - Current time: ${new Date().toISOString()} (${Intl.DateTimeFormat().resolvedOptions().timeZone})
81
+ - Local time: ${new Date().toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, hour12: false })}
82
+ - Working folder: ${FOLDER_PATH || '(none)'}
83
+ ${AGENT_ROLE ? `\n## Your Role\n${AGENT_ROLE}\n` : ''}
84
+ Messages arrive as <channel source="rune-channel" type="..." request_id="...">.
85
+
86
+ ## Message types
87
+ - type="chat": User message from the Rune chat UI. Always reply using the rune_reply tool with the request_id.
88
+
89
+ ## CRITICAL: ALL output MUST go through rune_reply
90
+ You are connected to a desktop app UI. The user CANNOT see your regular text output — they can ONLY see messages sent via rune_reply.
91
+ - NEVER output plain text without calling rune_reply. Any text not sent through rune_reply is invisible to the user.
92
+ - For chat messages: call rune_reply with the request_id.
93
+ - For proactive updates: call rune_reply WITHOUT request_id to push via SSE.
94
+ - If you need to do multi-step work (fetch data, analyze, etc.), do all the work FIRST, then send ONE comprehensive rune_reply at the end.
95
+ - Even error messages and status updates must go through rune_reply.
96
+
97
+ ## CRITICAL: Report ALL activities via rune_activity
98
+ The user wants to see EVERYTHING you do — thinking, tool calls, and results — in real-time, just like the Claude Code VSCode plugin.
99
+ - BEFORE you start working on a request, call rune_activity with type="thinking" and share your plan/reasoning.
100
+ - BEFORE each tool call (Read, Edit, Bash, Write, Grep, Glob, etc.), call rune_activity with type="tool_use", tool name, and key args.
101
+ - AFTER each tool call completes, call rune_activity with type="tool_result", tool name, and a brief summary of the result.
102
+ - This makes your work transparent. Without rune_activity calls, the user sees a blank screen until rune_reply.
103
+ - Keep thinking text concise but informative. For tool results, summarize key findings (1-2 sentences).
104
+ - You MUST call rune_activity for EVERY tool you use. Do NOT skip any.
105
+
106
+ ## CRITICAL: Actions, Not Words
107
+ When the user asks you to do something, ACTUALLY DO IT. Never just describe what you would do.
108
+ - Read files, write code, run commands — take action.
109
+ - Only explain when the user asks for an explanation.
110
+
111
+ ## Memory
112
+ You have a rune_memory tool to save persistent notes across sessions.
113
+ - Save important context: user preferences, project decisions, key findings, recurring patterns.
114
+ - Memory is stored in the .rune file and provided to you at the start of each session.
115
+ - Use it proactively when you learn something worth remembering.
116
+ `,
117
+ }
118
+ )
119
+
120
+ // ── Pending Replies ──────────────────────────────
121
+
122
+ const pendingReplies = new Map<string, (text: string) => void>()
123
+ const sseClients = new Set<http.ServerResponse>()
124
+ let mcpConnected = false
125
+ let sessionStarted = false
126
+
127
+ function broadcastSSE(data: Record<string, unknown>) {
128
+ const msg = `data: ${JSON.stringify(data)}\n\n`
129
+ for (const client of sseClients) {
130
+ try { client.write(msg) } catch {}
131
+ }
132
+ }
133
+
134
+ // ── Tools ────────────────────────────────────────
135
+
136
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
137
+ tools: [
138
+ {
139
+ name: 'rune_reply',
140
+ description: 'Send a reply back to the Rune UI. Use this for every chat message. If request_id is omitted, the message is pushed as a proactive notification.',
141
+ inputSchema: {
142
+ type: 'object' as const,
143
+ properties: {
144
+ request_id: { type: 'string', description: 'The request_id from the incoming channel message. Omit to send a proactive push message.' },
145
+ text: { type: 'string', description: 'Your response in markdown' },
146
+ },
147
+ required: ['text'],
148
+ },
149
+ },
150
+ {
151
+ name: 'rune_activity',
152
+ description: 'Report your current activity to the Rune chat UI in real-time. Call this BEFORE and AFTER each action so the user can see what you are doing. This makes your work visible — without it, the user sees nothing until rune_reply.',
153
+ inputSchema: {
154
+ type: 'object' as const,
155
+ properties: {
156
+ type: {
157
+ type: 'string',
158
+ enum: ['thinking', 'tool_use', 'tool_result'],
159
+ description: 'thinking: share your reasoning/plan. tool_use: report a tool you are about to call. tool_result: report the result of a tool call.',
160
+ },
161
+ content: { type: 'string', description: 'For thinking: your reasoning text. For tool_result: a brief summary of what happened.' },
162
+ tool: { type: 'string', description: 'Tool name (e.g. Read, Edit, Bash, Grep, Write). Required for tool_use and tool_result.' },
163
+ args: {
164
+ type: 'object',
165
+ description: 'Key arguments for the tool call (e.g. {file_path: "/foo.ts"} or {command: "npm test"}). For tool_use only.',
166
+ },
167
+ },
168
+ required: ['type'],
169
+ },
170
+ },
171
+ {
172
+ name: 'rune_search_history',
173
+ description: 'Search past conversation history by keyword. Returns matching messages with surrounding context. Use this when the user references a past conversation or you need to recall previous discussions.',
174
+ inputSchema: {
175
+ type: 'object' as const,
176
+ properties: {
177
+ query: { type: 'string', description: 'Search keyword or phrase to find in past messages' },
178
+ limit: { type: 'number', description: 'Max number of results to return (default: 10)' },
179
+ },
180
+ required: ['query'],
181
+ },
182
+ },
183
+ {
184
+ name: 'rune_memory',
185
+ description: 'Save, list, or delete persistent memory notes in the .rune file. Use this to remember important context across sessions — user preferences, project decisions, key findings, etc. Memory persists even when the session ends.',
186
+ inputSchema: {
187
+ type: 'object' as const,
188
+ properties: {
189
+ action: { type: 'string', enum: ['save', 'list', 'delete'], description: 'save: add a new memory note. list: show all saved memories. delete: remove a memory by index (1-based).' },
190
+ text: { type: 'string', description: 'The memory note to save (required for "save" action)' },
191
+ index: { type: 'number', description: 'The 1-based index of the memory to delete (required for "delete" action)' },
192
+ },
193
+ required: ['action'],
194
+ },
195
+ },
196
+ ],
197
+ }))
198
+
199
+ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
200
+ const toolName = req.params.name
201
+
202
+ // Broadcast tool_start for non-internal tools
203
+ if (toolName !== 'rune_reply' && toolName !== 'rune_memory' && toolName !== 'rune_activity') {
204
+ const argsSummary: Record<string, unknown> = {}
205
+ const rawArgs = req.params.arguments as Record<string, unknown> | undefined
206
+ if (rawArgs) {
207
+ for (const [k, v] of Object.entries(rawArgs)) {
208
+ if (typeof v === 'string' && v.length > 80) argsSummary[k] = (v as string).slice(0, 80) + '…'
209
+ else argsSummary[k] = v
210
+ }
211
+ }
212
+ broadcastSSE({ type: 'tool_start', tool: toolName, args: argsSummary })
213
+ }
214
+
215
+ // rune_activity tool — real-time activity reporting
216
+ if (req.params.name === 'rune_activity') {
217
+ const { type: activityType, content, tool, args } = req.params.arguments as {
218
+ type: string; content?: string; tool?: string; args?: Record<string, unknown>
219
+ }
220
+ broadcastSSE({ type: 'activity', activityType, content, tool, args })
221
+ return { content: [{ type: 'text' as const, text: 'ok' }] }
222
+ }
223
+
224
+ if (req.params.name === 'rune_reply') {
225
+ const { request_id, text } = req.params.arguments as { request_id?: string; text: string }
226
+ if (request_id) {
227
+ const resolve = pendingReplies.get(request_id)
228
+ if (resolve) {
229
+ resolve(text)
230
+ pendingReplies.delete(request_id)
231
+ } else {
232
+ broadcastSSE({ type: 'push', text })
233
+ }
234
+ } else {
235
+ broadcastSSE({ type: 'push', text })
236
+ }
237
+ return { content: [{ type: 'text' as const, text: 'sent' }] }
238
+ }
239
+
240
+ // rune_search_history tool
241
+ if (req.params.name === 'rune_search_history') {
242
+ const { query, limit: maxResults } = req.params.arguments as { query: string; limit?: number }
243
+ const rune = readRuneFile()
244
+ if (!rune || !rune.history || rune.history.length === 0) {
245
+ return { content: [{ type: 'text' as const, text: 'No conversation history found.' }] }
246
+ }
247
+
248
+ const cap = maxResults || 10
249
+ const keywords = query.toLowerCase().split(/\s+/)
250
+ const scored: { idx: number; msg: typeof rune.history[0]; score: number }[] = []
251
+
252
+ for (let i = 0; i < rune.history.length; i++) {
253
+ const text = rune.history[i].text.toLowerCase()
254
+ let score = 0
255
+ for (const kw of keywords) {
256
+ if (text.includes(kw)) score++
257
+ }
258
+ if (score > 0) scored.push({ idx: i, msg: rune.history[i], score })
259
+ }
260
+
261
+ if (scored.length === 0) {
262
+ return { content: [{ type: 'text' as const, text: `No messages matching "${query}".` }] }
263
+ }
264
+
265
+ scored.sort((a, b) => b.score - a.score)
266
+ const results = scored.slice(0, cap)
267
+
268
+ const output = results.map(r => {
269
+ const who = r.msg.role === 'user' ? 'User' : 'Assistant'
270
+ const date = new Date(r.msg.ts).toLocaleString()
271
+ const text = r.msg.text.length > 300 ? r.msg.text.slice(0, 300) + '…' : r.msg.text
272
+ return `[#${r.idx + 1} | ${date} | ${who}]\n${text}`
273
+ }).join('\n\n---\n\n')
274
+
275
+ return { content: [{ type: 'text' as const, text: `Found ${scored.length} matches (showing top ${results.length}):\n\n${output}` }] }
276
+ }
277
+
278
+ // rune_memory tool
279
+ if (req.params.name === 'rune_memory') {
280
+ const { action, text, index } = req.params.arguments as { action: string; text?: string; index?: number }
281
+ const rune = readRuneFile()
282
+ if (!rune) {
283
+ return { content: [{ type: 'text' as const, text: 'No .rune file available' }], isError: true }
284
+ }
285
+ if (!rune.memory) rune.memory = []
286
+
287
+ if (action === 'save') {
288
+ if (!text) return { content: [{ type: 'text' as const, text: 'text is required for save action' }], isError: true }
289
+ rune.memory.push(text)
290
+ writeRuneFile(rune)
291
+ broadcastSSE({ type: 'memory_update' })
292
+ return { content: [{ type: 'text' as const, text: `Memory saved (${rune.memory.length} total)` }] }
293
+ }
294
+
295
+ if (action === 'list') {
296
+ if (rune.memory.length === 0) {
297
+ return { content: [{ type: 'text' as const, text: 'No memories saved yet.' }] }
298
+ }
299
+ const list = rune.memory.map((m, i) => `${i + 1}. ${m}`).join('\n')
300
+ return { content: [{ type: 'text' as const, text: `Saved memories:\n${list}` }] }
301
+ }
302
+
303
+ if (action === 'delete') {
304
+ if (!index || index < 1 || index > rune.memory.length) {
305
+ return { content: [{ type: 'text' as const, text: `Invalid index. Valid range: 1-${rune.memory.length}` }], isError: true }
306
+ }
307
+ const removed = rune.memory.splice(index - 1, 1)
308
+ writeRuneFile(rune)
309
+ broadcastSSE({ type: 'memory_update' })
310
+ return { content: [{ type: 'text' as const, text: `Deleted: "${removed[0]}" (${rune.memory.length} remaining)` }] }
311
+ }
312
+
313
+ return { content: [{ type: 'text' as const, text: `Unknown action: ${action}` }], isError: true }
314
+ }
315
+
316
+ // Unknown tool
317
+ return { content: [{ type: 'text' as const, text: `Unknown tool: ${toolName}` }], isError: true }
318
+ })
319
+
320
+ // ── Main ─────────────────────────────────────────
321
+
322
+ async function main() {
323
+ const transport = new StdioServerTransport()
324
+ await mcp.connect(transport)
325
+ mcpConnected = true
326
+ console.error(`[rune-channel] MCP connected (port=${PORT}, folder=${FOLDER_PATH})`)
327
+
328
+ // Notify Claude about session start with history + memory context
329
+ setTimeout(() => {
330
+ const rune = readRuneFile()
331
+ const hasHistory = rune?.history && rune.history.length > 0
332
+ const sessionContext = buildSessionContext()
333
+ const contextBlock = sessionContext ? `\n\n${sessionContext}` : ''
334
+ const greetInstruction = hasHistory
335
+ ? ''
336
+ : `\n\nThis is a new conversation with no prior history. Greet the user briefly via rune_reply (no request_id). Introduce yourself based on your role and mention the working folder. Keep it short — 1-2 sentences.`
337
+ mcp.notification({
338
+ method: 'notifications/claude/channel',
339
+ params: {
340
+ content: `[SESSION_START] Channel connected. Folder: ${FOLDER_PATH || 'none'}${AGENT_ROLE ? `. Role: ${AGENT_ROLE}` : ''}${contextBlock}\n\nUse the rune_memory tool to save important context that should persist across sessions.${greetInstruction}`,
341
+ meta: { type: 'session_start' },
342
+ },
343
+ }).then(() => {
344
+ sessionStarted = true
345
+ broadcastSSE({ type: 'session_start' })
346
+ }).catch((e: any) => console.error(`[rune-channel] Startup notification failed: ${e.message}`))
347
+ }, 1000)
348
+
349
+ // Handle disconnect
350
+ function handleDisconnect(reason: string) {
351
+ if (!mcpConnected) return
352
+ console.error(`[rune-channel] ${reason}, shutting down`)
353
+ mcpConnected = false
354
+ broadcastSSE({ type: 'mcp_disconnected' })
355
+ setTimeout(() => process.exit(0), 500)
356
+ }
357
+
358
+ mcp.onclose = () => handleDisconnect('MCP connection closed')
359
+ process.stdin.on('end', () => handleDisconnect('stdin closed'))
360
+ process.stdin.on('error', () => handleDisconnect('stdin error'))
361
+
362
+ // ── HTTP server ─────────────────────────────────
363
+
364
+ let reqId = 0
365
+
366
+ const server = http.createServer(async (req, res) => {
367
+ // Hook events from Claude Code hooks system
368
+ if (req.method === 'POST' && req.url === '/hook') {
369
+ let body = ''
370
+ for await (const chunk of req) body += chunk
371
+ try {
372
+ const hookData = JSON.parse(body)
373
+ console.error(`[rune-channel] hook: ${hookData.hook_event_name} ${hookData.tool_name || ''}`)
374
+ broadcastSSE({ type: 'hook', event: hookData.hook_event_name, ...hookData })
375
+ } catch (e: any) {
376
+ console.error(`[rune-channel] hook parse error: ${e.message}`)
377
+ }
378
+ res.writeHead(200, { 'Content-Type': 'application/json' })
379
+ res.end('{"status":"ok"}')
380
+ return
381
+ }
382
+
383
+ // SSE endpoint
384
+ if (req.method === 'GET' && req.url === '/sse') {
385
+ res.writeHead(200, {
386
+ 'Content-Type': 'text/event-stream',
387
+ 'Cache-Control': 'no-cache',
388
+ Connection: 'keep-alive',
389
+ 'Access-Control-Allow-Origin': '*',
390
+ })
391
+ res.write('data: {"type":"connected"}\n\n')
392
+ if (sessionStarted) {
393
+ res.write('data: {"type":"session_start"}\n\n')
394
+ }
395
+ sseClients.add(res)
396
+ console.error(`[rune-channel] SSE client connected (total: ${sseClients.size}, sessionStarted: ${sessionStarted})`)
397
+ req.on('close', () => {
398
+ sseClients.delete(res)
399
+ console.error(`[rune-channel] SSE client disconnected (total: ${sseClients.size})`)
400
+ })
401
+ return
402
+ }
403
+
404
+ // Health check
405
+ if (req.method === 'GET') {
406
+ res.writeHead(200, { 'Content-Type': 'application/json' })
407
+ res.end(JSON.stringify({ status: mcpConnected ? 'ok' : 'no-mcp', name: 'rune-channel', mcpConnected }))
408
+ return
409
+ }
410
+
411
+ // POST: chat message
412
+ try {
413
+ let body = ''
414
+ for await (const chunk of req) body += chunk
415
+ const { type, content } = JSON.parse(body) as { type: string; content: string }
416
+ const id = String(++reqId)
417
+
418
+ console.error(`[rune-channel] received ${type} message (id=${id}): ${content.slice(0, 100)}`)
419
+
420
+ // Push to Claude Code
421
+ try {
422
+ await mcp.notification({
423
+ method: 'notifications/claude/channel',
424
+ params: {
425
+ content,
426
+ meta: { type, request_id: id },
427
+ },
428
+ })
429
+ } catch (notifErr: any) {
430
+ console.error(`[rune-channel] MCP notification FAILED: ${notifErr.message}`)
431
+ }
432
+
433
+ // For chat messages, wait for reply
434
+ if (type === 'chat') {
435
+ const reply = await new Promise<string>((resolve) => {
436
+ pendingReplies.set(id, resolve)
437
+ })
438
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
439
+ res.end(reply)
440
+ return
441
+ }
442
+
443
+ res.writeHead(200)
444
+ res.end('ok')
445
+ } catch (e: any) {
446
+ console.error(`[rune-channel] HTTP error: ${e.message}`)
447
+ res.writeHead(400, { 'Content-Type': 'application/json' })
448
+ res.end(JSON.stringify({ error: e.message }))
449
+ }
450
+ })
451
+
452
+ server.listen(PORT, '127.0.0.1', () => {
453
+ console.error(`[rune-channel] listening on http://127.0.0.1:${PORT}`)
454
+ })
455
+
456
+ // Graceful shutdown: close HTTP server to release port
457
+ const shutdown = () => {
458
+ console.error(`[rune-channel] shutting down, releasing port ${PORT}`)
459
+ server.close()
460
+ process.exit(0)
461
+ }
462
+ process.on('SIGTERM', shutdown)
463
+ process.on('SIGINT', shutdown)
464
+ process.on('SIGHUP', shutdown)
465
+ }
466
+
467
+ main()
@@ -0,0 +1,61 @@
1
+ appId: com.studio-h.rune
2
+ productName: Rune
3
+ copyright: Copyright © 2026 Studio-H
4
+
5
+ directories:
6
+ output: release
7
+ buildResources: assets
8
+
9
+ files:
10
+ - dist/**/*
11
+ - bootstrap.js
12
+ - package.json
13
+ - "!node_modules/**/*"
14
+ - node_modules/node-pty/**/*
15
+ - node_modules/@modelcontextprotocol/**/*
16
+
17
+ asar: true
18
+
19
+ mac:
20
+ category: public.app-category.developer-tools
21
+ target:
22
+ - target: dmg
23
+ arch: [arm64, x64]
24
+ - target: zip
25
+ arch: [arm64, x64]
26
+ entitlements: null
27
+ entitlementsInherit: null
28
+ hardenedRuntime: false
29
+ icon: assets/icon.icns
30
+ fileAssociations:
31
+ - ext: rune
32
+ name: Rune Agent File
33
+ description: Rune AI Agent Configuration
34
+ role: Editor
35
+ icon: assets/rune-file.icns
36
+
37
+ dmg:
38
+ title: Rune
39
+ contents:
40
+ - x: 130
41
+ y: 220
42
+ - x: 410
43
+ y: 220
44
+ type: link
45
+ path: /Applications
46
+
47
+ win:
48
+ target:
49
+ - nsis
50
+ fileAssociations:
51
+ - ext: rune
52
+ name: Rune Agent File
53
+ description: Rune AI Agent Configuration
54
+
55
+ linux:
56
+ target:
57
+ - AppImage
58
+ fileAssociations:
59
+ - ext: rune
60
+ name: Rune Agent File
61
+ mimeType: application/x-rune
@@ -0,0 +1,47 @@
1
+ import Cocoa
2
+ import FinderSync
3
+
4
+ class RuneFinderSync: FIFinderSync {
5
+ override init() {
6
+ super.init()
7
+ // Monitor all directories — menu appears everywhere in Finder
8
+ FIFinderSyncController.default().directoryURLs = [URL(fileURLWithPath: "/")]
9
+ }
10
+
11
+ override func menu(for menuKind: FIMenuKind) -> NSMenu {
12
+ let menu = NSMenu(title: "Rune")
13
+
14
+ // contextualMenuForContainer = background right-click (empty space)
15
+ // contextualMenuForItems = right-click on selected items
16
+ if menuKind == .contextualMenuForContainer || menuKind == .contextualMenuForItems {
17
+ let item = NSMenuItem(
18
+ title: "New Rune",
19
+ action: #selector(createRune(_:)),
20
+ keyEquivalent: ""
21
+ )
22
+ if #available(macOS 11.0, *) {
23
+ item.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Rune")
24
+ }
25
+ menu.addItem(item)
26
+ }
27
+
28
+ return menu
29
+ }
30
+
31
+ @objc func createRune(_ sender: AnyObject?) {
32
+ guard let target = FIFinderSyncController.default().targetedURL() else { return }
33
+ let dirPath = target.path
34
+
35
+ // Use shell to create .rune file and open it
36
+ let script = """
37
+ cd "\(dirPath)" && \
38
+ /usr/local/bin/rune new agent 2>/dev/null || \
39
+ ~/.rune/create-rune.sh "\(dirPath)" 2>/dev/null
40
+ """
41
+
42
+ let task = Process()
43
+ task.executableURL = URL(fileURLWithPath: "/bin/bash")
44
+ task.arguments = ["-c", script]
45
+ try? task.run()
46
+ }
47
+ }
@@ -0,0 +1,27 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleIdentifier</key>
6
+ <string>com.studio-h.rune.finder-sync</string>
7
+ <key>CFBundleName</key>
8
+ <string>RuneFinderSync</string>
9
+ <key>CFBundleDisplayName</key>
10
+ <string>Rune Finder Extension</string>
11
+ <key>CFBundleExecutable</key>
12
+ <string>RuneFinderSync</string>
13
+ <key>CFBundlePackageType</key>
14
+ <string>XPC!</string>
15
+ <key>CFBundleVersion</key>
16
+ <string>0.1.0</string>
17
+ <key>CFBundleShortVersionString</key>
18
+ <string>0.1.0</string>
19
+ <key>NSExtension</key>
20
+ <dict>
21
+ <key>NSExtensionPointIdentifier</key>
22
+ <string>com.apple.FinderSync</string>
23
+ <key>NSExtensionPrincipalClass</key>
24
+ <string>RuneFinderSync.RuneFinderSync</string>
25
+ </dict>
26
+ </dict>
27
+ </plist>
@@ -0,0 +1,5 @@
1
+ import Cocoa
2
+
3
+ // NSExtension entry point for Finder Sync extension
4
+ let app = NSApplication.shared
5
+ NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "openrune",
3
+ "version": "0.1.0",
4
+ "description": "Rune — File-based AI Agent Desktop App",
5
+ "keywords": ["ai", "agent", "claude", "desktop", "electron", "mcp", "claude-code"],
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/gilhyun/Rune.git"
9
+ },
10
+ "homepage": "https://github.com/gilhyun/Rune",
11
+ "license": "MIT",
12
+ "author": "gilhyun",
13
+ "main": "bootstrap.js",
14
+ "bin": {
15
+ "rune": "bin/rune.js"
16
+ },
17
+ "scripts": {
18
+ "pack": "electron-builder --dir",
19
+ "dist": "electron-builder",
20
+ "build:main": "esbuild src/main.ts --bundle --platform=node --outfile=dist/main.js --external:electron --external:node-pty && esbuild src/preload.ts --bundle --platform=node --outfile=dist/preload.js --external:electron && esbuild channel/rune-channel.ts --bundle --platform=node --outfile=dist/rune-channel.js",
21
+ "build:renderer": "cd renderer && npx vite build",
22
+ "build": "npm run build:main && npm run build:renderer",
23
+ "postinstall": "node bin/rune.js install",
24
+ "start": "npm run build && electron .",
25
+ "dev": "npm run build && electron ."
26
+ },
27
+ "devDependencies": {
28
+ "@tailwindcss/vite": "^4.2.2",
29
+ "@types/node": "^22.0.0",
30
+ "@types/react": "^19.2.14",
31
+ "@types/react-dom": "^19.2.3",
32
+ "@types/ws": "^8.18.1",
33
+ "@vitejs/plugin-react": "^6.0.1",
34
+ "electron": "^28.3.3",
35
+ "esbuild": "^0.27.4",
36
+ "tailwindcss": "^4.2.2",
37
+ "typescript": "^5.7.0",
38
+ "electron-builder": "^25.0.0",
39
+ "vite": "^8.0.1"
40
+ },
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.27.1",
43
+ "@xterm/addon-fit": "^0.11.0",
44
+ "@xterm/xterm": "^6.0.0",
45
+ "clsx": "^2.1.1",
46
+ "lucide-react": "^0.577.0",
47
+ "node-pty": "^1.1.0",
48
+ "react": "^19.2.4",
49
+ "react-dom": "^19.2.4",
50
+ "sonner": "^2.0.7",
51
+ "tailwind-merge": "^3.5.0"
52
+ }
53
+ }
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ko" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Rune</title>
7
+ </head>
8
+ <body class="bg-background text-foreground overflow-hidden">
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>