otoro-cli 1.4.0 → 2.0.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/bin/otoro.js +82 -114
- package/lib/agent-loop.js +230 -0
- package/lib/chat.js +92 -176
- package/lib/screen_agent.py +102 -0
- package/lib/tools.js +275 -147
- package/package.json +3 -7
package/lib/tools.js
CHANGED
|
@@ -1,173 +1,301 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const path = require('path')
|
|
3
|
-
const { execSync } = require('child_process')
|
|
4
|
-
const
|
|
3
|
+
const { execSync, exec } = require('child_process')
|
|
4
|
+
const os = require('os')
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const content = fs.readFileSync(resolved, 'utf8')
|
|
10
|
-
return { success: true, path: resolved, content, lines: content.split('\n').length }
|
|
11
|
-
} catch (e) {
|
|
12
|
-
return { success: false, error: e.message }
|
|
13
|
-
}
|
|
14
|
-
}
|
|
6
|
+
// ═══════════════════════════════════════════════════
|
|
7
|
+
// OTORO TOOL SYSTEM — Claude Code-style agentic tools
|
|
8
|
+
// ═══════════════════════════════════════════════════
|
|
15
9
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
10
|
+
const TOOLS = {
|
|
11
|
+
// ─── File System ────────────────────────────────
|
|
12
|
+
read: {
|
|
13
|
+
name: 'read',
|
|
14
|
+
description: 'Read a file from the filesystem',
|
|
15
|
+
parameters: { file_path: 'string', offset: 'number?', limit: 'number?' },
|
|
16
|
+
execute({ file_path, offset, limit }) {
|
|
17
|
+
const abs = path.resolve(file_path)
|
|
18
|
+
if (!fs.existsSync(abs)) return { error: `File not found: ${abs}` }
|
|
19
|
+
const stat = fs.statSync(abs)
|
|
20
|
+
if (stat.isDirectory()) return { error: `${abs} is a directory, not a file` }
|
|
21
|
+
if (stat.size > 5 * 1024 * 1024) return { error: `File too large: ${(stat.size / 1024 / 1024).toFixed(1)}MB` }
|
|
22
|
+
const content = fs.readFileSync(abs, 'utf8')
|
|
23
|
+
const lines = content.split('\n')
|
|
24
|
+
const start = (offset || 1) - 1
|
|
25
|
+
const end = limit ? start + limit : lines.length
|
|
26
|
+
const numbered = lines.slice(start, end).map((l, i) => `${start + i + 1}\t${l}`).join('\n')
|
|
27
|
+
return { content: numbered, total_lines: lines.length, path: abs }
|
|
28
|
+
}
|
|
29
|
+
},
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
write: {
|
|
32
|
+
name: 'write',
|
|
33
|
+
description: 'Write content to a file (creates or overwrites)',
|
|
34
|
+
parameters: { file_path: 'string', content: 'string' },
|
|
35
|
+
execute({ file_path, content }) {
|
|
36
|
+
const abs = path.resolve(file_path)
|
|
37
|
+
const dir = path.dirname(abs)
|
|
38
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
39
|
+
fs.writeFileSync(abs, content, 'utf8')
|
|
40
|
+
return { success: true, path: abs, bytes: Buffer.byteLength(content) }
|
|
34
41
|
}
|
|
35
|
-
|
|
36
|
-
fs.writeFileSync(resolved, content)
|
|
37
|
-
return { success: true, path: resolved }
|
|
38
|
-
} catch (e) {
|
|
39
|
-
return { success: false, error: e.message }
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
+
},
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
} else {
|
|
58
|
-
if (!pattern || rel.includes(pattern) || e.name.match(pattern)) files.push(rel)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
44
|
+
edit: {
|
|
45
|
+
name: 'edit',
|
|
46
|
+
description: 'Replace exact string in a file',
|
|
47
|
+
parameters: { file_path: 'string', old_string: 'string', new_string: 'string' },
|
|
48
|
+
execute({ file_path, old_string, new_string }) {
|
|
49
|
+
const abs = path.resolve(file_path)
|
|
50
|
+
if (!fs.existsSync(abs)) return { error: `File not found: ${abs}` }
|
|
51
|
+
let content = fs.readFileSync(abs, 'utf8')
|
|
52
|
+
const count = content.split(old_string).length - 1
|
|
53
|
+
if (count === 0) return { error: `String not found in ${abs}` }
|
|
54
|
+
if (count > 1) return { error: `String found ${count} times — be more specific` }
|
|
55
|
+
content = content.replace(old_string, new_string)
|
|
56
|
+
fs.writeFileSync(abs, content, 'utf8')
|
|
57
|
+
return { success: true, path: abs, replacements: 1 }
|
|
61
58
|
}
|
|
62
|
-
|
|
63
|
-
return { success: true, files: files.slice(0, 200), total: files.length }
|
|
64
|
-
} catch (e) {
|
|
65
|
-
return { success: false, error: e.message }
|
|
66
|
-
}
|
|
67
|
-
}
|
|
59
|
+
},
|
|
68
60
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
61
|
+
// ─── Search ─────────────────────────────────────
|
|
62
|
+
glob: {
|
|
63
|
+
name: 'glob',
|
|
64
|
+
description: 'Find files matching a glob pattern',
|
|
65
|
+
parameters: { pattern: 'string', directory: 'string?' },
|
|
66
|
+
execute({ pattern, directory }) {
|
|
67
|
+
const dir = directory || process.cwd()
|
|
68
|
+
try {
|
|
69
|
+
// Use find for glob-like matching
|
|
70
|
+
const cmd = `find ${JSON.stringify(dir)} -name ${JSON.stringify(pattern)} -type f 2>/dev/null | head -50`
|
|
71
|
+
const result = execSync(cmd, { encoding: 'utf8', timeout: 10000 }).trim()
|
|
72
|
+
const files = result ? result.split('\n') : []
|
|
73
|
+
return { files, count: files.length }
|
|
74
|
+
} catch {
|
|
75
|
+
return { files: [], count: 0 }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
80
|
+
grep: {
|
|
81
|
+
name: 'grep',
|
|
82
|
+
description: 'Search file contents for a pattern (regex supported)',
|
|
83
|
+
parameters: { pattern: 'string', directory: 'string?', glob: 'string?' },
|
|
84
|
+
execute({ pattern, directory, glob: fileGlob }) {
|
|
85
|
+
const dir = directory || process.cwd()
|
|
86
|
+
try {
|
|
87
|
+
let cmd = `grep -rn --include="${fileGlob || '*'}" ${JSON.stringify(pattern)} ${JSON.stringify(dir)} 2>/dev/null | head -30`
|
|
88
|
+
const result = execSync(cmd, { encoding: 'utf8', timeout: 10000 }).trim()
|
|
89
|
+
const matches = result ? result.split('\n').map(line => {
|
|
90
|
+
const [fileLine, ...rest] = line.split(':')
|
|
91
|
+
const parts = fileLine.match(/^(.+):(\d+)$/)
|
|
92
|
+
return parts ? { file: parts[1], line: parseInt(parts[2]), content: rest.join(':').trim() } : { raw: line }
|
|
93
|
+
}) : []
|
|
94
|
+
return { matches, count: matches.length }
|
|
95
|
+
} catch {
|
|
96
|
+
return { matches: [], count: 0 }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
88
100
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
// ─── Shell ──────────────────────────────────────
|
|
102
|
+
bash: {
|
|
103
|
+
name: 'bash',
|
|
104
|
+
description: 'Execute a bash command',
|
|
105
|
+
parameters: { command: 'string', timeout: 'number?' },
|
|
106
|
+
execute({ command, timeout }) {
|
|
107
|
+
const timeoutMs = Math.min((timeout || 30) * 1000, 120000)
|
|
108
|
+
try {
|
|
109
|
+
const result = execSync(command, {
|
|
110
|
+
encoding: 'utf8',
|
|
111
|
+
timeout: timeoutMs,
|
|
112
|
+
maxBuffer: 1024 * 1024,
|
|
113
|
+
cwd: process.cwd(),
|
|
114
|
+
env: { ...process.env, TERM: 'dumb' },
|
|
115
|
+
})
|
|
116
|
+
return { stdout: result.slice(0, 10000), exit_code: 0 }
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return {
|
|
119
|
+
stdout: (e.stdout || '').slice(0, 5000),
|
|
120
|
+
stderr: (e.stderr || '').slice(0, 5000),
|
|
121
|
+
exit_code: e.status || 1,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
92
126
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
127
|
+
// ─── Git ────────────────────────────────────────
|
|
128
|
+
git_status: {
|
|
129
|
+
name: 'git_status',
|
|
130
|
+
description: 'Get git status, diff, and recent log',
|
|
131
|
+
parameters: {},
|
|
132
|
+
execute() {
|
|
133
|
+
try {
|
|
134
|
+
const status = execSync('git status --short 2>/dev/null', { encoding: 'utf8', timeout: 5000 }).trim()
|
|
135
|
+
const branch = execSync('git branch --show-current 2>/dev/null', { encoding: 'utf8', timeout: 5000 }).trim()
|
|
136
|
+
const log = execSync('git log --oneline -5 2>/dev/null', { encoding: 'utf8', timeout: 5000 }).trim()
|
|
137
|
+
return { branch, status: status || '(clean)', recent_commits: log }
|
|
138
|
+
} catch {
|
|
139
|
+
return { error: 'Not a git repository' }
|
|
140
|
+
}
|
|
99
141
|
}
|
|
100
|
-
}
|
|
142
|
+
},
|
|
101
143
|
|
|
102
|
-
|
|
103
|
-
|
|
144
|
+
// ─── Project Context ────────────────────────────
|
|
145
|
+
project_context: {
|
|
146
|
+
name: 'project_context',
|
|
147
|
+
description: 'Scan project structure and detect type',
|
|
148
|
+
parameters: { directory: 'string?' },
|
|
149
|
+
execute({ directory }) {
|
|
150
|
+
const dir = directory || process.cwd()
|
|
151
|
+
const files = []
|
|
152
|
+
try {
|
|
153
|
+
const ls = execSync(`find ${JSON.stringify(dir)} -maxdepth 3 -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/__pycache__/*" -not -path "*/dist/*" -not -path "*/.next/*" 2>/dev/null | head -100`, { encoding: 'utf8', timeout: 10000 })
|
|
154
|
+
files.push(...ls.trim().split('\n').filter(Boolean))
|
|
155
|
+
} catch {}
|
|
104
156
|
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
157
|
+
// Detect project type
|
|
158
|
+
const has = (f) => files.some(p => p.endsWith(f))
|
|
159
|
+
let type = 'unknown'
|
|
160
|
+
if (has('package.json')) type = has('next.config.js') || has('next.config.ts') ? 'nextjs' : has('tsconfig.json') ? 'typescript' : 'nodejs'
|
|
161
|
+
else if (has('requirements.txt') || has('setup.py') || has('pyproject.toml')) type = 'python'
|
|
162
|
+
else if (has('Cargo.toml')) type = 'rust'
|
|
163
|
+
else if (has('go.mod')) type = 'go'
|
|
164
|
+
else if (has('Gemfile')) type = 'ruby'
|
|
165
|
+
|
|
166
|
+
// Read key files
|
|
167
|
+
const readme = files.find(f => f.toLowerCase().includes('readme'))
|
|
168
|
+
const pkg = files.find(f => f.endsWith('package.json') && !f.includes('node_modules'))
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
type,
|
|
172
|
+
directory: dir,
|
|
173
|
+
file_count: files.length,
|
|
174
|
+
files: files.map(f => path.relative(dir, f)).slice(0, 60),
|
|
175
|
+
package_json: pkg ? JSON.parse(fs.readFileSync(pkg, 'utf8').slice(0, 2000)) : null,
|
|
176
|
+
}
|
|
117
177
|
}
|
|
118
|
-
|
|
119
|
-
return { success: true, app: appName, platform }
|
|
120
|
-
} catch (e) {
|
|
121
|
-
return { success: false, error: e.message, platform }
|
|
122
|
-
}
|
|
123
|
-
}
|
|
178
|
+
},
|
|
124
179
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
180
|
+
// ─── Web Search ─────────────────────────────────
|
|
181
|
+
web_search: {
|
|
182
|
+
name: 'web_search',
|
|
183
|
+
description: 'Search the web for information',
|
|
184
|
+
parameters: { query: 'string' },
|
|
185
|
+
async execute({ query }) {
|
|
186
|
+
const { getConfig } = require('./config')
|
|
187
|
+
const config = getConfig()
|
|
188
|
+
const http = require('http')
|
|
189
|
+
return new Promise((resolve) => {
|
|
190
|
+
const body = JSON.stringify({ query, max_results: 5 })
|
|
191
|
+
const url = new URL(`${config.gpu_url}/v1/agent/search`)
|
|
192
|
+
const req = http.request(url, {
|
|
193
|
+
method: 'POST',
|
|
194
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.api_key}` },
|
|
195
|
+
timeout: 10000,
|
|
196
|
+
}, (res) => {
|
|
197
|
+
let data = ''
|
|
198
|
+
res.on('data', c => data += c)
|
|
199
|
+
res.on('end', () => {
|
|
200
|
+
try { resolve(JSON.parse(data)) } catch { resolve({ error: 'Bad response' }) }
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
req.on('error', () => resolve({ error: 'Search unavailable' }))
|
|
204
|
+
req.write(body)
|
|
205
|
+
req.end()
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
},
|
|
137
209
|
}
|
|
138
210
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
211
|
+
// ─── Tool definitions for the model ───────────────
|
|
212
|
+
function getToolDefinitions() {
|
|
213
|
+
return [
|
|
214
|
+
{
|
|
215
|
+
type: 'function',
|
|
216
|
+
function: {
|
|
217
|
+
name: 'read',
|
|
218
|
+
description: 'Read a file. Returns numbered lines.',
|
|
219
|
+
parameters: { type: 'object', properties: { file_path: { type: 'string' }, offset: { type: 'integer' }, limit: { type: 'integer' } }, required: ['file_path'] }
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
type: 'function',
|
|
224
|
+
function: {
|
|
225
|
+
name: 'write',
|
|
226
|
+
description: 'Write/create a file.',
|
|
227
|
+
parameters: { type: 'object', properties: { file_path: { type: 'string' }, content: { type: 'string' } }, required: ['file_path', 'content'] }
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
type: 'function',
|
|
232
|
+
function: {
|
|
233
|
+
name: 'edit',
|
|
234
|
+
description: 'Replace an exact string in a file. old_string must be unique.',
|
|
235
|
+
parameters: { type: 'object', properties: { file_path: { type: 'string' }, old_string: { type: 'string' }, new_string: { type: 'string' } }, required: ['file_path', 'old_string', 'new_string'] }
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
type: 'function',
|
|
240
|
+
function: {
|
|
241
|
+
name: 'glob',
|
|
242
|
+
description: 'Find files by name pattern (e.g. "*.tsx", "*.py").',
|
|
243
|
+
parameters: { type: 'object', properties: { pattern: { type: 'string' }, directory: { type: 'string' } }, required: ['pattern'] }
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
type: 'function',
|
|
248
|
+
function: {
|
|
249
|
+
name: 'grep',
|
|
250
|
+
description: 'Search file contents for a regex pattern.',
|
|
251
|
+
parameters: { type: 'object', properties: { pattern: { type: 'string' }, directory: { type: 'string' }, glob: { type: 'string' } }, required: ['pattern'] }
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
type: 'function',
|
|
256
|
+
function: {
|
|
257
|
+
name: 'bash',
|
|
258
|
+
description: 'Run a shell command. Use for: npm, git, python, build, test, etc.',
|
|
259
|
+
parameters: { type: 'object', properties: { command: { type: 'string' }, timeout: { type: 'integer' } }, required: ['command'] }
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
type: 'function',
|
|
264
|
+
function: {
|
|
265
|
+
name: 'git_status',
|
|
266
|
+
description: 'Get git branch, status, and recent commits.',
|
|
267
|
+
parameters: { type: 'object', properties: {} }
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
type: 'function',
|
|
272
|
+
function: {
|
|
273
|
+
name: 'project_context',
|
|
274
|
+
description: 'Scan project structure: file list, type detection, package.json.',
|
|
275
|
+
parameters: { type: 'object', properties: { directory: { type: 'string' } } }
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
type: 'function',
|
|
280
|
+
function: {
|
|
281
|
+
name: 'web_search',
|
|
282
|
+
description: 'Search the web for current information.',
|
|
283
|
+
parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] }
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
]
|
|
153
287
|
}
|
|
154
288
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
289
|
+
// ─── Execute a tool call ──────────────────────────
|
|
290
|
+
async function executeTool(name, args) {
|
|
291
|
+
const tool = TOOLS[name]
|
|
292
|
+
if (!tool) return { error: `Unknown tool: ${name}` }
|
|
158
293
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
else if (platform === 'win32') cmd = `powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::PrimaryScreen | ForEach-Object { $bitmap = New-Object System.Drawing.Bitmap($_.Bounds.Width, $_.Bounds.Height); $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($_.Bounds.Location, [System.Drawing.Point]::Empty, $_.Bounds.Size); $bitmap.Save('${tmpFile}'); }"`
|
|
162
|
-
else cmd = `import -window root "${tmpFile}" 2>/dev/null || gnome-screenshot -f "${tmpFile}" 2>/dev/null || scrot "${tmpFile}" 2>/dev/null`
|
|
163
|
-
execSync(cmd, { timeout: 10000 })
|
|
164
|
-
if (fs.existsSync(tmpFile)) {
|
|
165
|
-
return { success: true, path: tmpFile, size: fs.statSync(tmpFile).size }
|
|
166
|
-
}
|
|
167
|
-
return { success: false, error: 'Screenshot file not created' }
|
|
294
|
+
const result = await tool.execute(args)
|
|
295
|
+
return result
|
|
168
296
|
} catch (e) {
|
|
169
|
-
return {
|
|
297
|
+
return { error: e.message }
|
|
170
298
|
}
|
|
171
299
|
}
|
|
172
300
|
|
|
173
|
-
module.exports = {
|
|
301
|
+
module.exports = { TOOLS, getToolDefinitions, executeTool }
|
package/package.json
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "otoro-cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Otoro AGI — AI coding assistant for your terminal.
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Otoro AGI — AI coding assistant for your terminal. Like Claude Code, but open.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"otoro": "./bin/otoro.js"
|
|
8
8
|
},
|
|
9
|
-
"keywords": ["ai", "coding", "assistant", "cli", "otoro", "agi", "code-generation", "developer-tools"],
|
|
9
|
+
"keywords": ["ai", "coding", "assistant", "cli", "otoro", "agi", "code-generation", "developer-tools", "claude-code"],
|
|
10
10
|
"author": "Otoro Team",
|
|
11
11
|
"license": "MIT",
|
|
12
|
-
"repository": {
|
|
13
|
-
"type": "git",
|
|
14
|
-
"url": "https://github.com/otoroagi/cli"
|
|
15
|
-
},
|
|
16
12
|
"homepage": "https://otoroagi.com",
|
|
17
13
|
"dependencies": {
|
|
18
14
|
"chalk": "^4.1.2",
|