pelulu-cli 1.1.0 → 1.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.
@@ -7,46 +7,79 @@ import { exec } from 'child_process';
7
7
  import { createWriteStream } from 'fs';
8
8
  import { pipeline } from 'stream/promises';
9
9
  import { log } from '../core/logger.js';
10
-
11
- function httpGet(url, maxChars = 5000) {
12
- return new Promise((resolve, reject) => {
13
- const client = url.startsWith('https') ? https : http;
14
- client.get(url, { timeout: 15000, headers: { 'User-Agent': 'coding-agent/1.0' } }, (res) => {
15
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
16
- return httpGet(res.headers.location, maxChars).then(resolve, reject);
17
- }
18
- let d = '';
19
- res.on('data', c => { if (d.length < maxChars) d += c; });
20
- res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: d }));
21
- }).on('error', reject);
22
- });
23
- }
10
+ import { request as httpClientRequest } from '../core/http-client.js';
11
+ import { bus } from '../core/event-bus.js';
24
12
 
25
13
  const ACTIONS = {
26
14
  async fetch({ url, method, body, headers }) {
27
15
  if (!url) throw new Error('url required');
28
- log('network', `[WEB] ${method || 'GET'} ${url}`);
29
- const result = await httpGet(url);
30
- return { url, status: result.status, body: result.body.slice(0, 5000) };
16
+ const m = method || 'GET';
17
+ log('network', `[WEB] ${m} ${url}`);
18
+ const r = await httpClientRequest(url, { method: m, body, headers: headers || {}, timeout: 15000, followRedirects: true, maxBody: 200000 });
19
+ return {
20
+ url: r.finalUrl || url, status: r.status, headers: r.headers,
21
+ redirects: r.redirectChain, duration_ms: r.duration_ms,
22
+ body: r.body.slice(0, 8000), body_length: r.bytes,
23
+ };
31
24
  },
32
25
 
33
26
  async download({ url, path }) {
34
27
  if (!url || !path) throw new Error('url and path required');
35
- log('network', `📥 Downloading: ${url}`);
28
+ log('network', `[DL] Downloading: ${url}`);
29
+ const started = Date.now();
36
30
  const client = url.startsWith('https') ? https : http;
31
+
32
+ bus.emit('task:progress', {
33
+ tool: 'network', running: true, phase: 'download',
34
+ target: url, elapsed: 0, log: 'starting download',
35
+ });
36
+
37
37
  return new Promise((resolve, reject) => {
38
- client.get(url, { timeout: 30000, headers: { 'User-Agent': 'coding-agent/1.0' } }, async (res) => {
38
+ client.get(url, { timeout: 60000, headers: { 'User-Agent': 'pelulu-cli/1.0' } }, async (res) => {
39
39
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
40
40
  try { resolve(await ACTIONS.download({ url: res.headers.location, path })); } catch (e) { reject(e); }
41
41
  return;
42
42
  }
43
- if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
43
+ if (res.statusCode !== 200) {
44
+ bus.emit('task:progress', { tool: 'network', running: false, phase: 'error', target: url, log: `HTTP ${res.statusCode}` });
45
+ return reject(new Error(`HTTP ${res.statusCode}`));
46
+ }
47
+ const totalBytes = parseInt(res.headers['content-length'] || '0', 10);
48
+ let downloaded = 0;
49
+ let lastReport = 0;
50
+
51
+ // Track download progress
52
+ res.on('data', (chunk) => {
53
+ downloaded += chunk.length;
54
+ const now = Date.now();
55
+ if (now - lastReport > 2000) { // report every 2s
56
+ lastReport = now;
57
+ const elapsed = Math.round((now - started) / 1000);
58
+ const pct = totalBytes ? `${Math.round(downloaded / totalBytes * 100)}%` : `${Math.round(downloaded / 1024)}KB`;
59
+ bus.emit('task:progress', {
60
+ tool: 'network', running: true, phase: 'download',
61
+ target: url, elapsed, log: `${pct} downloaded`,
62
+ });
63
+ }
64
+ });
65
+
44
66
  const ws = createWriteStream(path);
45
67
  try {
46
68
  await pipeline(res, ws);
47
- resolve({ downloaded: true, path, url });
48
- } catch (e) { reject(e); }
49
- }).on('error', reject);
69
+ const elapsed = Math.round((Date.now() - started) / 1000);
70
+ bus.emit('task:progress', {
71
+ tool: 'network', running: false, phase: 'done',
72
+ target: url, elapsed, log: `${Math.round(downloaded / 1024)}KB saved`,
73
+ });
74
+ resolve({ downloaded: true, path, url, bytes: downloaded, elapsed_s: elapsed });
75
+ } catch (e) {
76
+ bus.emit('task:progress', { tool: 'network', running: false, phase: 'error', target: url, log: e.message });
77
+ reject(e);
78
+ }
79
+ }).on('error', (e) => {
80
+ bus.emit('task:progress', { tool: 'network', running: false, phase: 'error', target: url, log: e.message });
81
+ reject(e);
82
+ });
50
83
  });
