openrune 2.0.5 → 2.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 (3) hide show
  1. package/bin/rune.js +221 -0
  2. package/lib/index.js +87 -1
  3. package/package.json +1 -1
package/bin/rune.js CHANGED
@@ -19,6 +19,8 @@ switch (command) {
19
19
  case 'loop': return loopRunes(args)
20
20
  case 'watch': return watchRune(args[0], args.slice(1))
21
21
  case 'list': return listRunes()
22
+ case 'backup': return backupRune(args[0], args.slice(1))
23
+ case 'inbound': return inboundRune(args[0], args.slice(1))
22
24
  case 'open':
23
25
  console.log(' ℹ️ `rune open` has been removed from the CLI.')
24
26
  console.log(' For a GUI, use RuneChat: https://github.com/gilhyun/runechat')
@@ -982,6 +984,215 @@ function listRunes() {
982
984
  }
983
985
  }
984
986
 
987
+ // ── backup ──────────────────────────────────────
988
+
989
+ function backupRune(file, restArgs) {
990
+ if (!file) {
991
+ console.log('Usage: rune backup <file.rune> [--format md|json|rune] [--output <path>]')
992
+ console.log('')
993
+ console.log('Formats:')
994
+ console.log(' md Markdown — readable conversation export (default)')
995
+ console.log(' json JSON — full data including memory and metadata')
996
+ console.log(' rune Clone — timestamped copy of the .rune file')
997
+ console.log('')
998
+ console.log('Examples:')
999
+ console.log(' rune backup reviewer.rune')
1000
+ console.log(' rune backup reviewer.rune --format json')
1001
+ console.log(' rune backup reviewer.rune --format rune --output backups/')
1002
+ process.exit(1)
1003
+ }
1004
+
1005
+ const filePath = path.resolve(process.cwd(), file)
1006
+ if (!fs.existsSync(filePath)) {
1007
+ console.error(` ❌ File not found: ${filePath}`)
1008
+ process.exit(1)
1009
+ }
1010
+
1011
+ // Parse args
1012
+ let format = 'md'
1013
+ let outputPath = ''
1014
+ for (let i = 0; i < restArgs.length; i++) {
1015
+ if (restArgs[i] === '--format' && restArgs[i + 1]) { format = restArgs[++i] }
1016
+ else if (restArgs[i] === '--output' && restArgs[i + 1]) { outputPath = restArgs[++i] }
1017
+ }
1018
+
1019
+ const rune = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
1020
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
1021
+ const baseName = rune.name || path.basename(file, '.rune')
1022
+
1023
+ let outFile, content
1024
+
1025
+ if (format === 'md') {
1026
+ outFile = `${baseName}-backup-${ts}.md`
1027
+ const lines = []
1028
+ lines.push(`# ${rune.name} — Conversation Backup`)
1029
+ lines.push(``)
1030
+ lines.push(`- **Role**: ${rune.role || 'N/A'}`)
1031
+ lines.push(`- **Created**: ${rune.createdAt || 'N/A'}`)
1032
+ lines.push(`- **Exported**: ${new Date().toISOString()}`)
1033
+ lines.push(`- **Messages**: ${(rune.history || []).length}`)
1034
+ lines.push(``)
1035
+
1036
+ if (rune.memory && rune.memory.length > 0) {
1037
+ lines.push(`## Memory`)
1038
+ lines.push(``)
1039
+ rune.memory.forEach((m, i) => lines.push(`${i + 1}. ${m}`))
1040
+ lines.push(``)
1041
+ }
1042
+
1043
+ if (rune.history && rune.history.length > 0) {
1044
+ lines.push(`## Conversation`)
1045
+ lines.push(``)
1046
+ for (const msg of rune.history) {
1047
+ const who = msg.role === 'user' ? '**User**' : '**Assistant**'
1048
+ const time = msg.ts ? new Date(msg.ts).toLocaleString() : ''
1049
+ lines.push(`### ${who} ${time ? `(${time})` : ''}`)
1050
+ lines.push(``)
1051
+ lines.push(msg.text)
1052
+ lines.push(``)
1053
+ lines.push(`---`)
1054
+ lines.push(``)
1055
+ }
1056
+ }
1057
+
1058
+ content = lines.join('\n')
1059
+
1060
+ } else if (format === 'json') {
1061
+ outFile = `${baseName}-backup-${ts}.json`
1062
+ content = JSON.stringify({
1063
+ ...rune,
1064
+ _backup: {
1065
+ exportedAt: new Date().toISOString(),
1066
+ sourceFile: filePath,
1067
+ messageCount: (rune.history || []).length,
1068
+ }
1069
+ }, null, 2)
1070
+
1071
+ } else if (format === 'rune') {
1072
+ outFile = `${baseName}-backup-${ts}.rune`
1073
+ content = JSON.stringify(rune, null, 2)
1074
+
1075
+ } else {
1076
+ console.error(` ❌ Unknown format: ${format}. Use: md, json, rune`)
1077
+ process.exit(1)
1078
+ }
1079
+
1080
+ // Resolve output path
1081
+ if (outputPath) {
1082
+ const resolved = path.resolve(process.cwd(), outputPath)
1083
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
1084
+ outFile = path.join(resolved, outFile)
1085
+ } else {
1086
+ // Treat as file path
1087
+ outFile = resolved
1088
+ }
1089
+ } else {
1090
+ outFile = path.resolve(process.cwd(), outFile)
1091
+ }
1092
+
1093
+ // Ensure parent dir exists
1094
+ const outDir = path.dirname(outFile)
1095
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true })
1096
+
1097
+ fs.writeFileSync(outFile, content)
1098
+
1099
+ const stats = {
1100
+ messages: (rune.history || []).length,
1101
+ memory: (rune.memory || []).length,
1102
+ size: fs.statSync(outFile).size,
1103
+ }
1104
+
1105
+ console.log(`📦 Backup created`)
1106
+ console.log(` Agent: ${rune.name} (${rune.role || 'N/A'})`)
1107
+ console.log(` Format: ${format}`)
1108
+ console.log(` Messages: ${stats.messages}, Memory: ${stats.memory}`)
1109
+ console.log(` Size: ${(stats.size / 1024).toFixed(1)} KB`)
1110
+ console.log(` Output: ${outFile}`)
1111
+ }
1112
+
1113
+
1114
+ // ── inbound ─────────────────────────────────────
1115
+
1116
+ function inboundRune(file, restArgs) {
1117
+ if (!file) {
1118
+ console.log('Usage: rune inbound <file.rune> "message" [--run] [--auto] [--source <name>]')
1119
+ console.log('')
1120
+ console.log('Options:')
1121
+ console.log(' --run Send the message and immediately run the agent')
1122
+ console.log(' --auto Run in auto mode (agent can write files, run commands)')
1123
+ console.log(' --source <name> Label the message source (default: "external")')
1124
+ console.log('')
1125
+ console.log('Without --run, the message is queued in history for the next rune run.')
1126
+ console.log('')
1127
+ console.log('Examples:')
1128
+ console.log(' rune inbound reviewer.rune "PR #42 is ready for review" --run')
1129
+ console.log(' rune inbound coder.rune "Build failed, fix it" --run --auto')
1130
+ console.log(' rune inbound monitor.rune "Deploy complete" --source deploy-bot')
1131
+ console.log(' echo "Error log..." | rune inbound coder.rune --run --auto')
1132
+ process.exit(1)
1133
+ }
1134
+
1135
+ const filePath = path.resolve(process.cwd(), file)
1136
+ if (!fs.existsSync(filePath)) {
1137
+ console.error(` ❌ File not found: ${filePath}`)
1138
+ process.exit(1)
1139
+ }
1140
+
1141
+ // Parse args
1142
+ let message = ''
1143
+ let shouldRun = false
1144
+ let autoMode = false
1145
+ let source = 'external'
1146
+
1147
+ for (let i = 0; i < restArgs.length; i++) {
1148
+ if (restArgs[i] === '--run') { shouldRun = true }
1149
+ else if (restArgs[i] === '--auto') { autoMode = true; shouldRun = true }
1150
+ else if (restArgs[i] === '--source' && restArgs[i + 1]) { source = restArgs[++i] }
1151
+ else if (!message) { message = restArgs[i] }
1152
+ }
1153
+
1154
+ // Read from stdin if no message
1155
+ if (!message && process.stdin.isTTY === false) {
1156
+ message = fs.readFileSync('/dev/stdin', 'utf-8').trim()
1157
+ }
1158
+
1159
+ if (!message) {
1160
+ console.error(' ❌ No message provided. Pass a message string or pipe via stdin.')
1161
+ process.exit(1)
1162
+ }
1163
+
1164
+ const rune = JSON.parse(fs.readFileSync(filePath, 'utf-8'))
1165
+
1166
+ // Add message to history with source metadata
1167
+ rune.history = rune.history || []
1168
+ rune.history.push({
1169
+ role: 'user',
1170
+ text: message,
1171
+ ts: Date.now(),
1172
+ source,
1173
+ })
1174
+ fs.writeFileSync(filePath, JSON.stringify(rune, null, 2))
1175
+
1176
+ console.log(`📨 Message delivered to ${rune.name}`)
1177
+ console.log(` From: ${source}`)
1178
+ console.log(` Text: ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`)
1179
+
1180
+ if (!shouldRun) {
1181
+ console.log(` Status: Queued (will be seen on next rune run)`)
1182
+ console.log(`\n To run now: rune run ${file} "continue"`)
1183
+ return
1184
+ }
1185
+
1186
+ // Run the agent with the inbound message
1187
+ console.log(` Status: Running agent...\n`)
1188
+
1189
+ const runArgs = [file, message]
1190
+ if (autoMode) runArgs.push('--auto')
1191
+
1192
+ return runRune(file, runArgs.slice(1))
1193
+ }
1194
+
1195
+
985
1196
  // ── help ─────────────────────────────────────────
