openrune 0.1.2 → 0.2.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/.mcp.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "mcpServers": {
3
+ "rune-channel": {
4
+ "command": "node",
5
+ "args": [
6
+ "/Users/gilhyun/IdeaProjects/Rune/dist/rune-channel.js"
7
+ ],
8
+ "env": {
9
+ "RUNE_FOLDER_PATH": "/Users/gilhyun/IdeaProjects/Rune",
10
+ "RUNE_CHANNEL_PORT": "51234",
11
+ "RUNE_AGENT_ROLE": "General assistant",
12
+ "RUNE_FILE_PATH": "/Users/gilhyun/IdeaProjects/Rune/Rune.rune"
13
+ }
14
+ }
15
+ }
16
+ }
package/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
  <h1 align="center">Rune</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>File-based AI Agent Desktop App</strong><br/>
9
- Drop a <code>.rune</code> file in any folder. Double-click to open. Chat with your AI agent.
8
+ <strong>File-based AI Agent Harness for Claude Code</strong><br/>
9
+ Drop a <code>.rune</code> file in any folder. Run it headlessly, chain agents, or open the desktop UI.
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -31,11 +31,13 @@ Rune turns any folder into an AI workspace. Each `.rune` file is an independent
31
31
 
32
32
  ## Why Rune?
33
33
 
34
- Most AI tools lose context when you close the window. Rune doesn'tbecause your agent lives in your folder.
34
+ Building a Claude Code harness usually means wiring up process management, I/O parsing, state handling, and a UI from scratch. Rune lets you skip all of that just drop a file and go.
35
+
36
+ **No harness boilerplate** — No SDK wiring, no process management, no custom I/O parsing. One `.rune` file gives you a fully working Claude Code agent with a desktop UI.
35
37
 
36
38
  **Persistent context** — Your agent remembers everything. Close the app, reopen it next week — the conversation and context are right where you left off.
37
39
 
38
- **Portable** — The `.rune` file is just a file. Copy it to another machine, share it with a teammate, or check it into git. Your agent goes wherever the file goes.
40
+ **Portable** — The `.rune` file is just a JSON file. Copy it to another machine, share it with a teammate, or check it into git. Your agent goes wherever the file goes.
39
41
 
40
42
  **Multiple agents per folder** — Need a code reviewer AND a backend developer in the same project? Create two `.rune` files. Each agent has its own role, history, and expertise — working side by side in the same folder.
41
43
 
@@ -144,12 +146,78 @@ Edit the `role` field anytime to change the agent's behavior.
144
146
  | `rune install` | Build app, register file association, install Quick Action |
145
147
  | `rune new <name>` | Create a `.rune` file in the current directory |
146
148
  | `rune new <name> --role "..."` | Create with a custom role |
147
- | `rune open <file.rune>` | Open a `.rune` file |
149
+ | `rune open <file.rune>` | Open a `.rune` file (desktop GUI) |
150
+ | `rune run <file.rune> "prompt"` | Run agent headlessly (no GUI) |
151
+ | `rune pipe <a.rune> <b.rune> "prompt"` | Chain agents in a pipeline |
152
+ | `rune watch <file.rune> --on <event>` | Set up automated triggers |
148
153
  | `rune list` | List `.rune` files in the current directory |
149
154
  | `rune uninstall` | Remove Rune integration (keeps your `.rune` files) |
150
155
 
151
156
  ---
152
157
 
