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 +16 -0
- package/README.md +73 -5
- package/bin/rune.js +435 -6
- package/channel/rune-channel.ts +26 -7
- package/lib/index.js +116 -0
- package/package.json +6 -3
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
|
|
9
|
-
Drop a <code>.rune</code> file in any folder.
|
|
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
|
-
|
|
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
|
|
723
|
-
rune
|
|
724
|
-
rune
|
|
725
|
-
rune
|
|
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
|
}
|
package/channel/rune-channel.ts
CHANGED
|
@@ -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
|
|
53
|
+
// History: last 50 messages, recent 10 in full detail
|
|
54
54
|
if (rune.history && rune.history.length > 0) {
|
|
55
|
-
const
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
parts.push(
|
|
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.
|
|
4
|
-
"description": "Rune — File-based AI Agent
|
|
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
|
},
|