51
84
  },
52
85
 
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Project Tool — project lifecycle operations (1 MCP tool, 6 actions)
3
3
  * Actions: init, build, test, lint, deps, info
4
+ *
5
+ * Long-running operations (build, test, deps) stream progress via task:progress
6
+ * so the TUI shows live feedback instead of appearing stuck.
4
7
  */
5
8
  import { exec } from 'child_process';
6
9
  import { readFile } from 'fs/promises';
@@ -8,6 +11,7 @@ import { existsSync } from 'fs';
8
11
  import { join } from 'path';
9
12
  import { log } from '../core/logger.js';
10
13
  import { getConfig } from '../core/config.js';
14
+ import { bus } from '../core/event-bus.js';
11
15
 
12
16
  function run(cmd, cwd, timeout = 120000) {
13
17
  return new Promise((resolve, reject) => {
@@ -18,6 +22,35 @@ function run(cmd, cwd, timeout = 120000) {
18
22
  });
19
23
  }
20
24
 
25
+ /**
26
+ * Run a command with progress reporting for long operations.
27
+ */
28
+ async function runWithProgress(cmd, dir, { label, timeout = 120000 } = {}) {
29
+ const started = Date.now();
30
+ bus.emit('task:progress', {
31
+ tool: 'project', running: true, phase: 'exec',
32
+ target: label || cmd.split(' ')[0], elapsed: 0, log: `running: ${cmd}`,
33
+ });
34
+
35
+ try {
36
+ const result = await run(cmd, dir, timeout);
37
+ const elapsed = Math.round((Date.now() - started) / 1000);
38
+ bus.emit('task:progress', {
39
+ tool: 'project', running: false, phase: 'done',
40
+ target: label || cmd.split(' ')[0], elapsed,
41
+ log: result.code === 0 ? 'completed' : `exit ${result.code}`,
42
+ });
43
+ return result;
44
+ } catch (err) {
45
+ bus.emit('task:progress', {
46
+ tool: 'project', running: false, phase: 'error',
47
+ target: label || cmd.split(' ')[0],
48
+ elapsed: Math.round((Date.now() - started) / 1000), log: err.message,
49
+ });
50
+ throw err;
51
+ }
52
+ }
53
+
21
54
  function cwd(p) { return p || getConfig().agent?.workspace || process.cwd(); }
22
55
 
23
56
  async function detectProject(dir) {
@@ -58,7 +91,7 @@ const ACTIONS = {
58
91
  const cmd = BUILD_CMD[type];
59
92
  if (!cmd) throw new Error(`No build for ${type}`);
60
93
  log('project', `[BUILD] Building (${type})...`);
61
- const r = await run(cmd, dir);
94
+ const r = await runWithProgress(cmd, dir, { label: `build (${type})`, timeout: 180000 });
62
95
  return { success: r.code === 0, type, exitCode: r.code, output: r.stdout.slice(-500) || r.stderr.slice(-500) };
63
96
  },
64
97
  },
@@ -71,7 +104,7 @@ const ACTIONS = {
71
104
  const cmd = TEST_CMD[type];
72
105
  if (!cmd) throw new Error(`No test for ${type}`);
73
106
  log('project', `[TEST] Testing (${type})...`);
74
- const r = await run(cmd, dir);
107
+ const r = await runWithProgress(cmd, dir, { label: `test (${type})`, timeout: 180000 });
75
108
  return { passed: r.code === 0, type, exitCode: r.code, output: r.stdout.slice(-1000) || r.stderr.slice(-1000) };
76
109
  },
77
110
  },
@@ -83,7 +116,7 @@ const ACTIONS = {
83
116
  const type = await detectProject(dir);
84
117
  const cmd = LINT_CMD[type];
85
118
  if (!cmd) throw new Error(`No lint for ${type}`);
86
- const r = await run(cmd, dir);
119
+ const r = await runWithProgress(cmd, dir, { label: `lint (${type})` });
87
120
  return { type, issues: r.stdout.slice(-2000) };
88
121
  },