158
+ ## Harness Mode
159
+
160
+ Rune isn't just a desktop app — it's a full agent harness. Use it from scripts, CI/CD, or your own tools.
161
+
162
+ ### Headless execution
163
+
164
+ Run any `.rune` agent from the command line without opening the GUI:
165
+
166
+ ```bash
167
+ rune run reviewer.rune "Review the latest commit"
168
+
169
+ # Pipe input from other commands
170
+ git diff | rune run reviewer.rune "Review this diff"
171
+
172
+ # JSON output for scripting
173
+ rune run reviewer.rune "Review src/auth.ts" --output json
174
+ ```
175
+
176
+ ### Agent chaining
177
+
178
+ Chain multiple agents into a pipeline. The output of each agent becomes the input for the next:
179
+
180
+ ```bash
181
+ rune pipe coder.rune reviewer.rune tester.rune "Implement a login page"
182
+ ```
183
+
184
+ This runs: coder writes the code → reviewer reviews it → tester writes tests.
185
+
186
+ ### Automated triggers
187
+
188
+ Set agents to run automatically on events:
189
+
190
+ ```bash
191
+ # Run on every git commit
192
+ rune watch reviewer.rune --on git-commit --prompt "Review this commit"
193
+
194
+ # Watch for file changes
195
+ rune watch linter.rune --on file-change --glob "src/**/*.ts" --prompt "Check for issues"
196
+
197
+ # Run on a schedule
198
+ rune watch monitor.rune --on cron --interval 5m --prompt "Check server health"
199
+ ```
200
+
201
+ ### Node.js API
202
+
203
+ Use Rune agents programmatically in your own code:
204
+
205
+ ```js
206
+ const rune = require('openrune')
207
+
208
+ const reviewer = rune.load('reviewer.rune')
209
+ const result = await reviewer.send('Review the latest commit')
210
+ console.log(result)
211
+
212
+ // Agent chaining via API
213
+ const { finalOutput } = await rune.pipe(
214
+ ['coder.rune', 'reviewer.rune'],
215
+ 'Implement a login page'
216
+ )
217
+ ```
218
+
219
+ ---
220
+
153
221
  ## Architecture
154
222
 
