shoud-cli 1.0.0 → 1.0.2

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/shoud.js CHANGED
@@ -1,22 +1,23 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
+
2
3
  const { program } = require('commander');
3
4
  const chalk = require('chalk');
4
5
  const { login } = require('../src/auth/deviceFlow');
5
6
  const { executeTask } = require('../src/runtime/agentLoop');
6
7
 
7
- // ─── Banner Art ───────────────────────────────────────────────
8
+ // ─── Exact SHOUD Banner Art (provided by user) ──────────────────────────────
8
9
  const BANNER_ART = [
9
- " ███████╗██╗ ██╗ ██████╗ ██████╗ ███████╗██╗ ██╗██╗ ██╗██████╗",
10
- " ██╔════╝██║ ██║██╔═══██╗██╔══██╗██╔════╝██║ ██║██║ ██║██╔══██╗",
11
- " ███████╗███████║██║ ██║██║ ██║███████╗██║ ██║██║ ██║██████╔╝",
12
- " ╚════██║██╔══██║██║ ██║██║ ██║╚════██║██║ ██║██║ ██║██╔══██╗",
13
- " ███████║██║ ██║╚██████╔╝██████╔╝███████║╚██████╔╝╚██████╔╝██║ ██║",
14
- " ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝"
10
+ " ███████╗██╗ ██╗ ██████╗ ██╗ ██╗██████╗ ",
11
+ " ██╔════╝██║ ██║██╔═══██╗██║ ██║██╔══██╗",
12
+ " ███████╗███████║██║ ██║██║ ██║██║ ██║",
13
+ " ╚════██║██╔══██║██║ ██║██║ ██║██║ ██║",
14
+ " ███████║██║ ██║╚██████╔╝╚██████╔╝██████╔╝",
15
+ " ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ "
15
16
  ];
16
17
 
17
18
  const SLEEP = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
18
19
 
