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/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 chalk = require('chalk')
3
+ const { execSync, exec } = require('child_process')
4
+ const os = require('os')
5
5
 
6
- function readFile(filePath) {
7
- try {
8
- const resolved = path.resolve(filePath)
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
- function writeFile(filePath, content) {
17
- try {
18
- const resolved = path.resolve(filePath)
19
- const dir = path.dirname(resolved)
20
- fs.mkdirSync(dir, { recursive: true })
21
- fs.writeFileSync(resolved, content)
22
- return { success: true, path: resolved, lines: content.split('\n').length }
23
- } catch (e) {
24
- return { success: false, error: e.message }
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
- function editFile(filePath, oldStr, newStr) {
29
- try {
30
- const resolved = path.resolve(filePath)
31
- let content = fs.readFileSync(resolved, 'utf8')
32
- if (!content.includes(oldStr)) {
33
- return { success: false, error: 'Old string not found in file' }
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
- content = content.replace(oldStr, newStr)
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
- function listFiles(dir = '.', pattern = '') {
44
- try {
45
- const resolved = path.resolve(dir)
46
- const files = []
47
- function walk(d, depth = 0) {
48
- if (depth > 4) return
49
- const entries = fs.readdirSync(d, { withFileTypes: true })
50
- for (const e of entries) {
51
- if (e.name.startsWith('.') || e.name === 'node_modules' || e.name === '__pycache__') continue
52
- const full = path.join(d, e.name)
53
- const rel = path.relative(resolved, full)
54
- if (e.isDirectory()) {
55
- files.push(rel + '/')
56
- walk(full, depth + 1)
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
- walk(resolved)
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
- function runCommand(cmd, timeout = 15000) {
70
- try {
71
- const output = execSync(cmd, { encoding: 'utf8', timeout, maxBuffer: 1024 * 1024 })
72
- return { success: true, output: output.slice(0, 8000) }
73
- } catch (e) {
74
- return { success: false, output: (e.stdout || '').slice(0, 4000), error: (e.stderr || e.message).slice(0, 2000) }
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
- function searchCode(pattern, dir = '.') {
79
- try {
80
- const output = execSync(`grep -rn "${pattern}" "${dir}" --include="*.{js,ts,tsx,py,go,rs,java,c,cpp,h,css,html,json,yaml,yml,md,sh}" 2>/dev/null | head -30`, {
81
- encoding: 'utf8', timeout: 10000
82
- })
83
- return { success: true, results: output.trim() }
84
- } catch (e) {
85
- return { success: true, results: '' }
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
- // Process AI response — extract and apply file operations
90
- function processToolCalls(response) {
91
- const actions = []
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
- // Extract file writes: ```filename.ext\n...content...\n```
94
- const fileBlocks = response.matchAll(/```(\S+\.\w+)\n([\s\S]*?)```/g)
95
- for (const match of fileBlocks) {
96
- const [, filename, content] = match
97
- if (filename && !['python', 'javascript', 'typescript', 'bash', 'json', 'html', 'css', 'sql', 'rust', 'go', 'java', 'c', 'cpp'].includes(filename)) {
98
- actions.push({ type: 'write', path: filename, content })
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
- return actions
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
- // Platform-aware app launching
106
- function openApp(appName) {
107
- const platform = process.platform
108
- try {
109
- let cmd
110
- if (platform === 'darwin') {
111
- cmd = `open -a "${appName}"`
112
- } else if (platform === 'win32') {
113
- cmd = `start "" "${appName}"`
114
- } else {
115
- // Linux try common approaches
116
- cmd = `${appName.toLowerCase()} &`
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
- execSync(cmd, { timeout: 5000, stdio: 'ignore' })
119
- return { success: true, app: appName, platform }
120
- } catch (e) {
121
- return { success: false, error: e.message, platform }
122
- }
123
- }
178
+ },
124
179
 
125
- function openUrl(url) {
126
- const platform = process.platform
127
- try {
128
- let cmd
129
- if (platform === 'darwin') cmd = `open "${url}"`
130
- else if (platform === 'win32') cmd = `start "" "${url}"`
131
- else cmd = `xdg-open "${url}"`
132
- execSync(cmd, { timeout: 5000, stdio: 'ignore' })
133
- return { success: true, url }
134
- } catch (e) {
135
- return { success: false, error: e.message }
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
- function getSystemInfo() {
140
- const os = require('os')
141
- return {
142
- platform: process.platform,
143
- arch: process.arch,
144
- hostname: os.hostname(),
145
- user: os.userInfo().username,
146
- home: os.homedir(),
147
- cwd: process.cwd(),
148
- node: process.version,
149
- cpus: os.cpus().length,
150
- memory: Math.round(os.totalmem() / 1024 / 1024 / 1024) + 'GB',
151
- freeMemory: Math.round(os.freemem() / 1024 / 1024 / 1024) + 'GB',
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
- function takeScreenshot() {
156
- const platform = process.platform
157
- const tmpFile = path.join(require('os').tmpdir(), `otoro-screenshot-${Date.now()}.png`)
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
- let cmd
160
- if (platform === 'darwin') cmd = `screencapture -x "${tmpFile}"`
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 { success: false, error: e.message }
297
+ return { error: e.message }
170
298
  }
171
299
  }
172
300
 
173
- module.exports = { readFile, writeFile, editFile, listFiles, runCommand, searchCode, processToolCalls, openApp, openUrl, getSystemInfo, takeScreenshot }
301
+ module.exports = { TOOLS, getToolDefinitions, executeTool }
package/package.json CHANGED
@@ -1,18 +1,14 @@
1
1
  {
2
2
  "name": "otoro-cli",
3
- "version": "1.4.0",
4
- "description": "Otoro AGI — AI coding assistant for your terminal. Code, generate images, execute tasks, and control your projects remotely.",
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",