155
223
  ```
package/bin/rune.js CHANGED
@@ -29,6 +29,9 @@ switch (command) {
29
29
  case 'install': return install()
30
30
  case 'new': return createRune(args[0], args)
31
31
  case 'open': return openRune(args[0])
32
+ case 'run': return runRune(args[0], args.slice(1))
33
+ case 'pipe': return pipeRunes(args)
34
+ case 'watch': return watchRune(args[0], args.slice(1))
32
35
  case 'list': return listRunes()
33
36
  case 'uninstall': return uninstall()
34
37
  case 'help':
@@ -649,6 +652,421 @@ function openRune(file) {
649
652
  child.unref()
650
653
  }
651
654
 
655
+ // ── run (headless) ──────────────────────────────
656
+
657
+ function runRune(file, restArgs) {
658
+ if (!file) {
659
+ console.log('Usage: rune run <file.rune> "prompt" [--output json|text]')
660
+ console.log('Example: rune run reviewer.rune "Review the latest commit"')
661
+ process.exit(1)
662
+ }
663
+
664
+ const filePath = path.resolve(process.cwd(), file)
665
+ if (!fs.existsSync(filePath)) {
666
+ console.error(` ❌ File not found: ${filePath}`)
667
+ process.exit(1)
668
+ }
669
+
670
+ // Parse args: prompt and flags
671
+ let prompt = ''
672
+ let outputFormat = 'text'
673
+ for (let i = 0; i < restArgs.length; i++) {
674
+ if (restArgs[i] === '--output' && restArgs[i + 1]) {
675
+ outputFormat = restArgs[i + 1]
676
+ i++
677
+ } else if (!prompt) {
678
+ prompt = restArgs[i]
679
+ }
680
+ }
681
+
682
+ // Read from stdin if no prompt provided
683
+ if (!prompt && process.stdin.isTTY === false) {
684
+ prompt = fs.readFileSync('/dev/stdin', 'utf-8').trim()
685
+ }
686
+
687
+ if (!prompt) {
688
+ console.error(' ❌ No prompt provided. Pass a prompt string or pipe via stdin.')
689
+ process.exit(1)
690
+ }
691
+
692
+ const rune = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
693
+ const folderPath = path.dirname(filePath)
694
+
695
+ // Build system prompt from .rune context
696
+ const systemParts = []
697
+ if (rune.role) systemParts.push(`Your role: ${rune.role}`)
698
+ if (rune.memory && rune.memory.length > 0) {
699
+ systemParts.push('Saved memory from previous sessions:')
700
+ rune.memory.forEach((m, i) => systemParts.push(`${i + 1}. ${m}`))
701
+ }
702
+ if (rune.history && rune.history.length > 0) {
703
+ const recent = rune.history.slice(-10)
704
+ systemParts.push(`\nRecent conversation (${rune.history.length} total, showing last ${recent.length}):`)
705
+ for (const msg of recent) {
706
+ const who = msg.role === 'user' ? 'User' : 'Assistant'
707
+ systemParts.push(`${who}: ${msg.text}`)
708
+ }
709
+ }
710
+
711
+ const systemPrompt = systemParts.join('\n')
712
+
713
+ // Build claude CLI args
714
+ const claudeArgs = ['-p', '--print']
715
+ if (systemPrompt) {
716
+ claudeArgs.push('--system-prompt', systemPrompt)
717
+ }
718
+ if (outputFormat === 'json') {
719
+ claudeArgs.push('--output-format', 'json')
720
+ }
721
+ claudeArgs.push(prompt)
722
+
723
+ const child = spawn('claude', claudeArgs, {
724
+ cwd: folderPath,
725
+ stdio: ['pipe', 'pipe', 'pipe'],
726
+ env: { ...process.env },
727
+ })
728
+
729
+ let stdout = ''
730
+ let stderr = ''
731
+
732
+ child.stdout.on('data', (data) => {
733
+ const text = data.toString()
734
+ stdout += text
735
+ if (outputFormat !== 'json') process.stdout.write(text)
736
+ })
737
+ child.stderr.on('data', (data) => { stderr += data.toString() })
738
+
739
+ child.on('close', (code) => {
740
+ if (outputFormat === 'json') {
741
+ try {
742
+ const parsed = JSON.parse(stdout)
743
+ console.log(JSON.stringify({ agent: rune.name, role: rune.role, response: parsed }, null, 2))
744
+ } catch {
745
+ console.log(JSON.stringify({ agent: rune.name, role: rune.role, response: stdout.trim() }, null, 2))
746
+ }
747
+ }
748
+
749
+ // Save to history
750
+ rune.history = rune.history || []
751
+ rune.history.push({ role: 'user', text: prompt, ts: Date.now() })
752
+ rune.history.push({ role: 'assistant', text: stdout.trim(), ts: Date.now() })
753
+ fs.writeFileSync(filePath, JSON.stringify(rune, null, 2))
754
+
755
+ if (code !== 0 && stderr) {
756
+ console.error(stderr)
757
+ }
758
+ process.exit(code || 0)
759
+ })
760
+ }
761
+
762
+ // ── pipe (agent chaining) ───────────────────────
763
+
764
+ async function pipeRunes(args) {
765
+ // Parse: rune pipe agent1.rune agent2.rune ... "initial prompt" [--output json]
766
+ const runeFiles = []
767
+ let prompt = ''
768
+ let outputFormat = 'text'
769
+
770
+ for (let i = 0; i < args.length; i++) {
771
+ if (args[i] === '--output' && args[i + 1]) {
772
+ outputFormat = args[i + 1]
773
+ i++
774
+ } else if (args[i].endsWith('.rune')) {
775
+ runeFiles.push(args[i])
776
+ } else if (!prompt) {
777
+ prompt = args[i]
778
+ }
779
+ }
780
+
781
+ if (runeFiles.length < 2 || !prompt) {
782
+ console.log('Usage: rune pipe <agent1.rune> <agent2.rune> [...] "initial prompt"')
783
+ console.log('Example: rune pipe coder.rune reviewer.rune "Implement a login page"')
784
+ console.log('\nThe output of each agent becomes the input for the next.')
785
+ process.exit(1)
786
+ }
787
+
788
+ // Read from stdin if no prompt
789
+ if (!prompt && process.stdin.isTTY === false) {
790
+ prompt = fs.readFileSync('/dev/stdin', 'utf-8').trim()
791
+ }
792
+
793
+ let currentInput = prompt
794
+ const results = []
795
+
796
+ for (let i = 0; i < runeFiles.length; i++) {
797
+ const file = runeFiles[i]
798
+ const filePath = path.resolve(process.cwd(), file)
799
+
800
+ if (!fs.existsSync(filePath)) {
801
+ console.error(` ❌ File not found: ${filePath}`)
802
+ process.exit(1)
803
+ }
804
+
805
+ const rune = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
806
+ const folderPath = path.dirname(filePath)
807
+
808
+ const isLast = i === runeFiles.length - 1
809
+ const pipeContext = i > 0
810
+ ? `You are step ${i + 1} of ${runeFiles.length} in a pipeline. The previous agent (${results[i-1].agent}) produced the following output:\n\n${currentInput}\n\nNow do your part:`
811
+ : currentInput
812
+
813
+ if (outputFormat !== 'json') {
814
+ console.error(`\n ▶ [${i + 1}/${runeFiles.length}] ${rune.name} (${rune.role || 'assistant'})`)
815
+ }
816
+
817
+ // Build system prompt
818
+ const systemParts = []
819
+ if (rune.role) systemParts.push(`Your role: ${rune.role}`)
820
+ if (rune.memory && rune.memory.length > 0) {
821
+ systemParts.push('Saved memory:')
822
+ rune.memory.forEach((m, j) => systemParts.push(`${j + 1}. ${m}`))
823
+ }
824
+
825
+ const claudeArgs = ['-p', '--print']
826
+ if (systemParts.length > 0) {
827
+ claudeArgs.push('--system-prompt', systemParts.join('\n'))
828
+ }
829
+ claudeArgs.push(pipeContext)
830
+
831
+ const output = await new Promise((resolve, reject) => {
832
+ const child = spawn('claude', claudeArgs, {
833
+ cwd: folderPath,
834
+ stdio: ['pipe', 'pipe', 'pipe'],
835
+ env: { ...process.env },
836
+ })
837
+
838
+ let stdout = ''
839
+ let stderr = ''
840
+ child.stdout.on('data', (d) => { stdout += d.toString() })
841
+ child.stderr.on('data', (d) => { stderr += d.toString() })
842
+ child.on('close', (code) => {
843
+ if (code !== 0) reject(new Error(stderr || `Agent ${rune.name} exited with code ${code}`))
844
+ else resolve(stdout.trim())
845
+ })
846
+ })
847
+
848
+ results.push({ agent: rune.name, role: rune.role, output })
849
+
850
+ // Save to .rune history
851
+ rune.history = rune.history || []
852
+ rune.history.push({ role: 'user', text: pipeContext, ts: Date.now() })
853
+ rune.history.push({ role: 'assistant', text: output, ts: Date.now() })
854
+ fs.writeFileSync(filePath, JSON.stringify(rune, null, 2))
855
+
856
+ currentInput = output
857
+
858
+ // Print intermediate output
859
+ if (outputFormat !== 'json' && !isLast) {
860
+ console.error(` ✓ Done\n`)
861
+ }
862
+ }
863
+
864
+ // Final output
865
+ if (outputFormat === 'json') {
866
+ console.log(JSON.stringify({ pipeline: results, finalOutput: currentInput }, null, 2))
867
+ } else {
868
+ console.log(currentInput)
869
+ }
870
+ }
871
+
872
+ // ── watch (triggers) ────────────────────────────
873
+
874
+ function watchRune(file, restArgs) {
875
+ if (!file) {
876
+ console.log('Usage: rune watch <file.rune> --on <event> [options]')
877
+ console.log('')
878
+ console.log('Events:')
879
+ console.log(' file-change Watch for file changes in the project folder')
880
+ console.log(' git-push Run after git push (installs a git hook)')
881
+ console.log(' git-commit Run after git commit (installs a git hook)')
882
+ console.log(' cron Run on a schedule (e.g. --interval 5m)')
883
+ console.log('')
884
+ console.log('Options:')
885
+ console.log(' --prompt "..." The prompt to send when triggered')
886
+ console.log(' --glob "*.ts" File pattern to watch (for file-change)')
887
+ console.log(' --interval 5m Interval for cron (e.g. 30s, 5m, 1h)')
888
+ console.log('')
889
+ console.log('Examples:')
890
+ console.log(' rune watch reviewer.rune --on file-change --glob "src/**/*.ts" --prompt "Review changed files"')
891
+ console.log(' rune watch reviewer.rune --on git-commit --prompt "Review this commit"')
892
+ console.log(' rune watch monitor.rune --on cron --interval 5m --prompt "Check server status"')
893
+ process.exit(1)
894
+ }
895
+
896
+ const filePath = path.resolve(process.cwd(), file)
897
+ if (!fs.existsSync(filePath)) {
898
+ console.error(` ❌ File not found: ${filePath}`)
899
+ process.exit(1)
900
+ }
901
+
902
+ // Parse args
903
+ let event = ''
904
+ let prompt = ''
905
+ let glob = ''
906
+ let interval = '5m'
907
+
908
+ for (let i = 0; i < restArgs.length; i++) {
909
+ if (restArgs[i] === '--on' && restArgs[i + 1]) { event = restArgs[++i] }
910
+ else if (restArgs[i] === '--prompt' && restArgs[i + 1]) { prompt = restArgs[++i] }
911
+ else if (restArgs[i] === '--glob' && restArgs[i + 1]) { glob = restArgs[++i] }
912
+ else if (restArgs[i] === '--interval' && restArgs[i + 1]) { interval = restArgs[++i] }
913
+ }
914
+
915
+ if (!event) {
916
+ console.error(' ❌ --on <event> is required')
917
+ process.exit(1)
918
+ }
919
+
920
+ if (!prompt) {
921
+ console.error(' ❌ --prompt is required')
922
+ process.exit(1)
923
+ }
924
+
925
+ const rune = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
926
+ const folderPath = path.dirname(filePath)
927
+
928
+ function triggerRun(triggerInfo) {
929
+ const fullPrompt = triggerInfo
930
+ ? `[Triggered by: ${triggerInfo}]\n\n${prompt}`
931
+ : prompt
932
+ console.log(`\n🔮 [${new Date().toLocaleTimeString()}] Triggered: ${rune.name} — ${triggerInfo || event}`)
933
+
934
+ const systemParts = []
935
+ if (rune.role) systemParts.push(`Your role: ${rune.role}`)
936
+
937
+ const claudeArgs = ['-p', '--print']
938
+ if (systemParts.length > 0) {
939
+ claudeArgs.push('--system-prompt', systemParts.join('\n'))
940
+ }
941
+ claudeArgs.push(fullPrompt)
942
+
943
+ const child = spawn('claude', claudeArgs, {
944
+ cwd: folderPath,
945
+ stdio: ['pipe', 'pipe', 'pipe'],
946
+ env: { ...process.env },
947
+ })
948
+
949
+ let stdout = ''
950
+ child.stdout.on('data', (d) => {
951
+ const text = d.toString()
952
+ stdout += text
953
+ process.stdout.write(text)
954
+ })
955
+ child.stderr.on('data', (d) => { process.stderr.write(d) })
956
+ child.on('close', () => {
957
+ // Save to history
958
+ const fresh = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
959
+ fresh.history = fresh.history || []
960
+ fresh.history.push({ role: 'user', text: fullPrompt, ts: Date.now() })
961
+ fresh.history.push({ role: 'assistant', text: stdout.trim(), ts: Date.now() })
962
+ fs.writeFileSync(filePath, JSON.stringify(fresh, null, 2))
963
+ console.log(`\n✓ Done`)
964
+ })
965
+ }
966
+
967
+ // ── Event handlers ──
968
+
969
+ if (event === 'file-change') {
970
+ const watchDir = folderPath
971
+ console.log(`🔮 Watching ${watchDir} for file changes...`)
972
+ if (glob) console.log(` Pattern: ${glob}`)
973
+ console.log(` Agent: ${rune.name} (${rune.role || 'assistant'})`)
974
+ console.log(' Press Ctrl+C to stop\n')
975
+
976
+ let debounce = null
977
+ fs.watch(watchDir, { recursive: true }, (eventType, filename) => {
978
+ if (!filename) return
979
+ if (filename.endsWith('.rune')) return // ignore .rune file changes
980
+ if (filename.startsWith('.git')) return
981
+ if (filename.includes('node_modules')) return
982
+
983
+ // Simple glob matching
984
+ if (glob) {
985
+ const ext = glob.replace('*', '')
986
+ if (!filename.endsWith(ext) && !filename.includes(glob.replace('*', ''))) return
987
+ }
988
+
989
+ // Debounce: wait 1s after last change
990
+ if (debounce) clearTimeout(debounce)
991
+ debounce = setTimeout(() => {
992
+ triggerRun(`file changed: ${filename}`)
993
+ }, 1000)
994
+ })
995
+ }
996
+
997
+ else if (event === 'git-commit' || event === 'git-push') {
998
+ const hookName = event === 'git-commit' ? 'post-commit' : 'post-push'
999
+ const gitDir = path.join(folderPath, '.git', 'hooks')
1000
+
1001
+ if (!fs.existsSync(path.join(folderPath, '.git'))) {
1002
+ console.error(' ❌ Not a git repository')
1003
+ process.exit(1)
1004
+ }
1005
+
1006
+ ensureDir(gitDir)
1007
+
1008
+ // For git-push, use pre-push since post-push doesn't exist natively
1009
+ const actualHook = event === 'git-push' ? 'pre-push' : 'post-commit'
1010
+ const hookPath = path.join(gitDir, actualHook)
1011
+ const runeBin = path.resolve(__dirname, 'rune.js')
1012
+ const nodebin = process.execPath
1013
+
1014
+ const hookScript = `#!/bin/bash
1015
+ # Rune auto-trigger: ${rune.name}
1016
+ "${nodebin}" "${runeBin}" run "${filePath}" "${prompt.replace(/"/g, '\\"')}" &
1017
+ `
1018
+
1019
+ // Append if hook exists, create if not
1020
+ if (fs.existsSync(hookPath)) {
1021
+ const existing = fs.readFileSync(hookPath, 'utf-8')
1022
+ if (!existing.includes('Rune auto-trigger')) {
1023
+ fs.appendFileSync(hookPath, '\n' + hookScript)
1024
+ } else {
1025
+ console.log(` ⚠️ Rune hook already installed in ${actualHook}`)
1026
+ return
1027
+ }
1028
+ } else {
1029
+ fs.writeFileSync(hookPath, hookScript, { mode: 0o755 })
1030
+ }
1031
+
1032
+ console.log(`🔮 Git hook installed: ${actualHook}`)
1033
+ console.log(` Agent: ${rune.name} will run on every ${event.replace('git-', '')}`)
1034
+ console.log(` Prompt: "${prompt}"`)
1035
+ console.log(` Hook: ${hookPath}`)
1036
+ }
1037
+
1038
+ else if (event === 'cron') {
1039
+ const ms = parseInterval(interval)
1040
+ console.log(`🔮 Running ${rune.name} every ${interval}`)
1041
+ console.log(` Prompt: "${prompt}"`)
1042
+ console.log(' Press Ctrl+C to stop\n')
1043
+
1044
+ // Run immediately, then on interval
1045
+ triggerRun(`cron (every ${interval})`)
1046
+ setInterval(() => {
1047
+ triggerRun(`cron (every ${interval})`)
1048
+ }, ms)
1049
+ }
1050
+
1051
+ else {
1052
+ console.error(` ❌ Unknown event: ${event}. Use: file-change, git-commit, git-push, cron`)
1053
+ process.exit(1)
1054
+ }
1055
+ }
1056
+
1057
+ function parseInterval(str) {
1058
+ const match = str.match(/^(\d+)(s|m|h)$/)
1059
+ if (!match) {
1060
+ console.error(` ❌ Invalid interval: ${str}. Use format like 30s, 5m, 1h`)
1061
+ process.exit(1)
1062
+ }
1063
+ const num = parseInt(match[1])
1064
+ const unit = match[2]
1065
+ if (unit === 's') return num * 1000
1066
+ if (unit === 'm') return num * 60 * 1000
1067
+ if (unit === 'h') return num * 60 * 60 * 1000
1068
+ }
1069
+
652
1070
  // ── list ─────────────────────────────────────────
653
1071
 
654
1072
  function listRunes() {
@@ -707,21 +1125,32 @@ function uninstall() {
707
1125
 
708
1126
  function showHelp() {
709
1127
  console.log(`