89
122
  },
@@ -96,7 +129,8 @@ const ACTIONS = {
96
129
  if (params.install) {
97
130
  const cmd = INSTALL_CMD[type];
98
131
  if (!cmd) throw new Error(`No install for ${type}`);
99
- await run(cmd, dir, 180000);
132
+ log('project', `[DEPS] Installing (${type})...`);
133
+ await runWithProgress(cmd, dir, { label: `deps install (${type})`, timeout: 180000 });
100
134
  return { installed: true, type };
101
135
  }
102
136
  try {
@@ -3,9 +3,9 @@
3
3
  * Actions: grep, find, web
4
4
  */
5
5
  import { exec } from 'child_process';
6
- import https from 'https';
7
- import http from 'http';
8
6
  import { log } from '../core/logger.js';
7
+ import { request as httpClientRequest } from '../core/http-client.js';
8
+ import { bus } from '../core/event-bus.js';
9
9
 
10
10
  function runCmd(cmd, timeout = 10000) {
11
11
  return new Promise((resolve) => {
@@ -15,18 +15,10 @@ function runCmd(cmd, timeout = 10000) {
15
15
  });
16
16
  }
17
17
 
18
- function httpGet(url, maxChars = 8000) {
19
- return new Promise((resolve, reject) => {
20
- const client = url.startsWith('https') ? https : http;
21
- client.get(url, { timeout: 15000, headers: { 'User-Agent': 'coding-agent/1.0' } }, (res) => {
22
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
23
- return httpGet(res.headers.location, maxChars).then(resolve, reject);
24
- }
25
- let d = '';
26
- res.on('data', c => { if (d.length < maxChars) d += c; });
27
- res.on('end', () => resolve({ status: res.statusCode, body: d }));
28
- }).on('error', reject);
29
- });
18
+ // Delegates to the shared HTTP engine (gzip-aware, follows redirects).
19
+ async function httpGet(url, maxChars = 8000) {
20
+ const r = await httpClientRequest(url, { timeout: 15000, followRedirects: true, maxBody: Math.max(maxChars, 8000) });
21
+ return { status: r.status, body: r.body.slice(0, maxChars) };
30
22
  }
31
23
 
32
24
  const ACTIONS = {
@@ -40,7 +32,9 @@ const ACTIONS = {
40
32
  const inc = include ? `--include="*.${include}"` : '';
41
33
  const ctx = context ? `-C ${context}` : '';
42
34
  const cmd = `grep ${flags}${ic} ${inc} ${ctx} "${pattern}" "${path || '.'}" 2>/dev/null | head -100`;
35
+ bus.emit('task:progress', { tool: 'search', running: true, phase: 'grep', target: path || '.', log: `searching: ${pattern}` });
43
36
  const r = await runCmd(cmd);
37
+ bus.emit('task:progress', { tool: 'search', running: false, phase: 'done', target: path || '.' });
44
38
  const lines = r.stdout.split('\n').filter(Boolean);
45
39
  return { pattern, matches: lines.length, results: lines };
46
40
  },
@@ -52,7 +46,9 @@ const ACTIONS = {
52
46
  log('search', `[FIND] find: ${name}`);
53
47
  const t = type ? `-type ${type}` : '';
54
48
  const cmd = `find "${path || '.'}" ${t} -name "${name}" 2>/dev/null | head -50`;
49
+ bus.emit('task:progress', { tool: 'search', running: true, phase: 'find', target: path || '.', log: `finding: ${name}` });
55
50
  const r = await runCmd(cmd);
51
+ bus.emit('task:progress', { tool: 'search', running: false, phase: 'done', target: path || '.' });
56
52
  return { pattern: name, matches: r.stdout.split('\n').filter(Boolean).length, files: r.stdout.split('\n').filter(Boolean) };
57
53
  },
58
54
  },
@@ -1,15 +1,76 @@
1
1
  /**
2
2
  * Shell Tool — consolidated shell operations (1 MCP tool, 4 actions)
3
3
  * Actions: exec, bg, ps, kill
4
+ *
5
+ * Long-running commands are streamable: stdout/stderr are captured in chunks
6
+ * and reported via task:progress so the TUI can show live output. The job
7
+ * layer (core/job-manager.js) auto-backgrounds anything > 8s.
4
8
  */
5
9
  import { exec, spawn } from 'child_process';
6
10
  import { log } from '../core/logger.js';
7
11
  import { getConfig } from '../core/config.js';
12
+ import { bus } from '../core/event-bus.js';
13
+ import { jobManager } from '../core/job-manager.js';
8
14
 
9
15
  const BLOCKED = [/rm\s+-rf\s+\//, /mkfs/, /dd\s+if=.*of=\/dev/, /chmod\s+777\s+\//];
10
16
 
11
17
  function isBlocked(cmd) { return BLOCKED.some(re => re.test(cmd)); }
12
18
 
19
+ /**
20
+ * Run a command with streaming progress. Emits task:progress on each chunk
21
+ * so the TUI shows live output instead of waiting for the full result.
22
+ */
23
+ function runStreaming(cmd, timeout, { label } = {}) {
24
+ return new Promise((resolve, reject) => {
25
+ if (isBlocked(cmd)) return reject(new Error('Blocked: dangerous command'));
26
+ const cfg = getConfig();
27
+ const t = timeout || cfg.tools?.shell_timeout || 30000;
28
+ const started = Date.now();
29
+ let stdout = '';
30
+ let stderr = '';
31
+ let chunkCount = 0;
32
+
33
+ const child = spawn('bash', ['-c', cmd], {
34
+ timeout: t,
35
+ maxBuffer: 1024 * 1024,
36
+ env: { ...process.env, FORCE_COLOR: '0' },
37
+ });
38
+
39
+ child.stdout?.on('data', (d) => {
40
+ stdout += d;
41
+ chunkCount++;
42
+ if (chunkCount % 5 === 0) {
43
+ bus.emit('task:progress', {
44
+ tool: 'shell', running: true,
45
+ phase: 'exec', target: label || cmd.slice(0, 60),
46
+ elapsed: Math.round((Date.now() - started) / 1000),
47
+ log: `${stdout.length + stderr.length} bytes captured`,
48
+ });
49
+ }
50
+ });
51
+
52
+ child.stderr?.on('data', (d) => {
53
+ stderr += d;
54
+ });
55
+
56
+ child.on('error', reject);
57
+ child.on('close', (code) => {
58
+ if (code !== 0 && !stdout && stderr) {
59
+ reject(new Error(stderr.trim().slice(0, 500)));
60
+ } else {
61
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code: code ?? 0 });
62
+ }
63
+ });
64
+
65
+ // Timeout guard
66
+ const timer = setTimeout(() => {
67
+ child.kill('SIGTERM');
68
+ reject(new Error(`Timed out after ${t}ms`));
69
+ }, t);
70
+ child.on('close', () => clearTimeout(timer));
71
+ });
72
+ }
73
+
13
74
  function run(cmd, timeout) {
14
75
  return new Promise((resolve, reject) => {
15
76
  if (isBlocked(cmd)) return reject(new Error('Blocked: dangerous command'));
@@ -26,9 +87,14 @@ const ACTIONS = {
26
87
  exec: {
27
88
  required: ['command'],
28
89
  handler: async ({ command, timeout }) => {
29
- log('shell', `[CFG] $ ${command}`);
30
- const result = await run(command, timeout);
31
- const maxLen = getConfig().tools?.max_output || 10000;
90
+ log('shell', `[EXEC] $ ${command}`);
91
+ // Use streaming for commands that might take a while
92
+ const cfg = getConfig();
93
+ const t = timeout || cfg.tools?.shell_timeout || 30000;
94
+ const result = t > 10000
95
+ ? await runStreaming(command, timeout, { label: command.slice(0, 60) })
96
+ : await run(command, timeout);
97
+ const maxLen = cfg.tools?.max_output || 10000;
32
98
  return { stdout: result.stdout.slice(0, maxLen), stderr: result.stderr.slice(0, 2000), exitCode: result.code };
33
99
  },
34
100
  },
@@ -36,7 +102,7 @@ const ACTIONS = {
36
102
  bg: {
37
103
  required: ['command'],
38
104
  handler: async ({ command }) => {
39
- log('shell', `[CFG] [bg] $ ${command}`);
105
+ log('shell', `[BG] $ ${command}`);
40
106
  const child = spawn('bash', ['-c', command], { detached: true, stdio: 'ignore' });
41
107
  child.unref();
42
108
  return { pid: child.pid, background: true, command };