19
- // ─── Animated Banner ──────────────────────────────────────────
20
+ // ─── Animated Banner ─────────────────────────────────────────────────────────
20
21
  async function renderAnimatedBanner() {
21
22
  if (!process.stdout.isTTY) {
22
23
  console.log(chalk.hex('#B5F96C')(BANNER_ART.join('\n')));
@@ -44,13 +45,13 @@ async function renderAnimatedBanner() {
44
45
 
45
46
  console.log(
46
47
  chalk.hex('#3B472E')(
47
- ' ┌──────────────────────────────────────────────────────┐\n' +
48
+ ' ┌──────────────────────────────────────────────────────────┐\n' +
48
49
  ' │'
49
50
  ) +
50
51
  chalk.hex('#B5F96C').bold(' Give your computer a job. ') +
51
52
  chalk.hex('#3B472E')(
52
53
  '│\n' +
53
- ' └──────────────────────────────────────────────────────┘\n'
54
+ ' └──────────────────────────────────────────────────────────┘\n'
54
55
  )
55
56
  );
56
57
 
@@ -60,15 +61,16 @@ async function renderAnimatedBanner() {
60
61
  process.stdout.write('\x1B[?25h');
61
62
  }
62
63
 
63
- // ─── Custom Help ──────────────────────────────────────────────
64
+ // ─── Custom Help ──────────────────────────────────────────────────────────────
64
65
  async function customHelp() {
65
66
  console.log(chalk.hex('#B5F96C').bold('\n SHOUD Terminal Agent - Command Reference\n'));
66
67
  console.log(chalk.gray(' Usage: shoud [command] OR shoud "[prompt]"\n'));
67
68
 
68
69
  console.log(chalk.white.bold(' Commands:'));
69
70
  console.log(` ${chalk.hex('#B5F96C')('shoud login')} Authenticate this device with your Google account.`);
70
- console.log(` ${chalk.hex('#B5F96C')('shoud help')} Display this beautiful help menu.`);
71
- console.log(` ${chalk.hex('#B5F96C')('shoud status')} Check your current credit balance and active plan.\n`);
71
+ console.log(` ${chalk.hex('#B5F96C')('shoud help')} Display this help menu.`);
72
+ console.log(` ${chalk.hex('#B5F96C')('shoud status')} Check your current credit balance and active plan.`);
73
+ console.log(` ${chalk.hex('#B5F96C')('shoud --version')} Show the version number.\n`);
72
74
 
73
75
  console.log(chalk.white.bold(' Autonomous Execution:'));
74
76
  console.log(chalk.gray(' Wrap your instructions in quotes to trigger the agent loop.'));
@@ -76,12 +78,20 @@ async function customHelp() {
76
78
  console.log(` ${chalk.hex('#B5F96C')('>')} shoud "Run npm test and fix any failing test cases"\n`);
77
79
  }
78
80
 
79
- // ─── Main ─────────────────────────────────────────────────────
81
+ // ─── Main ──────────────────────────────────────────────────────────────────────
80
82
  async function main() {
81
83
  const args = process.argv.slice(2);
82
84
 
83
- // Intercept help commands and empty input
84
- if (args.length === 0 || args[0] === 'help' || args[0] === '--help') {
85
+ // Intercept help, version, and empty input
86
+ if (args.length === 0 ||
87
+ args[0] === 'help' || args[0] === '--help' || args[0] === '-h' ||
88
+ args[0] === '--version' || args[0] === '-v') {
89
+ if (args[0] === '--version' || args[0] === '-v') {
90
+ // Let Commander handle version
91
+ program.version('1.0.0');
92
+ program.parse(process.argv);
93
+ return;
94
+ }
85
95
  await renderAnimatedBanner();
86
96
  await customHelp();
87
97
  return;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "shoud-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "SHOUD Terminal Agent: Give your computer a job.",
5
5
  "main": "bin/shoud.js",
6
6
  "bin": {
7
- "shoud": "./bin/shoud.js"
7
+ "shoud": "bin/shoud.js"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node ./bin/shoud.js"
@@ -19,5 +19,17 @@
19
19
  },
20
20
  "engines": {
21
21
  "node": ">=18.0.0"
22
+ },
23
+ "files": [
24
+ "bin/",
25
+ "src/",
26
+ "install.sh",
27
+ "!**/*.test.js",
28
+ "!**/*.spec.js"
29
+ ],
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/your-username/shoud-monorepo.git"
22
34
  }
23
35
  }
@@ -5,36 +5,139 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
7
 
8
- const SESSION_FILE = path.join(os.homedir(), '.shoud_session');
8
+ // Use a dedicated config directory
9
+ const CONFIG_DIR = path.join(os.homedir(), '.shoud');
10
+ const SESSION_FILE = path.join(CONFIG_DIR, 'session.json');
9
11
 
12
+ // Ensure config directory exists with secure permissions
13
+ if (!fs.existsSync(CONFIG_DIR)) {
14
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
15
+ }
16
+
17
+ /**
18
+ * Decode a JWT token (without verification) to extract payload
19
+ */
20
+ function decodeJWT(token) {
21
+ try {
22
+ const parts = token.split('.');
23
+ if (parts.length !== 3) return null;
24
+ const payload = Buffer.from(parts[1], 'base64').toString('utf-8');
25
+ return JSON.parse(payload);
26
+ } catch (_) {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Write token to session file with expiry
33
+ */
34
+ function saveToken(token) {
35
+ const payload = decodeJWT(token);
36
+ const expiresAt = payload?.exp ? payload.exp * 1000 : null; // convert to milliseconds
37
+ const data = {
38
+ token,
39
+ expiresAt,
40
+ createdAt: Date.now()
41
+ };
42
+ try {
43
+ fs.writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2), {
44
+ encoding: 'utf-8',
45
+ mode: 0o600
46
+ });
47
+ } catch (err) {
48
+ console.error(chalk.red(`Failed to save session: ${err.message}`));
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Retrieve token if present and not expired
55
+ */
56
+ function getToken() {
57
+ if (!fs.existsSync(SESSION_FILE)) {
58
+ return null;
59
+ }
60
+ try {
61
+ const raw = fs.readFileSync(SESSION_FILE, 'utf-8');
62
+ const data = JSON.parse(raw);
63
+ const { token, expiresAt } = data;
64
+
65
+ if (!token) return null;
66
+
67
+ // If we have an expiry and it's in the past, token is invalid
68
+ if (expiresAt && Date.now() >= expiresAt) {
69
+ // Optionally delete the stale file
70
+ fs.unlinkSync(SESSION_FILE);
71
+ return null;
72
+ }
73
+
74
+ // If we don't have expiry, we can still try to decode now to check
75
+ if (!expiresAt) {
76
+ const payload = decodeJWT(token);
77
+ if (payload?.exp) {
78
+ const expMs = payload.exp * 1000;
79
+ if (Date.now() >= expMs) {
80
+ fs.unlinkSync(SESSION_FILE);
81
+ return null;
82
+ }
83
+ }
84
+ }
85
+
86
+ return token;
87
+ } catch (_) {
88
+ // Corrupted or invalid file, delete it
89
+ try { fs.unlinkSync(SESSION_FILE); } catch (_) {}
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Login flow: open browser, ask for token, save it
96
+ */
10
97
  async function login() {
11
98
  console.log(chalk.cyan('Opening browser to sign in via shoud.online...'));
12
99
  await open('https://shoud.online/login');
13
-
100
+
14
101
  console.log(chalk.gray('\nOnce signed in, copy your session token from the browser.'));
15
-
102
+
16
103
  const { token } = await inquirer.prompt([
17
104
  {
18
105
  type: 'password',
19
106
  name: 'token',
20
107
  message: 'Paste your session token:',
21
- mask: '*'
108
+ mask: '*',
109
+ validate: (input) => {
110
+ if (!input || input.trim().length === 0) {
111
+ return 'Token cannot be empty.';
112
+ }
113
+ // Simple validation: check if it looks like a JWT (three parts)
114
+ const parts = input.trim().split('.');
115
+ if (parts.length !== 3) {
116
+ return 'Invalid token format. Please paste the full token from the browser.';
117
+ }
118
+ return true;
119
+ }
22
120
  }
23
121
  ]);
24
122
 
25
123
  if (token) {
26
- fs.writeFileSync(SESSION_FILE, token, { encoding: 'utf-8', mode: 0o600 });
124
+ saveToken(token.trim());
27
125
  console.log(chalk.green('\n✓ Signed in successfully. Device connected.'));
28
126
  } else {
29
- console.log(chalk.red('\nLogin failed: No token provided.'));
127
+ console.log(chalk.red('\nLogin cancelled.'));
30
128
  }
31
129
  }
32
130
 
33
- function getToken() {
34
- if (!fs.existsSync(SESSION_FILE)) {
35
- return null;
36
- }
37
- return fs.readFileSync(SESSION_FILE, 'utf-8').trim();
131
+ /**
132
+ * Validate token by checking with the backend (optional, can be used before API calls)
133
+ * For now, we just rely on getToken() returning null if expired.
134
+ */
135
+ async function validateToken() {
136
+ const token = getToken();
137
+ if (!token) return false;
138
+ // Could make a lightweight request to backend to verify (e.g., /auth/verify)
139
+ // but we assume token is valid if not expired.
140
+ return true;
38
141
  }
39
142
 
40
- module.exports = { login, getToken };
143
+ module.exports = { login, getToken, validateToken, saveToken };
@@ -1,22 +1,81 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const os = require('os');
3
4
 
4
- const CHECKPOINT_FILE = path.join(process.cwd(), '.shoud_checkpoint.json');
5
+ const CONFIG_DIR = path.join(os.homedir(), '.shoud');
6
+ const CHECKPOINT_FILE = path.join(CONFIG_DIR, 'checkpoint.json');
7
+ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
5
8
 
9
+ // Ensure config directory exists with secure permissions
10
+ if (!fs.existsSync(CONFIG_DIR)) {
11
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
12
+ }
13
+
14
+ /**
15
+ * Save the execution state (messages, etc.) to a checkpoint file.
16
+ * Includes a timestamp for TTL.
17
+ */
6
18
  function saveCheckpoint(state) {
7
- fs.writeFileSync(CHECKPOINT_FILE, JSON.stringify(state, null, 2));
19
+ try {
20
+ const data = {
21
+ timestamp: Date.now(),
22
+ ...state
23
+ };
24
+ fs.writeFileSync(CHECKPOINT_FILE, JSON.stringify(data, null, 2), {
25
+ encoding: 'utf-8',
26
+ mode: 0o600
27
+ });
28
+ } catch (err) {
29
+ console.error(`Failed to save checkpoint: ${err.message}`);
30
+ // We don't throw; the agent can continue without checkpointing
31
+ }
8
32
  }
9
33
 
34
+ /**
35
+ * Load a checkpoint if it exists and is not too old.
36
+ * Returns the state object (e.g., { messages }) or null if none or expired.
37
+ */
10
38
  function loadCheckpoint() {
11
- if (fs.existsSync(CHECKPOINT_FILE)) {
12
- return JSON.parse(fs.readFileSync(CHECKPOINT_FILE, 'utf-8'));
39
+ if (!fs.existsSync(CHECKPOINT_FILE)) {
40
+ return null;
41
+ }
42
+ try {
43
+ const raw = fs.readFileSync(CHECKPOINT_FILE, 'utf-8');
44
+ const data = JSON.parse(raw);
45
+ const { timestamp, ...state } = data;
46
+
47
+ // Check TTL
48
+ if (timestamp && (Date.now() - timestamp) > MAX_AGE_MS) {
49
+ // Stale checkpoint – delete it and ignore
50
+ clearCheckpoint();
51
+ return null;
52
+ }
53
+
54
+ // Validate that the state contains at least a messages array
55
+ if (!state.messages || !Array.isArray(state.messages)) {
56
+ // Corrupted or invalid state; delete it
57
+ clearCheckpoint();
58
+ return null;
59
+ }
60
+
61
+ return state;
62
+ } catch (err) {
63
+ // If file is corrupt, delete it
64
+ clearCheckpoint();
65
+ return null;
13
66
  }
14
- return null;
15
67
  }
16
68
 
69
+ /**
70
+ * Clear (delete) the checkpoint file.
71
+ */
17
72
  function clearCheckpoint() {
18
73
  if (fs.existsSync(CHECKPOINT_FILE)) {
19
- fs.unlinkSync(CHECKPOINT_FILE);
74
+ try {
75
+ fs.unlinkSync(CHECKPOINT_FILE);
76
+ } catch (_) {
77
+ // Ignore errors during cleanup
78
+ }
20
79
  }
21
80
  }
22
81
 
@@ -1,20 +1,85 @@
1
1
  const inquirer = require('inquirer');
2
2
  const chalk = require('chalk');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
3
6
 
4
- // In a full implementation, this could read from a .shoudignore or local config
5
- const alwaysAllowedCommands = new Set(['ls', 'pwd', 'git status']);
7
+ const CONFIG_DIR = path.join(os.homedir(), '.shoud');
8
+ const ALLOWLIST_FILE = path.join(CONFIG_DIR, 'allowlist.json');
6
9
 
7
- async function verifyPermission(toolName, input) {
8
- if (toolName === 'read_file') return true; // Safe operation
9
-
10
- const commandContext = input.command || input.path || 'unknown';
10
+ // Ensure config directory exists
11
+ if (!fs.existsSync(CONFIG_DIR)) {
12
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
13
+ }
11
14
 
12
- if (toolName === 'execute_shell' && alwaysAllowedCommands.has(commandContext)) {
13
- return true;
15
+ // Load persistent allowlist (or create empty)
16
+ function loadAllowlist() {
17
+ try {
18
+ if (fs.existsSync(ALLOWLIST_FILE)) {
19
+ return JSON.parse(fs.readFileSync(ALLOWLIST_FILE, 'utf-8'));
20
+ }
21
+ } catch (_) {}
22
+ return { commands: [], paths: [] };
23
+ }
24
+
25
+ // Save allowlist
26
+ function saveAllowlist(data) {
27
+ try {
28
+ fs.writeFileSync(ALLOWLIST_FILE, JSON.stringify(data, null, 2), {
29
+ encoding: 'utf-8',
30
+ mode: 0o600
31
+ });
32
+ } catch (_) {}
33
+ }
34
+
35
+ // In‑memory session allowlist (cleared on CLI restart)
36
+ let sessionAllowList = {
37
+ commands: new Set(),
38
+ paths: new Set()
39
+ };
40
+
41
+ /**
42
+ * Check if a command is allowed by persistent or session allowlists.
43
+ */
44
+ function isAllowed(toolName, input) {
45
+ if (toolName === 'read_file') return true; // always safe
46
+
47
+ if (toolName === 'execute_shell') {
48
+ const cmd = input.command?.trim().split(/\s+/)[0] || '';
49
+ // Persistent allowlist
50
+ const persistent = loadAllowlist();
51
+ if (persistent.commands.includes(cmd)) return true;
52
+ // Session allowlist
53
+ if (sessionAllowList.commands.has(cmd)) return true;
14
54
  }
15
55
 
56
+ if (toolName === 'write_file' || toolName === 'read_file') {
57
+ const filePath = input.path || '';
58
+ // Check persistent and session for paths (exact match or prefix)
59
+ // For simplicity, we'll just match exact paths; could be extended
60
+ const persistent = loadAllowlist();
61
+ if (persistent.paths.includes(filePath)) return true;
62
+ if (sessionAllowList.paths.has(filePath)) return true;
63
+ }
64
+
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Prompt user for permission, with options for session‑wide or persistent allow.
70
+ */
71
+ async function verifyPermission(toolName, input) {
72
+ // If already allowed, skip prompt
73
+ if (isAllowed(toolName, input)) return true;
74
+
75
+ // Build display info
76
+ let target = '';
77
+ if (toolName === 'execute_shell') target = input.command || 'unknown command';
78
+ else if (toolName === 'read_file' || toolName === 'write_file') target = input.path || 'unknown path';
79
+ else target = JSON.stringify(input);
80
+
16
81
  console.log(chalk.yellow(`\n⚠ SHOUD requests permission to run: ${chalk.bold(toolName)}`));
17
- console.log(chalk.gray(`Target: ${commandContext}`));
82
+ console.log(chalk.gray(`Target: ${target}`));
18
83
 
19
84
  const { permission } = await inquirer.prompt([
20
85
  {
@@ -23,12 +88,46 @@ async function verifyPermission(toolName, input) {
23
88
  message: 'Allow this operation?',
24
89
  choices: [
25
90
  { name: 'Allow once', value: 'once' },
91
+ { name: 'Allow for this session', value: 'session' },
92
+ { name: 'Always allow (save to config)', value: 'always' },
26
93
  { name: 'Deny', value: 'deny' }
27
94
  ]
28
95
  }
29
96
  ]);
30
97
 
31
- return permission === 'once';
98
+ if (permission === 'deny') return false;
99
+
100
+ // Add to session allowlist if requested
101
+ if (permission === 'session') {
102
+ if (toolName === 'execute_shell') {
103
+ const cmd = input.command.trim().split(/\s+/)[0];
104
+ sessionAllowList.commands.add(cmd);
105
+ } else if (toolName === 'read_file' || toolName === 'write_file') {
106
+ sessionAllowList.paths.add(input.path);
107
+ }
108
+ return true;
109
+ }
110
+
111
+ // Add to persistent allowlist
112
+ if (permission === 'always') {
113
+ const allowlist = loadAllowlist();
114
+ if (toolName === 'execute_shell') {
115
+ const cmd = input.command.trim().split(/\s+/)[0];
116
+ if (!allowlist.commands.includes(cmd)) {
117
+ allowlist.commands.push(cmd);
118
+ saveAllowlist(allowlist);
119
+ }
120
+ } else if (toolName === 'read_file' || toolName === 'write_file') {
121
+ if (!allowlist.paths.includes(input.path)) {
122
+ allowlist.paths.push(input.path);
123
+ saveAllowlist(allowlist);
124
+ }
125
+ }
126
+ return true;
127
+ }
128
+
129
+ // Once
130
+ return true;
32
131
  }
33
132
 
34
133
  module.exports = { verifyPermission };
@@ -8,6 +8,7 @@ const { executeTool } = require('../tools/index');
8
8
  const { saveCheckpoint, loadCheckpoint, clearCheckpoint } = require('../context/checkpoint');
9
9
 
10
10
  const API_URL = process.env.SHOUD_API_URL || 'https://shoud.online/api';
11
+ const BILLING_URL = process.env.SHOUD_BILLING_URL || 'https://shoud.vantyrixtek.online';
11
12
 
12
13
  async function executeTask(prompt) {
13
14
  const token = getToken();
@@ -33,70 +34,95 @@ async function executeTask(prompt) {
33
34
  try {
34
35
  const response = await axios.post(`${API_URL}/agent/infer`, {
35
36
  messages,
36
- estimatedBudget: 0.10 // 10 cents max budget per loop iteration
37
+ systemPrompt: undefined // optional, can be passed via CLI flag if needed
37
38
  }, {
38
39
  headers: { Authorization: `Bearer ${token}` }
39
40
  });
40
41
 
41
42
  const aiMessage = response.data;
43
+
44
+ // Validate response structure
45
+ if (!aiMessage || !aiMessage.content || !Array.isArray(aiMessage.content)) {
46
+ throw new Error('Invalid response from SHOUD agent.');
47
+ }
48
+
49
+ // Add assistant message to conversation history
42
50
  messages.push({ role: 'assistant', content: aiMessage.content });
43
51
  spinner.stop();
44
52
 
53
+ // Check if the assistant used a tool
45
54
  if (aiMessage.stop_reason === 'tool_use') {
46
55
  for (const block of aiMessage.content) {
47
56
  if (block.type === 'tool_use') {
48
57
  console.log(chalk.blue(`\n● Agent requested: ${block.name}`));
49
-
58
+
50
59
  const isAllowed = await verifyPermission(block.name, block.input);
51
60
  let toolResult = '';
52
61
 
53
62
  if (isAllowed) {
54
63
  spinner.start(`Executing ${block.name}...`);
55
- toolResult = executeTool(block.name, block.input);
56
- spinner.stop();
57
- console.log(chalk.green(`✓ Tool executed.`));
64
+ try {
65
+ toolResult = executeTool(block.name, block.input);
66
+ spinner.stop();
67
+ console.log(chalk.green(`✓ Tool executed.`));
68
+ } catch (toolError) {
69
+ spinner.stop();
70
+ console.log(chalk.red(`✕ Tool execution failed: ${toolError.message}`));
71
+ toolResult = `Error: ${toolError.message}`;
72
+ }
58
73
  } else {
59
- toolResult = "User denied permission to execute this tool.";
74
+ toolResult = 'User denied permission to execute this tool.';
60
75
  console.log(chalk.red(`✕ Permission denied.`));
61
76
  }
62
77
 
78
+ // Send tool result back to the agent
63
79
  messages.push({
64
80
  role: 'user',
65
81
  content: [{ type: 'tool_result', tool_use_id: block.id, content: toolResult }]
66
82
  });
67
-
68
- // Save state after tool execution
83
+
84
+ // Save checkpoint after each tool interaction
69
85
  saveCheckpoint({ messages });
70
86
  }
71
87
  }
72
88
  spinner.start('Analyzing tool results...');
73
89
  } else {
74
- // Task completed normally
90
+ // Task completed (no more tool calls)
75
91
  taskComplete = true;
76
92
  clearCheckpoint();
77
-
78
- // Print the final text block from Claude
79
- const finalResponse = aiMessage.content.find(c => c.type === 'text')?.text || 'Done.';
93
+
94
+ // Extract the final text response
95
+ const textBlocks = aiMessage.content.filter(c => c.type === 'text');
96
+ const finalResponse = textBlocks.length > 0
97
+ ? textBlocks.map(t => t.text).join('\n')
98
+ : 'Task completed.';
99
+
80
100
  console.log(chalk.green('\n✓ Task Complete.\n'));
81
101
  console.log(chalk.white(finalResponse));
82
102
  }
83
103
 
84
104
  } catch (error) {
85
105
  spinner.stop();
106
+
107
+ // Handle insufficient funds (402) specifically
86
108
  if (error.response && error.response.status === 402) {
109
+ const balance = error.response.data.balance || 0;
87
110
  console.log(chalk.red('\n⏸ Task paused. Insufficient SHOUD Credits.'));
88
- console.log(chalk.gray(`Balance: $${error.response.data.balance.toFixed(4)}`));
111
+ console.log(chalk.gray(`Balance: $${balance.toFixed(4)}`));
112
+ // Save checkpoint so the user can resume later
89
113
  saveCheckpoint({ messages });
90
-
114
+
91
115
  console.log(chalk.cyan('Opening browser to add credits...'));
92
- await open('https://shoud.vantyrixtek.online/?amount=10');
116
+ await open(`${BILLING_URL}/?amount=10`);
93
117
  console.log(chalk.gray('Run your command again once credits are added to resume.'));
94
118
  return;
95
- } else {
96
- console.log(chalk.red(`\nError: ${error.response?.data?.error || error.message}`));
97
- saveCheckpoint({ messages });
98
- return;
99
119
  }
120
+
121
+ // Generic error
122
+ console.log(chalk.red(`\nError: ${error.response?.data?.error || error.message}`));
123
+ // Save checkpoint to preserve progress
124
+ saveCheckpoint({ messages });
125
+ return;
100
126
  }
101
127
  }
102
128
  }
@@ -1,20 +1,128 @@
1
- const { execSync } = require('child_process');
1
+ const { execFileSync } = require('child_process');
2
2
  const fs = require('fs');
3
+ const path = require('path');
3
4
 
4
- function executeTool(toolName, input) {
5
+ // Allowed shell commands (whitelist)
6
+ const ALLOWED_COMMANDS = new Set([
7
+ 'ls', 'pwd', 'cat', 'grep', 'find', 'echo',
8
+ 'mkdir', 'rmdir', 'touch', 'head', 'tail',
9
+ 'wc', 'sort', 'uniq', 'diff', 'patch',
10
+ 'git' // optional, but careful
11
+ ]);
12
+
13
+ // Maximum output size (1 MB)
14
+ const MAX_OUTPUT_SIZE = 1024 * 1024;
15
+
16
+ // Get the project root (current working directory where CLI was started)
17
+ const PROJECT_ROOT = process.cwd();
18
+
19
+ /**
20
+ * Sanitize a file path to ensure it stays within the project root.
21
+ */
22
+ function sanitizePath(inputPath) {
23
+ // Resolve relative paths
24
+ const resolved = path.resolve(PROJECT_ROOT, inputPath);
25
+ // Ensure it starts with the project root
26
+ if (!resolved.startsWith(PROJECT_ROOT)) {
27
+ throw new Error('Path is outside the project directory.');
28
+ }
29
+ return resolved;
30
+ }
31
+
32
+ /**
33
+ * Execute a shell command securely.
34
+ * Only commands from the whitelist are allowed.
35
+ */
36
+ function executeShell(commandString) {
37
+ // Split command into parts (first word is the command, rest are arguments)
38
+ const parts = commandString.trim().split(/\s+/);
39
+ if (parts.length === 0) throw new Error('Empty command.');
40
+
41
+ const cmd = parts[0];
42
+ if (!ALLOWED_COMMANDS.has(cmd)) {
43
+ throw new Error(`Command "${cmd}" is not allowed.`);
44
+ }
45
+
46
+ const args = parts.slice(1);
47
+
48
+ try {
49
+ const output = execFileSync(cmd, args, {
50
+ cwd: PROJECT_ROOT,
51
+ encoding: 'utf-8',
52
+ timeout: 30000, // 30 seconds
53
+ maxBuffer: MAX_OUTPUT_SIZE,
54
+ stdio: 'pipe'
55
+ });
56
+ return output.trim() || 'Command executed successfully with no output.';
57
+ } catch (err) {
58
+ // If command fails, return the error message (safe to show to AI)
59
+ if (err.stderr) {
60
+ return `Error: ${err.stderr.trim()}`;
61
+ }
62
+ throw new Error(`Command execution failed: ${err.message}`);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Read a file securely, ensuring it's within the project root.
68
+ */
69
+ function readFile(filePath) {
70
+ const safePath = sanitizePath(filePath);
5
71
  try {
6
- if (toolName === 'execute_shell') {
7
- const output = execSync(input.command, { encoding: 'utf-8', stdio: 'pipe' });
8
- return output.trim() || 'Command executed successfully with no output.';
9
- }
10
-
11
- if (toolName === 'read_file') {
12
- return fs.readFileSync(input.path, 'utf-8');
72
+ const stats = fs.statSync(safePath);
73
+ if (!stats.isFile()) throw new Error('Path is not a file.');
74
+ if (stats.size > MAX_OUTPUT_SIZE) {
75
+ // Read only first 1 MB
76
+ const buffer = Buffer.alloc(MAX_OUTPUT_SIZE);
77
+ const fd = fs.openSync(safePath, 'r');
78
+ fs.readSync(fd, buffer, 0, MAX_OUTPUT_SIZE, 0);
79
+ fs.closeSync(fd);
80
+ return buffer.toString('utf-8') + '\n... (file truncated)';
13
81
  }
82
+ return fs.readFileSync(safePath, 'utf-8');
83
+ } catch (err) {
84
+ throw new Error(`Failed to read file: ${err.message}`);
85
+ }
86
+ }
14
87
 
15
- throw new Error(`Tool ${toolName} is not supported locally.`);
88
+ /**
89
+ * Write content to a file securely (optional, but useful for agent).
90
+ * Ensure file is within project root.
91
+ */
92
+ function writeFile(filePath, content) {
93
+ const safePath = sanitizePath(filePath);
94
+ try {
95
+ // Ensure directory exists
96
+ const dir = path.dirname(safePath);
97
+ if (!fs.existsSync(dir)) {
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ }
100
+ fs.writeFileSync(safePath, content, 'utf-8');
101
+ return `File written successfully: ${filePath}`;
102
+ } catch (err) {
103
+ throw new Error(`Failed to write file: ${err.message}`);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Main tool dispatcher.
109
+ * Returns the result as a string (to be sent back to the AI).
110
+ */
111
+ function executeTool(toolName, input) {
112
+ try {
113
+ switch (toolName) {
114
+ case 'execute_shell':
115
+ return executeShell(input.command);
116
+ case 'read_file':
117
+ return readFile(input.path);
118
+ case 'write_file':
119
+ return writeFile(input.path, input.content);
120
+ default:
121
+ throw new Error(`Tool "${toolName}" is not supported.`);
122
+ }
16
123
  } catch (error) {
17
- return `Error executing tool: ${error.message}`;
124
+ // Return a generic error message to the AI (don't leak internal details)
125
+ return `Tool execution failed: ${error.message}`;
18
126
  }
19
127
  }
20
128