@ikyyofc/gemini-cli 3.0.8 → 4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikyyofc/gemini-cli",
3
- "version": "3.0.8",
3
+ "version": "4.0.0",
4
4
  "description": "AI Agent CLI — native function calling · GEMINI.md context · extensions",
5
5
  "type": "module",
6
6
  "bin": { "gemini": "./index.js" },
package/src/agent.js CHANGED
@@ -20,8 +20,21 @@ function buildSystemPrompt(extra = "") {
20
20
  const skills = loadSkills();
21
21
  const skillsBlock = buildSkillsPrompt(skills);
22
22
 
23
+ // Current datetime injected directly — no tool needed
24
+ const now = new Date();
25
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
26
+ const datetime = now.toLocaleString("id-ID", {
27
+ timeZone: tz,
28
+ dateStyle: "full",
29
+ timeStyle: "long",
30
+ });
31
+
23
32
  return `You are an autonomous AI coding agent running in the user's terminal. You have full access to their filesystem and shell through tools.
24
33
 
34
+ ## CURRENT TIME
35
+ ${datetime} (${tz})
36
+ Unix timestamp: ${Math.floor(now.getTime() / 1000)}
37
+
25
38
  ## CORE RULE — NEVER ASK, ALWAYS ACT
26
39
 
27
40
  You MUST use tools to complete tasks. You are NEVER allowed to:
package/src/tools.js CHANGED
@@ -161,6 +161,285 @@ export const FUNCTION_DECLARATIONS = [
161
161
  },
162
162
  required: ["url"]
163
163
  }
164
+ },
165
+ // ── NEW TOOLS ────────────────────────────────────────────────
166
+ {
167
+ name: "copy_file",
168
+ description: "Copy a file or directory to a new location.",
169
+ parameters: {
170
+ type: "OBJECT",
171
+ properties: {
172
+ from: { type: "STRING", description: "Source path" },
173
+ to: { type: "STRING", description: "Destination path" },
174
+ recursive: { type: "BOOLEAN", description: "Copy directory recursively (default true)" }
175
+ },
176
+ required: ["from", "to"]
177
+ }
178
+ },
179
+ {
180
+ name: "read_file_lines",
181
+ description: "Read a specific range of lines from a file. Use for large files.",
182
+ parameters: {
183
+ type: "OBJECT",
184
+ properties: {
185
+ path: { type: "STRING", description: "File path" },
186
+ start: { type: "NUMBER", description: "Start line number (1-based)" },
187
+ end: { type: "NUMBER", description: "End line number (inclusive)" }
188
+ },
189
+ required: ["path", "start", "end"]
190
+ }
191
+ },
192
+ {
193
+ name: "tree",
194
+ description: "Show directory structure as a tree. Good for understanding project layout.",
195
+ parameters: {
196
+ type: "OBJECT",
197
+ properties: {
198
+ path: { type: "STRING", description: "Root directory (default: .)" },
199
+ depth: { type: "NUMBER", description: "Max depth (default: 3)" },
200
+ show_hidden: { type: "BOOLEAN", description: "Include hidden files" }
201
+ }
202
+ }
203
+ },
204
+ {
205
+ name: "file_info",
206
+ description: "Get file or directory metadata: size, permissions, modified date, type.",
207
+ parameters: {
208
+ type: "OBJECT",
209
+ properties: {
210
+ path: { type: "STRING", description: "File or directory path" }
211
+ },
212
+ required: ["path"]
213
+ }
214
+ },
215
+ {
216
+ name: "diff_files",
217
+ description: "Show line-by-line differences between two files.",
218
+ parameters: {
219
+ type: "OBJECT",
220
+ properties: {
221
+ file_a: { type: "STRING", description: "First file path" },
222
+ file_b: { type: "STRING", description: "Second file path" }
223
+ },
224
+ required: ["file_a", "file_b"]
225
+ }
226
+ },
227
+ {
228
+ name: "http_request",
229
+ description: "Make an HTTP request (GET/POST/PUT/PATCH/DELETE) with headers and body.",
230
+ parameters: {
231
+ type: "OBJECT",
232
+ properties: {
233
+ url: { type: "STRING", description: "URL to request" },
234
+ method: { type: "STRING", description: "HTTP method (default: GET)" },
235
+ headers: { type: "STRING", description: "JSON string of request headers" },
236
+ body: { type: "STRING", description: "Request body for POST/PUT/PATCH" }
237
+ },
238
+ required: ["url"]
239
+ }
240
+ },
241
+ {
242
+ name: "download_file",
243
+ description: "Download a file from a URL and save it to disk.",
244
+ parameters: {
245
+ type: "OBJECT",
246
+ properties: {
247
+ url: { type: "STRING", description: "URL to download" },
248
+ dest: { type: "STRING", description: "Destination file path" }
249
+ },
250
+ required: ["url", "dest"]
251
+ }
252
+ },
253
+ {
254
+ name: "hash_file",
255
+ description: "Calculate hash of a file (md5, sha1, sha256).",
256
+ parameters: {
257
+ type: "OBJECT",
258
+ properties: {
259
+ path: { type: "STRING", description: "File path" },
260
+ algorithm: { type: "STRING", description: "md5 | sha1 | sha256 (default: sha256)" }
261
+ },
262
+ required: ["path"]
263
+ }
264
+ },
265
+ {
266
+ name: "base64",
267
+ description: "Encode or decode base64. Works on strings or files.",
268
+ parameters: {
269
+ type: "OBJECT",
270
+ properties: {
271
+ action: { type: "STRING", description: "encode or decode" },
272
+ input: { type: "STRING", description: "String to process, or file path if is_file=true" },
273
+ is_file: { type: "BOOLEAN", description: "If true, input is treated as a file path" }
274
+ },
275
+ required: ["action", "input"]
276
+ }
277
+ },
278
+ {
279
+ name: "json_query",
280
+ description: "Parse and query JSON using a dot-notation key path. e.g. 'user.name', 'items[0].id'.",
281
+ parameters: {
282
+ type: "OBJECT",
283
+ properties: {
284
+ input: { type: "STRING", description: "JSON file path or raw JSON string" },
285
+ query: { type: "STRING", description: "Key path e.g. 'user.name' (empty = pretty-print all)" },
286
+ is_file: { type: "BOOLEAN", description: "If true, input is a file path" }
287
+ },
288
+ required: ["input"]
289
+ }
290
+ },
291
+ {
292
+ name: "extract",
293
+ description: "Extract a zip, tar, tar.gz, or tar.bz2 archive.",
294
+ parameters: {
295
+ type: "OBJECT",
296
+ properties: {
297
+ archive: { type: "STRING", description: "Path to the archive file" },
298
+ dest: { type: "STRING", description: "Destination directory (default: same as archive)" }
299
+ },
300
+ required: ["archive"]
301
+ }
302
+ },
303
+ {
304
+ name: "compress",
305
+ description: "Compress files or a directory into a zip or tar.gz archive.",
306
+ parameters: {
307
+ type: "OBJECT",
308
+ properties: {
309
+ source: { type: "STRING", description: "File or directory to compress" },
310
+ dest: { type: "STRING", description: "Output archive path (.zip or .tar.gz)" }
311
+ },
312
+ required: ["source", "dest"]
313
+ }
314
+ },
315
+ {
316
+ name: "git",
317
+ description: "Run git operations: status, log, diff, add, commit, push, pull, clone, checkout, branch, init, stash.",
318
+ parameters: {
319
+ type: "OBJECT",
320
+ properties: {
321
+ action: { type: "STRING", description: "status | log | diff | add | commit | push | pull | clone | checkout | branch | init | stash" },
322
+ args: { type: "STRING", description: "Additional arguments (commit message, branch name, remote, etc.)" },
323
+ cwd: { type: "STRING", description: "Working directory (default: current)" }
324
+ },
325
+ required: ["action"]
326
+ }
327
+ },
328
+ {
329
+ name: "process_list",
330
+ description: "List running processes. Filter by name optionally.",
331
+ parameters: {
332
+ type: "OBJECT",
333
+ properties: {
334
+ filter: { type: "STRING", description: "Filter by process name (optional)" }
335
+ }
336
+ }
337
+ },
338
+ {
339
+ name: "disk_usage",
340
+ description: "Check disk usage of a path (du) or total disk space (df). Use path='/' for overall.",
341
+ parameters: {
342
+ type: "OBJECT",
343
+ properties: {
344
+ path: { type: "STRING", description: "Path to check (default: current dir)" }
345
+ }
346
+ }
347
+ },
348
+ {
349
+ name: "chmod",
350
+ description: "Change file permissions. e.g. '755', '+x', 'a+r'.",
351
+ parameters: {
352
+ type: "OBJECT",
353
+ properties: {
354
+ path: { type: "STRING", description: "File or directory path" },
355
+ permissions: { type: "STRING", description: "Permission string: '755', '+x', 'a+r', etc." },
356
+ recursive: { type: "BOOLEAN", description: "Apply recursively" }
357
+ },
358
+ required: ["path", "permissions"]
359
+ }
360
+ },
361
+
362
+ // ── REAL-TIME TOOLS ──────────────────────────────────────────
363
+ {
364
+ name: "web_search",
365
+ description: "Search the web for current information, news, facts, or anything up-to-date. Use this when you need real-time or recent information.",
366
+ parameters: {
367
+ type: "OBJECT",
368
+ properties: {
369
+ query: { type: "STRING", description: "Search query" },
370
+ region: { type: "STRING", description: "Region code e.g. id-id, us-en, my-en (default: id-id)" }
371
+ },
372
+ required: ["query"]
373
+ }
374
+ },
375
+ {
376
+ name: "get_weather",
377
+ description: "Get current weather and forecast for any city or location.",
378
+ parameters: {
379
+ type: "OBJECT",
380
+ properties: {
381
+ location: { type: "STRING", description: "City name or location e.g. 'Jakarta', 'New York', 'Tokyo'" },
382
+ format: { type: "STRING", description: "simple (1 line) | full (detailed JSON) — default: simple" }
383
+ },
384
+ required: ["location"]
385
+ }
386
+ },
387
+ {
388
+ name: "get_news",
389
+ description: "Get latest news headlines on any topic from around the web.",
390
+ parameters: {
391
+ type: "OBJECT",
392
+ properties: {
393
+ query: { type: "STRING", description: "Topic or keyword to search news for (e.g. 'AI', 'Indonesia', 'bitcoin')" },
394
+ language: { type: "STRING", description: "Language code: id, en, etc. (default: id)" },
395
+ limit: { type: "NUMBER", description: "Number of results (default: 10, max: 20)" }
396
+ },
397
+ required: ["query"]
398
+ }
399
+ },
400
+ {
401
+ name: "get_exchange_rate",
402
+ description: "Get current currency exchange rates. Supports all major currencies.",
403
+ parameters: {
404
+ type: "OBJECT",
405
+ properties: {
406
+ from: { type: "STRING", description: "Base currency code e.g. USD, IDR, EUR (default: USD)" },
407
+ to: { type: "STRING", description: "Target currency or comma-separated list e.g. 'IDR,EUR,JPY'" }
408
+ }
409
+ }
410
+ },
411
+ {
412
+ name: "get_stock",
413
+ description: "Get real-time or latest stock price and info for a ticker symbol.",
414
+ parameters: {
415
+ type: "OBJECT",
416
+ properties: {
417
+ symbol: { type: "STRING", description: "Stock ticker symbol e.g. AAPL, GOOGL, BBCA.JK, TLKM.JK" }
418
+ },
419
+ required: ["symbol"]
420
+ }
421
+ },
422
+ {
423
+ name: "get_crypto",
424
+ description: "Get current cryptocurrency prices and market data.",
425
+ parameters: {
426
+ type: "OBJECT",
427
+ properties: {
428
+ coins: { type: "STRING", description: "Coin IDs comma-separated e.g. 'bitcoin,ethereum,solana'" },
429
+ currency: { type: "STRING", description: "Fiat currency for prices e.g. usd, idr (default: usd)" }
430
+ },
431
+ required: ["coins"]
432
+ }
433
+ },
434
+ {
435
+ name: "get_ip_info",
436
+ description: "Get geolocation and network info for an IP address, or your own public IP if none given.",
437
+ parameters: {
438
+ type: "OBJECT",
439
+ properties: {
440
+ ip: { type: "STRING", description: "IP address to look up (leave empty for your own IP)" }
441
+ }
442
+ }
164
443
  }
165
444
  ];
166
445
 
@@ -333,6 +612,422 @@ export async function executeTool(name, args = {}, { autoApprove = false } = {})
333
612
  return { result: stdout.trim() || "(empty response)" };
334
613
  }
335
614
 
615
+ // ── NEW TOOLS ──────────────────────────────────────────────
616
+ case "copy_file": {
617
+ const from = path.resolve(args.from);
618
+ const to = path.resolve(args.to);
619
+ if (!fs.existsSync(from)) return { error: `Not found: ${from}` };
620
+ fs.mkdirSync(path.dirname(to), { recursive: true });
621
+ const rec = args.recursive !== false;
622
+ const flag = rec ? "-r" : "";
623
+ await execAsync(`cp ${flag} "${from}" "${to}"`);
624
+ return { result: `Copied: ${from} → ${to}` };
625
+ }
626
+
627
+ case "read_file_lines": {
628
+ const p = path.resolve(args.path);
629
+ if (!fs.existsSync(p)) return { error: `File not found: ${p}` };
630
+ const lines = fs.readFileSync(p, "utf8").split("\n");
631
+ const start = Math.max(1, args.start) - 1;
632
+ const end = Math.min(lines.length, args.end);
633
+ const slice = lines.slice(start, end);
634
+ const numbered = slice.map((l, i) =>
635
+ `${String(start + i + 1).padStart(4)} │ ${l}`
636
+ ).join("\n");
637
+ return { result: numbered, total_lines: lines.length };
638
+ }
639
+
640
+ case "tree": {
641
+ const p = path.resolve(args.path || ".");
642
+ const depth = args.depth ?? 3;
643
+ const showH = args.show_hidden ?? false;
644
+ const SKIP = new Set(["node_modules", ".git", "dist", "build"]);
645
+ const lines = [];
646
+
647
+ const walk = (dir, prefix, d) => {
648
+ if (d > depth) return;
649
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
650
+ .filter(e => showH || !e.name.startsWith("."))
651
+ .filter(e => !SKIP.has(e.name));
652
+ entries.forEach((e, i) => {
653
+ const last = i === entries.length - 1;
654
+ const branch = last ? "└── " : "├── ";
655
+ const child = last ? " " : "│ ";
656
+ lines.push(prefix + branch + (e.isDirectory() ? e.name + "/" : e.name));
657
+ if (e.isDirectory()) walk(path.join(dir, e.name), prefix + child, d + 1);
658
+ });
659
+ };
660
+
661
+ lines.push(p);
662
+ walk(p, "", 0);
663
+ return { result: lines.join("\n") };
664
+ }
665
+
666
+ case "file_info": {
667
+ const p = path.resolve(args.path);
668
+ if (!fs.existsSync(p)) return { error: `Not found: ${p}` };
669
+ const st = fs.statSync(p);
670
+ return {
671
+ result: JSON.stringify({
672
+ path: p,
673
+ type: st.isDirectory() ? "directory" : "file",
674
+ size: fmtBytes(st.size),
675
+ bytes: st.size,
676
+ mode: "0" + (st.mode & 0o777).toString(8),
677
+ modified: st.mtime.toISOString(),
678
+ created: st.birthtime.toISOString(),
679
+ }, null, 2)
680
+ };
681
+ }
682
+
683
+ case "diff_files": {
684
+ const a = path.resolve(args.file_a);
685
+ const b = path.resolve(args.file_b);
686
+ const { stdout } = await execAsync(`diff -u "${a}" "${b}"`).catch(e => ({ stdout: e.stdout || "" }));
687
+ return { result: stdout || "Files are identical." };
688
+ }
689
+
690
+ case "http_request": {
691
+ const method = (args.method ?? "GET").toUpperCase();
692
+ let hdrs = "";
693
+ if (args.headers) {
694
+ try {
695
+ const obj = JSON.parse(args.headers);
696
+ hdrs = Object.entries(obj).map(([k,v]) => `-H "${k}: ${v}"`).join(" ");
697
+ } catch {}
698
+ }
699
+ const bodyFlag = args.body ? `-d '${args.body.replace(/'/g, "'\\''")}'` : "";
700
+ const cmd = `curl -s -X ${method} ${hdrs} ${bodyFlag} --max-time 20 "${args.url}" | head -c 102400`;
701
+ const { stdout } = await execAsync(cmd);
702
+ return { result: stdout.trim() || "(empty response)" };
703
+ }
704
+
705
+ case "download_file": {
706
+ const dest = path.resolve(args.dest);
707
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
708
+ await execAsync(`curl -L --max-time 60 -o "${dest}" "${args.url}"`);
709
+ const size = fmtBytes(fs.statSync(dest).size);
710
+ return { result: `Downloaded to ${dest} (${size})` };
711
+ }
712
+
713
+ case "hash_file": {
714
+ const p = path.resolve(args.path);
715
+ if (!fs.existsSync(p)) return { error: `Not found: ${p}` };
716
+ const alg = args.algorithm ?? "sha256";
717
+ const cmd = alg === "md5"
718
+ ? `md5sum "${p}" 2>/dev/null || md5 -q "${p}"`
719
+ : `sha${alg === "sha256" ? "256" : alg === "sha1" ? "1" : "256"}sum "${p}" 2>/dev/null || shasum -a ${alg === "sha1" ? "1" : "256"} "${p}"`;
720
+ const { stdout } = await execAsync(cmd);
721
+ return { result: stdout.trim().split(" ")[0], algorithm: alg, path: p };
722
+ }
723
+
724
+ case "base64": {
725
+ const action = args.action.toLowerCase();
726
+ let input = args.input;
727
+ if (args.is_file) {
728
+ const p = path.resolve(input);
729
+ if (!fs.existsSync(p)) return { error: `File not found: ${p}` };
730
+ input = fs.readFileSync(p, "utf8");
731
+ }
732
+ if (action === "encode") {
733
+ return { result: Buffer.from(input).toString("base64") };
734
+ } else {
735
+ return { result: Buffer.from(input, "base64").toString("utf8") };
736
+ }
737
+ }
738
+
739
+ case "json_query": {
740
+ let data;
741
+ if (args.is_file) {
742
+ const p = path.resolve(args.input);
743
+ if (!fs.existsSync(p)) return { error: `File not found: ${p}` };
744
+ data = JSON.parse(fs.readFileSync(p, "utf8"));
745
+ } else {
746
+ data = JSON.parse(args.input);
747
+ }
748
+ if (!args.query) return { result: JSON.stringify(data, null, 2) };
749
+ // Simple dot+bracket notation traversal
750
+ const keys = args.query.replace(/\[(\d+)\]/g, ".$1").split(".");
751
+ let val = data;
752
+ for (const k of keys) {
753
+ if (val == null) return { error: `Key "${k}" not found` };
754
+ val = val[k];
755
+ }
756
+ return { result: typeof val === "object" ? JSON.stringify(val, null, 2) : String(val) };
757
+ }
758
+
759
+ case "extract": {
760
+ const archive = path.resolve(args.archive);
761
+ if (!fs.existsSync(archive)) return { error: `Archive not found: ${archive}` };
762
+ const dest = path.resolve(args.dest ?? path.dirname(archive));
763
+ fs.mkdirSync(dest, { recursive: true });
764
+ const ext = archive.toLowerCase();
765
+ let cmd;
766
+ if (ext.endsWith(".zip")) cmd = `unzip -o "${archive}" -d "${dest}"`;
767
+ else if (ext.endsWith(".tar.gz") || ext.endsWith(".tgz")) cmd = `tar -xzf "${archive}" -C "${dest}"`;
768
+ else if (ext.endsWith(".tar.bz2")) cmd = `tar -xjf "${archive}" -C "${dest}"`;
769
+ else if (ext.endsWith(".tar")) cmd = `tar -xf "${archive}" -C "${dest}"`;
770
+ else if (ext.endsWith(".gz")) cmd = `gunzip -k "${archive}"`;
771
+ else return { error: `Unsupported archive format: ${archive}` };
772
+ const { stdout, stderr } = await execAsync(cmd);
773
+ return { result: `Extracted to ${dest}` };
774
+ }
775
+
776
+ case "compress": {
777
+ const source = path.resolve(args.source);
778
+ const dest = path.resolve(args.dest);
779
+ if (!fs.existsSync(source)) return { error: `Not found: ${source}` };
780
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
781
+ let cmd;
782
+ if (dest.endsWith(".zip")) cmd = `zip -r "${dest}" "${source}"`;
783
+ else if (dest.endsWith(".tar.gz") || dest.endsWith(".tgz")) cmd = `tar -czf "${dest}" "${source}"`;
784
+ else if (dest.endsWith(".tar")) cmd = `tar -cf "${dest}" "${source}"`;
785
+ else return { error: "Unsupported format. Use .zip or .tar.gz" };
786
+ await execAsync(cmd);
787
+ const size = fmtBytes(fs.statSync(dest).size);
788
+ return { result: `Compressed to ${dest} (${size})` };
789
+ }
790
+
791
+ case "git": {
792
+ const cwd = args.cwd ? path.resolve(args.cwd) : process.cwd();
793
+ const a = args.action.toLowerCase();
794
+ const extra = args.args ?? "";
795
+ const gitCmds = {
796
+ status: `git status`,
797
+ log: `git log --oneline -20 ${extra}`,
798
+ diff: `git diff ${extra}`,
799
+ add: `git add ${extra || "."}`,
800
+ commit: `git commit -m "${extra}"`,
801
+ push: `git push ${extra}`,
802
+ pull: `git pull ${extra}`,
803
+ clone: `git clone ${extra}`,
804
+ checkout: `git checkout ${extra}`,
805
+ branch: `git branch ${extra}`,
806
+ init: `git init ${extra}`,
807
+ stash: `git stash ${extra}`,
808
+ };
809
+ const cmd = gitCmds[a];
810
+ if (!cmd) return { error: `Unknown git action: ${a}` };
811
+ const { stdout, stderr } = await execAsync(cmd, { cwd }).catch(e => ({
812
+ stdout: e.stdout || "", stderr: e.stderr || ""
813
+ }));
814
+ return { result: (stdout + (stderr ? "\n" + stderr : "")).trim() || "(no output)" };
815
+ }
816
+
817
+ case "process_list": {
818
+ const filter = args.filter ?? "";
819
+ const cmd = filter
820
+ ? `ps aux | grep -i "${filter}" | grep -v grep`
821
+ : `ps aux | head -30`;
822
+ const { stdout } = await execAsync(cmd).catch(e => ({ stdout: e.stdout || "" }));
823
+ return { result: stdout.trim() || "No processes found." };
824
+ }
825
+
826
+ case "disk_usage": {
827
+ const p = args.path ? path.resolve(args.path) : process.cwd();
828
+ const { stdout: du } = await execAsync(`du -sh "${p}" 2>/dev/null`).catch(() => ({ stdout: "" }));
829
+ const { stdout: df } = await execAsync(`df -h "${p}" 2>/dev/null`).catch(() => ({ stdout: "" }));
830
+ return { result: [du.trim(), df.trim()].filter(Boolean).join("\n\n") || "(unavailable)" };
831
+ }
832
+
833
+ case "chmod": {
834
+ const p = path.resolve(args.path);
835
+ if (!fs.existsSync(p)) return { error: `Not found: ${p}` };
836
+ const rec = args.recursive ? "-R" : "";
837
+ await execAsync(`chmod ${rec} ${args.permissions} "${p}"`);
838
+ return { result: `chmod ${args.permissions} applied to ${p}` };
839
+ }
840
+
841
+ // ── REAL-TIME TOOLS ──────────────────────────────────────
842
+ case "web_search": {
843
+ const q = encodeURIComponent(args.query);
844
+ const reg = args.region ?? "id-id";
845
+ // DuckDuckGo Instant Answer API
846
+ const { stdout: ddg } = await execAsync(
847
+ `curl -sL --max-time 10 "https://api.duckduckgo.com/?q=${q}&format=json&no_redirect=1&no_html=1&skip_disambig=1"`
848
+ ).catch(() => ({ stdout: "" }));
849
+
850
+ // Also get HTML search results via scraping (lite version)
851
+ const { stdout: html } = await execAsync(
852
+ `curl -sL --max-time 10 -H "User-Agent: Mozilla/5.0" "https://html.duckduckgo.com/html/?q=${q}" | grep -oP '(?<=class="result__snippet">)[^<]+' | head -8`
853
+ ).catch(() => ({ stdout: "" }));
854
+
855
+ let out = "";
856
+ try {
857
+ const data = JSON.parse(ddg);
858
+ if (data.AbstractText) out += `📖 ${data.AbstractText}\n Source: ${data.AbstractURL}\n\n`;
859
+ if (data.Answer) out += `💡 ${data.Answer}\n\n`;
860
+ if (data.RelatedTopics?.length) {
861
+ out += "Related:\n";
862
+ data.RelatedTopics.slice(0, 5).forEach(t => {
863
+ if (t.Text) out += ` · ${t.Text}\n`;
864
+ });
865
+ }
866
+ } catch {}
867
+
868
+ if (html.trim()) out += "\nSearch snippets:\n" + html.trim().split("\n").map(l => ` · ${l.trim()}`).join("\n");
869
+
870
+ return { result: out.trim() || "No results found. Try a different query." };
871
+ }
872
+
873
+ case "get_weather": {
874
+ const loc = encodeURIComponent(args.location);
875
+ if (args.format === "full") {
876
+ const { stdout } = await execAsync(
877
+ `curl -sL --max-time 10 "https://wttr.in/${loc}?format=j1"`
878
+ );
879
+ try {
880
+ const data = JSON.parse(stdout);
881
+ const current = data.current_condition[0];
882
+ const area = data.nearest_area[0];
883
+ const city = area.areaName[0].value;
884
+ const country = area.country[0].value;
885
+ return {
886
+ result: JSON.stringify({
887
+ location: `${city}, ${country}`,
888
+ temp_c: current.temp_C + "°C",
889
+ feels_like: current.FeelsLikeC + "°C",
890
+ humidity: current.humidity + "%",
891
+ wind_kmph: current.windspeedKmph + " km/h",
892
+ description: current.weatherDesc[0].value,
893
+ visibility: current.visibility + " km",
894
+ uv_index: current.uvIndex,
895
+ }, null, 2)
896
+ };
897
+ } catch {}
898
+ }
899
+ // Simple one-line format
900
+ const { stdout } = await execAsync(
901
+ `curl -sL --max-time 10 "https://wttr.in/${loc}?format=3"`
902
+ );
903
+ return { result: stdout.trim() || "Could not fetch weather." };
904
+ }
905
+
906
+ case "get_news": {
907
+ const q = encodeURIComponent(args.query);
908
+ const lang = args.language ?? "id";
909
+ const lim = Math.min(args.limit ?? 10, 20);
910
+
911
+ // Use HackerNews Algolia API for tech news, BBC/Reuters RSS for general
912
+ const results = [];
913
+
914
+ // Try GNews RSS (free, no key)
915
+ const { stdout: hn } = await execAsync(
916
+ `curl -sL --max-time 12 "https://hn.algolia.com/api/v1/search?query=${q}&tags=story&hitsPerPage=${lim}" 2>/dev/null`
917
+ ).catch(() => ({ stdout: "" }));
918
+
919
+ try {
920
+ const data = JSON.parse(hn);
921
+ if (data.hits?.length) {
922
+ data.hits.forEach((h, i) => {
923
+ results.push(`${i+1}. ${h.title}\n 🔗 ${h.url || "https://news.ycombinator.com/item?id="+h.objectID}\n ⏱ ${h.created_at}`);
924
+ });
925
+ }
926
+ } catch {}
927
+
928
+ // Also try Google News RSS if lang is id
929
+ if (results.length < 5) {
930
+ const { stdout: rss } = await execAsync(
931
+ `curl -sL --max-time 12 "https://news.google.com/rss/search?q=${q}&hl=${lang}&gl=ID&ceid=ID:${lang}" | grep -oP '(?<=<title>)[^<]+' | grep -v "Google News" | head -${lim}`
932
+ ).catch(() => ({ stdout: "" }));
933
+ if (rss.trim()) {
934
+ const headlines = rss.trim().split("\n").filter(Boolean);
935
+ headlines.forEach((h, i) => {
936
+ if (!results.find(r => r.includes(h)))
937
+ results.push(`${results.length+1}. ${h}`);
938
+ });
939
+ }
940
+ }
941
+
942
+ return { result: results.slice(0, lim).join("\n\n") || "No news found." };
943
+ }
944
+
945
+ case "get_exchange_rate": {
946
+ const from = (args.from ?? "USD").toUpperCase();
947
+ const to = args.to ? args.to.toUpperCase() : "";
948
+ const url = to
949
+ ? `https://api.frankfurter.app/latest?from=${from}&to=${to}`
950
+ : `https://api.frankfurter.app/latest?from=${from}`;
951
+ const { stdout } = await execAsync(`curl -sL --max-time 10 "${url}"`);
952
+ try {
953
+ const data = JSON.parse(stdout);
954
+ const rates = Object.entries(data.rates)
955
+ .map(([k, v]) => ` ${k}: ${v}`)
956
+ .join("\n");
957
+ return { result: `1 ${from} =\n${rates}\n\nDate: ${data.date}` };
958
+ } catch {
959
+ return { error: "Could not fetch exchange rates." };
960
+ }
961
+ }
962
+
963
+ case "get_stock": {
964
+ const sym = encodeURIComponent(args.symbol.toUpperCase());
965
+ const { stdout } = await execAsync(
966
+ `curl -sL --max-time 12 "https://query1.finance.yahoo.com/v8/finance/chart/${sym}?range=1d&interval=1d"`
967
+ );
968
+ try {
969
+ const data = JSON.parse(stdout);
970
+ const meta = data.chart.result[0].meta;
971
+ return {
972
+ result: JSON.stringify({
973
+ symbol: meta.symbol,
974
+ name: meta.longName ?? meta.shortName ?? meta.symbol,
975
+ price: meta.regularMarketPrice,
976
+ prev_close: meta.previousClose,
977
+ change: (meta.regularMarketPrice - meta.previousClose).toFixed(2),
978
+ change_pct: (((meta.regularMarketPrice - meta.previousClose) / meta.previousClose) * 100).toFixed(2) + "%",
979
+ currency: meta.currency,
980
+ exchange: meta.exchangeName,
981
+ market_state: meta.marketState,
982
+ volume: meta.regularMarketVolume,
983
+ }, null, 2)
984
+ };
985
+ } catch {
986
+ return { error: `Could not fetch stock data for ${args.symbol}.` };
987
+ }
988
+ }
989
+
990
+ case "get_crypto": {
991
+ const ids = encodeURIComponent(args.coins.toLowerCase().replace(/\s/g, ""));
992
+ const cur = (args.currency ?? "usd").toLowerCase();
993
+ const url = `https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=${cur}&include_24hr_change=true&include_market_cap=true&include_24hr_vol=true`;
994
+ const { stdout } = await execAsync(`curl -sL --max-time 12 "${url}"`);
995
+ try {
996
+ const data = JSON.parse(stdout);
997
+ const lines = Object.entries(data).map(([coin, info]) => {
998
+ const price = info[cur];
999
+ const change = info[`${cur}_24h_change`]?.toFixed(2);
1000
+ const mcap = info[`${cur}_market_cap`];
1001
+ return `${coin.toUpperCase()}: ${cur.toUpperCase()} ${price?.toLocaleString()} (24h: ${change}%) mcap: ${mcap?.toLocaleString()}`;
1002
+ });
1003
+ return { result: lines.join("\n") };
1004
+ } catch {
1005
+ return { error: "Could not fetch crypto prices." };
1006
+ }
1007
+ }
1008
+
1009
+ case "get_ip_info": {
1010
+ const target = args.ip ? `https://ipapi.co/${args.ip}/json/` : "https://ipapi.co/json/";
1011
+ const { stdout } = await execAsync(`curl -sL --max-time 10 "${target}"`);
1012
+ try {
1013
+ const d = JSON.parse(stdout);
1014
+ return {
1015
+ result: JSON.stringify({
1016
+ ip: d.ip,
1017
+ city: d.city,
1018
+ region: d.region,
1019
+ country: d.country_name,
1020
+ timezone: d.timezone,
1021
+ isp: d.org,
1022
+ lat: d.latitude,
1023
+ lon: d.longitude,
1024
+ }, null, 2)
1025
+ };
1026
+ } catch {
1027
+ return { error: "Could not fetch IP info." };
1028
+ }
1029
+ }
1030
+
336
1031
  default:
337
1032
  return { error: `Unknown tool: ${name}` };
338
1033
  }