shoud-cli 1.0.0 → 1.0.1
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 +27 -17
- package/package.json +1 -1
- package/src/auth/deviceFlow.js +115 -12
- package/src/context/checkpoint.js +65 -6
- package/src/permissions/engine.js +109 -10
- package/src/runtime/agentLoop.js +45 -19
- package/src/tools/index.js +119 -11
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
71
|
-
console.log(` ${chalk.hex('#B5F96C')('shoud status')} Check your current credit balance and active plan
|
|
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
|
|
84
|
-
if (args.length === 0 ||
|
|
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
package/src/auth/deviceFlow.js
CHANGED
|
@@ -5,36 +5,139 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
127
|
+
console.log(chalk.red('\nLogin cancelled.'));
|
|
30
128
|
}
|
|
31
129
|
}
|
|
32
130
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
const
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), '.shoud');
|
|
8
|
+
const ALLOWLIST_FILE = path.join(CONFIG_DIR, 'allowlist.json');
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
// Ensure config directory exists
|
|
11
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
12
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
13
|
+
}
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
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: ${
|
|
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
|
-
|
|
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 };
|
package/src/runtime/agentLoop.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 =
|
|
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
|
|
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
|
|
90
|
+
// Task completed (no more tool calls)
|
|
75
91
|
taskComplete = true;
|
|
76
92
|
clearCheckpoint();
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
const
|
|
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: $${
|
|
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(
|
|
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
|
}
|
package/src/tools/index.js
CHANGED
|
@@ -1,20 +1,128 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { execFileSync } = require('child_process');
|
|
2
2
|
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
3
4
|
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|