710
- 🔮 Rune — File-based AI Agent
1128
+ 🔮 Rune — File-based AI Agent Harness
711
1129
 
712
1130
  Usage:
713
1131
  rune install Install Rune (build app, register file association, add Quick Action)
714
1132
  rune new <name> Create a new .rune file in current directory
715
1133
  --role "description" Set the agent's role
716
- rune open <file.rune> Open a .rune file
1134
+ rune open <file.rune> Open a .rune file (desktop GUI)
1135
+ rune run <file.rune> "prompt" Run agent headlessly (no GUI)
1136
+ --output json|text Output format (default: text)
1137
+ rune pipe <a.rune> <b.rune> ... "prompt" Chain agents in a pipeline
1138
+ --output json|text Output format (default: text)
1139
+ rune watch <file.rune> Set up automated triggers
1140
+ --on <event> Event: file-change, git-commit, git-push, cron
1141
+ --prompt "..." Prompt to send when triggered
1142
+ --glob "*.ts" File pattern (for file-change)
1143
+ --interval 5m Schedule interval (for cron: 30s, 5m, 1h)
717
1144
  rune list List .rune files in current directory
718
1145
  rune uninstall Remove Rune integration (keeps .rune files)
719
1146
  rune help Show this help
720
1147
 
721
1148
  Examples:
722
- rune new designer --role "UI/UX design expert"
723
- rune new backend --role "Backend developer, Node.js specialist"
724
- rune open designer.rune
725
- rune list
1149
+ rune new reviewer --role "Code reviewer, security focused"
1150
+ rune run reviewer.rune "Review the latest commit"
1151
+ rune pipe coder.rune reviewer.rune "Implement a login page"
1152
+ rune watch reviewer.rune --on git-commit --prompt "Review this commit"
1153
+ rune watch monitor.rune --on cron --interval 5m --prompt "Check server health"
1154
+ echo "Fix the bug in auth.ts" | rune run backend.rune
726
1155
  `)
727
1156
  }
@@ -50,15 +50,34 @@ function buildSessionContext(): string {
50
50
  rune.memory.forEach((m, i) => parts.push(`${i + 1}. ${m}`))
51
51
  }
52
52
 
53
- // History summary (last 20 messages condensed)
53
+ // History: last 50 messages, recent 10 in full detail
54
54
  if (rune.history && rune.history.length > 0) {
55
- const recent = rune.history.slice(-20)
56
- parts.push('\n## Recent Conversation History')
55
+ const TOTAL_LIMIT = 50
56
+ const FULL_DETAIL_COUNT = 10
57
+ const recent = rune.history.slice(-TOTAL_LIMIT)
58
+ const olderMessages = recent.slice(0, -FULL_DETAIL_COUNT)
59
+ const recentMessages = recent.slice(-FULL_DETAIL_COUNT)
60
+
61
+ parts.push('\n## Conversation History')
57
62
  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}`)