986
1197
 
987
1198
  function showHelp() {
@@ -1006,6 +1217,13 @@ Usage:
1006
1217
  --prompt "..." Prompt to send when triggered
1007
1218
  --glob "*.ts" File pattern (for file-change)
1008
1219
  --interval 5m Schedule interval (for cron: 30s, 5m, 1h)
1220
+ rune backup <file.rune> Export agent conversation/data
1221
+ --format md|json|rune Output format (default: md)
1222
+ --output <path> Output file or directory
1223
+ rune inbound <file.rune> "msg" Send external message to agent
1224
+ --run Immediately run the agent after delivery
1225
+ --auto Run in auto mode (implies --run)
1226
+ --source <name> Label the message source (default: "external")
1009
1227
  rune list List .rune files in current directory
1010
1228
  rune help Show this help
1011
1229
 
@@ -1015,6 +1233,9 @@ Examples:
1015
1233
  rune pipe coder.rune reviewer.rune "Implement a login page"
1016
1234
  rune loop coder.rune reviewer.rune "Build a REST API" --until "no critical issues" --max-iterations 3 --auto
1017
1235
  rune watch reviewer.rune --on git-commit --prompt "Review this commit"
1236
+ rune backup reviewer.rune --format md
1237
+ rune inbound coder.rune "Build failed, fix it" --run --auto
1238
+ echo "Error log..." | rune inbound coder.rune --run --auto
1018
1239
  echo "Fix the bug in auth.ts" | rune run backend.rune
1019
1240
 
1020
1241
  Looking for a GUI? Check out RuneChat: https://github.com/gilhyun/runechat
package/lib/index.js CHANGED
@@ -120,4 +120,90 @@ async function pipe(runeFiles, prompt) {
120
120
  return { pipeline: results, finalOutput: currentInput }
121
121
  }
122
122
 
123
- module.exports = { load, pipe }
123
+ /**
124
+ * Backup a .rune file to md/json/rune format
125
+ * @param {string} filePath - path to .rune file
126
+ * @param {object} opts - { format: 'md'|'json'|'rune' }
127
+ * @returns {{ content: string, filename: string }}
128
+ */
129
+ function backup(filePath, opts = {}) {
130
+ const resolved = path.resolve(filePath)
131
+ if (!fs.existsSync(resolved)) {
132
+ throw new Error(`Rune file not found: ${resolved}`)
133
+ }
134
+
135
+ const rune = JSON.parse(fs.readFileSync(resolved, 'utf-8'))
136
+ const format = opts.format || 'md'
137
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
138
+ const baseName = rune.name || path.basename(filePath, '.rune')
139
+
140
+ if (format === 'md') {
141
+ const lines = []
142
+ lines.push(`# ${rune.name} — Conversation Backup\n`)
143
+ lines.push(`- **Role**: ${rune.role || 'N/A'}`)
144
+ lines.push(`- **Exported**: ${new Date().toISOString()}`)
145
+ lines.push(`- **Messages**: ${(rune.history || []).length}\n`)
146
+
147
+ if (rune.memory && rune.memory.length > 0) {
148
+ lines.push(`## Memory\n`)
149
+ rune.memory.forEach((m, i) => lines.push(`${i + 1}. ${m}`))
150
+ lines.push('')
151
+ }
152
+
153
+ if (rune.history && rune.history.length > 0) {
154
+ lines.push(`## Conversation\n`)
155
+ for (const msg of rune.history) {
156
+ const who = msg.role === 'user' ? '**User**' : '**Assistant**'
157
+ const time = msg.ts ? new Date(msg.ts).toLocaleString() : ''
158
+ lines.push(`### ${who} ${time ? `(${time})` : ''}\n`)
159
+ lines.push(msg.text)
160
+ lines.push('\n---\n')
161
+ }
162
+ }
163
+
164
+ return { content: lines.join('\n'), filename: `${baseName}-backup-${ts}.md` }
165
+ }
166
+
167
+ if (format === 'json') {
168
+ return {
169
+ content: JSON.stringify({ ...rune, _backup: { exportedAt: new Date().toISOString(), sourceFile: resolved } }, null, 2),
170
+ filename: `${baseName}-backup-${ts}.json`,
171
+ }
172
+ }
173
+
174
+ if (format === 'rune') {
175
+ return {
176
+ content: JSON.stringify(rune, null, 2),
177
+ filename: `${baseName}-backup-${ts}.rune`,
178
+ }
179
+ }
180
+
181
+ throw new Error(`Unknown format: ${format}`)
182
+ }
183
+
184
+ /**
185
+ * Send an inbound message to a .rune agent
186
+ * @param {string} filePath - path to .rune file
187
+ * @param {string} message - the message text
188
+ * @param {object} opts - { source: string }
189
+ */
190
+ function inbound(filePath, message, opts = {}) {
191
+ const resolved = path.resolve(filePath)
192
+ if (!fs.existsSync(resolved)) {
193
+ throw new Error(`Rune file not found: ${resolved}`)
194
+ }
195
+
196
+ const rune = JSON.parse(fs.readFileSync(resolved, 'utf-8'))
197
+ rune.history = rune.history || []
198
+ rune.history.push({
199
+ role: 'user',
200
+ text: message,
201
+ ts: Date.now(),
202
+ source: opts.source || 'external',
203
+ })
204
+ fs.writeFileSync(resolved, JSON.stringify(rune, null, 2))
205
+
206
+ return { agent: rune.name, queued: true, historyLength: rune.history.length }
207
+ }
208
+
209
+ module.exports = { load, pipe, backup, inbound }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openrune",
3
- "version": "2.0.5",
3
+ "version": "2.1.0",
4
4
  "description": "Persistent AI agents for Claude Code — build once, run forever.",
5
5
  "keywords": ["ai", "agent", "claude", "claude-code", "cli", "toolkit", "automation"],
6
6
  "repository": {