63
+
64
+ // Older messages: summarized (truncated to 500 chars)
65
+ if (olderMessages.length > 0) {
66
+ parts.push('\n### Earlier Context')
67
+ for (const msg of olderMessages) {
68
+ const who = msg.role === 'user' ? 'User' : 'You'
69
+ const text = msg.text.length > 500 ? msg.text.slice(0, 500) + '...' : msg.text
70
+ parts.push(`- **${who}**: ${text}`)
71
+ }
72
+ }
73
+
74
+ // Recent messages: full text (no truncation)
75
+ if (recentMessages.length > 0) {
76
+ parts.push('\n### Recent Messages (Full Detail)')
77
+ for (const msg of recentMessages) {
78
+ const who = msg.role === 'user' ? 'User' : 'You'
79
+ parts.push(`- **${who}**: ${msg.text}`)
80
+ }
62
81
  }
63
82
  }
64
83
 
package/lib/index.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * OpenRune — Node.js API
3
+ *
4
+ * Usage:
5
+ * const rune = require('openrune')
6
+ * const agent = rune.load('reviewer.rune')
7
+ * const result = await agent.send('Review this code')
8
+ */
9
+
10
+ const { spawn } = require('child_process')
11
+ const path = require('path')
12
+ const fs = require('fs')
13
+
14
+ function load(filePath) {
15
+ const resolved = path.resolve(filePath)
16
+ if (!fs.existsSync(resolved)) {
17
+ throw new Error(`Rune file not found: ${resolved}`)
18
+ }
19
+
20
+ const rune = JSON.parse(fs.readFileSync(resolved, 'utf-8'))
21
+ const folderPath = path.dirname(resolved)
22
+
23
+ return {
24
+ name: rune.name,
25
+ role: rune.role,
26
+ filePath: resolved,
27
+
28
+ async send(prompt) {
29
+ const fresh = JSON.parse(fs.readFileSync(resolved, 'utf-8'))
30
+
31
+ // Build system prompt
32
+ const systemParts = []
33
+ if (fresh.role) systemParts.push(`Your role: ${fresh.role}`)
34
+ if (fresh.memory && fresh.memory.length > 0) {
35
+ systemParts.push('Saved memory:')
36
+ fresh.memory.forEach((m, i) => systemParts.push(`${i + 1}. ${m}`))
37
+ }
38
+ if (fresh.history && fresh.history.length > 0) {
39
+ const recent = fresh.history.slice(-10)
40
+ systemParts.push(`\nRecent conversation (last ${recent.length}):`)
41
+ for (const msg of recent) {
42
+ const who = msg.role === 'user' ? 'User' : 'Assistant'
43
+ systemParts.push(`${who}: ${msg.text}`)
44
+ }
45
+ }
46
+
47
+ const claudeArgs = ['-p', '--print']
48
+ if (systemParts.length > 0) {
49
+ claudeArgs.push('--system-prompt', systemParts.join('\n'))
50
+ }
51
+ claudeArgs.push(prompt)
52
+
53
+ const result = await new Promise((resolve, reject) => {
54
+ const child = spawn('claude', claudeArgs, {
55
+ cwd: folderPath,
56
+ stdio: ['pipe', 'pipe', 'pipe'],
57
+ env: { ...process.env },
58
+ })
59
+
60
+ let stdout = ''
61
+ let stderr = ''
62
+ child.stdout.on('data', (d) => { stdout += d.toString() })
63
+ child.stderr.on('data', (d) => { stderr += d.toString() })
64
+ child.on('close', (code) => {
65
+ if (code !== 0) reject(new Error(stderr || `Exit code ${code}`))
66
+ else resolve(stdout.trim())
67
+ })
68
+ })
69
+
70
+ // Save to history
71
+ fresh.history = fresh.history || []
72
+ fresh.history.push({ role: 'user', text: prompt, ts: Date.now() })
73
+ fresh.history.push({ role: 'assistant', text: result, ts: Date.now() })
74
+ fs.writeFileSync(resolved, JSON.stringify(fresh, null, 2))
75
+
76
+ return result
77
+ },
78
+
79
+ getHistory() {
80
+ const fresh = JSON.parse(fs.readFileSync(resolved, 'utf-8'))
81
+ return fresh.history || []
82
+ },
83
+
84
+ getMemory() {
85
+ const fresh = JSON.parse(fs.readFileSync(resolved, 'utf-8'))
86
+ return fresh.memory || []
87
+ },
88
+
89
+ addMemory(text) {
90
+ const fresh = JSON.parse(fs.readFileSync(resolved, 'utf-8'))
91
+ fresh.memory = fresh.memory || []
92
+ fresh.memory.push(text)
93
+ fs.writeFileSync(resolved, JSON.stringify(fresh, null, 2))
94
+ },
95
+ }
96
+ }
97
+
98
+ async function pipe(runeFiles, prompt) {
99
+ let currentInput = prompt
100
+ const results = []
101
+
102
+ for (let i = 0; i < runeFiles.length; i++) {
103
+ const agent = load(runeFiles[i])
104
+ const input = i > 0
105
+ ? `Previous agent (${results[i-1].agent}) output:\n\n${currentInput}\n\nNow do your part:`
106
+ : currentInput
107
+
108
+ const output = await agent.send(input)
109
+ results.push({ agent: agent.name, role: agent.role, output })
110
+ currentInput = output
111
+ }
112
+
113
+ return { pipeline: results, finalOutput: currentInput }
114
+ }
115
+
116
+ module.exports = { load, pipe }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "openrune",
3
- "version": "0.1.2",
4
- "description": "Rune — File-based AI Agent Desktop App",
5
- "keywords": ["ai", "agent", "claude", "desktop", "electron", "mcp", "claude-code"],
3
+ "version": "0.2.0",
4
+ "description": "Rune — File-based AI Agent Harness for Claude Code",
5
+ "keywords": ["ai", "agent", "claude", "desktop", "electron", "mcp", "claude-code", "harness", "automation"],
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/gilhyun/Rune.git"
@@ -11,6 +11,9 @@
11
11
  "license": "MIT",
12
12
  "author": "gilhyun",
13
13
  "main": "bootstrap.js",
14
+ "exports": {
15
+ ".": "./lib/index.js"
16
+ },
14
17
  "bin": {
15
18
  "rune": "bin/rune.js"
16
19
  },