shoud-cli 1.0.11 → 3.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 +220 -116
- package/install.sh +13 -8
- package/package.json +10 -11
- package/src/auth/credentials.js +49 -0
- package/src/auth/deviceFlow.js +89 -111
- package/src/job/baseline.js +82 -0
- package/src/job/checkpoint.js +61 -0
- package/src/job/manager.js +89 -0
- package/src/job/model.js +54 -0
- package/src/job/receipt.js +92 -0
- package/src/job/undo.js +104 -0
- package/src/project/discovery.js +93 -0
- package/src/project/ignore.js +31 -0
- package/src/project/shoudMd.js +38 -0
- package/src/runtime/agentLoop.js +281 -98
- package/src/runtime/budget.js +30 -0
- package/src/runtime/noProgress.js +38 -0
- package/src/runtime/verification.js +39 -0
- package/src/security/permissions.js +132 -0
- package/src/security/riskClassifier.js +127 -0
- package/src/security/secrets.js +57 -0
- package/src/security/shellParser.js +56 -0
- package/src/tools/files.js +81 -0
- package/src/tools/git.js +32 -0
- package/src/tools/index.js +92 -156
- package/src/tools/project.js +7 -0
- package/src/tools/search.js +49 -0
- package/src/tools/shell.js +71 -0
- package/src/ui/banner.js +20 -0
- package/src/ui/output.js +17 -0
- package/src/utils/config.js +47 -0
- package/src/utils/errors.js +21 -0
- package/src/context/checkpoint.js +0 -82
- package/src/permissions/engine.js +0 -133
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
|
|
4
|
+
function getShell() {
|
|
5
|
+
if (os.platform() === 'win32') return 'powershell.exe';
|
|
6
|
+
return process.env.SHELL || '/bin/bash';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getShellArgs(command) {
|
|
10
|
+
if (os.platform() === 'win32') {
|
|
11
|
+
const escaped = command.replace(/"/g, '`"');
|
|
12
|
+
return ['-NoProfile', '-Command', `& { ${escaped} } | Out-String -Width 4096`];
|
|
13
|
+
}
|
|
14
|
+
return ['-c', command];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Execute a shell command and stream output to `onOutput` (if provided).
|
|
19
|
+
* Returns { ok, exitCode, stdout, stderr, duration }.
|
|
20
|
+
*/
|
|
21
|
+
function runShell(command, { cwd, timeout, onOutput, env } = {}) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const start = Date.now();
|
|
24
|
+
let stdout = '';
|
|
25
|
+
let stderr = '';
|
|
26
|
+
let timedOut = false;
|
|
27
|
+
|
|
28
|
+
const child = spawn(getShell(), getShellArgs(command), {
|
|
29
|
+
cwd,
|
|
30
|
+
env: { ...process.env, ...(env || {}) },
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const timer = timeout ? setTimeout(() => {
|
|
35
|
+
timedOut = true;
|
|
36
|
+
try { child.kill('SIGKILL'); } catch (_) {}
|
|
37
|
+
}, timeout) : null;
|
|
38
|
+
|
|
39
|
+
const forward = (chunk, isErr) => {
|
|
40
|
+
const text = chunk.toString();
|
|
41
|
+
if (isErr) stderr += text; else stdout += text;
|
|
42
|
+
if (onOutput) onOutput(text, isErr);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
child.stdout.on('data', (c) => forward(c, false));
|
|
46
|
+
child.stderr.on('data', (c) => forward(c, true));
|
|
47
|
+
|
|
48
|
+
child.on('close', (code) => {
|
|
49
|
+
if (timer) clearTimeout(timer);
|
|
50
|
+
resolve({
|
|
51
|
+
ok: code === 0 && !timedOut,
|
|
52
|
+
exitCode: code,
|
|
53
|
+
timedOut,
|
|
54
|
+
stdout: stdout.trim(),
|
|
55
|
+
stderr: stderr.trim(),
|
|
56
|
+
duration: Date.now() - start,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
child.on('error', (err) => {
|
|
61
|
+
if (timer) clearTimeout(timer);
|
|
62
|
+
resolve({
|
|
63
|
+
ok: false, exitCode: -1, timedOut: false,
|
|
64
|
+
stdout, stderr: stderr + '\n' + err.message,
|
|
65
|
+
duration: Date.now() - start,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { runShell };
|
package/src/ui/banner.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
const BANNER_ART = [
|
|
4
|
+
" ███████╗██╗ ██╗ ██████╗ ██╗ ██╗██████╗ ",
|
|
5
|
+
" ██╔════╝██║ ██║██╔═══██╗██║ ██║██╔══██╗",
|
|
6
|
+
" ███████╗███████║██║ ██║██║ ██║██║ ██║",
|
|
7
|
+
" ╚════██║██╔══██║██║ ██║██║ ██║██║ ██║",
|
|
8
|
+
" ███████║██║ ██║╚██████╔╝╚██████╔╝██████╔╝",
|
|
9
|
+
" ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ "
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function printStaticBanner() {
|
|
13
|
+
const color = chalk.hex('#B5F96C');
|
|
14
|
+
console.log(color(BANNER_ART.join('\n')));
|
|
15
|
+
console.log(color(' ┌──────────────────────────────────────────────────────────┐'));
|
|
16
|
+
console.log(color(' │ Give your computer a job. │'));
|
|
17
|
+
console.log(color(' └──────────────────────────────────────────────────────────┘'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { printStaticBanner, BANNER_ART };
|
package/src/ui/output.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
const sym = {
|
|
4
|
+
ok: chalk.green('✓'),
|
|
5
|
+
fail: chalk.red('✗'),
|
|
6
|
+
warn: chalk.yellow('⚠'),
|
|
7
|
+
info: chalk.cyan('●'),
|
|
8
|
+
bullet: chalk.gray('·'),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function section(title) { console.log(chalk.bold('\n' + title)); }
|
|
12
|
+
function ok(msg) { console.log(` ${sym.ok} ${msg}`); }
|
|
13
|
+
function fail(msg) { console.log(` ${sym.fail} ${msg}`); }
|
|
14
|
+
function warn(msg) { console.log(` ${sym.warn} ${msg}`); }
|
|
15
|
+
function info(msg) { console.log(` ${sym.info} ${msg}`); }
|
|
16
|
+
|
|
17
|
+
module.exports = { section, ok, fail, warn, info, sym };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.shoud');
|
|
6
|
+
const JOBS_DIR = path.join(CONFIG_DIR, 'jobs');
|
|
7
|
+
const LOGS_DIR = path.join(CONFIG_DIR, 'logs');
|
|
8
|
+
|
|
9
|
+
function ensureDir(dir, mode = 0o700) {
|
|
10
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function initDirs() {
|
|
14
|
+
ensureDir(CONFIG_DIR);
|
|
15
|
+
ensureDir(JOBS_DIR);
|
|
16
|
+
ensureDir(LOGS_DIR);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const paths = {
|
|
20
|
+
CONFIG_DIR,
|
|
21
|
+
JOBS_DIR,
|
|
22
|
+
LOGS_DIR,
|
|
23
|
+
SESSION_FILE: path.join(CONFIG_DIR, 'session.json'),
|
|
24
|
+
ALLOWLIST_FILE: path.join(CONFIG_DIR, 'allowlist.json'),
|
|
25
|
+
SETTINGS_FILE: path.join(CONFIG_DIR, 'settings.json'),
|
|
26
|
+
jobDir: (id) => path.join(JOBS_DIR, id),
|
|
27
|
+
jobFile: (id, name) => path.join(JOBS_DIR, id, name),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const DEFAULT_SETTINGS = {
|
|
31
|
+
apiUrl: 'https://shoud.online/api',
|
|
32
|
+
billingUrl: 'https://shoud.vantyrixtek.online',
|
|
33
|
+
defaultBudget: 2.0,
|
|
34
|
+
shellDefaults: { read: 30000, test: 300000, build: 600000, install: 600000 },
|
|
35
|
+
telemetry: false,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function loadSettings() {
|
|
39
|
+
try {
|
|
40
|
+
if (fs.existsSync(paths.SETTINGS_FILE)) {
|
|
41
|
+
return { ...DEFAULT_SETTINGS, ...JSON.parse(fs.readFileSync(paths.SETTINGS_FILE, 'utf-8')) };
|
|
42
|
+
}
|
|
43
|
+
} catch (_) {}
|
|
44
|
+
return { ...DEFAULT_SETTINGS };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { paths, initDirs, loadSettings, DEFAULT_SETTINGS };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class ShoudError extends Error {
|
|
2
|
+
constructor(message, code = 1, meta = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'ShoudError';
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.meta = meta;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ExitCodes = {
|
|
11
|
+
VERIFIED: 0,
|
|
12
|
+
FAILED: 1,
|
|
13
|
+
BLOCKED: 2,
|
|
14
|
+
PERMISSION_DENIED: 3,
|
|
15
|
+
VERIFICATION_FAILED: 4,
|
|
16
|
+
BUDGET_EXCEEDED: 5,
|
|
17
|
+
AUTH_REQUIRED: 6,
|
|
18
|
+
CANCELLED: 130,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
module.exports = { ShoudError, ExitCodes };
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
|
|
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
|
|
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
|
-
*/
|
|
18
|
-
function saveCheckpoint(state) {
|
|
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
|
-
}
|
|
32
|
-
}
|
|
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
|
-
*/
|
|
38
|
-
function loadCheckpoint() {
|
|
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;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Clear (delete) the checkpoint file.
|
|
71
|
-
*/
|
|
72
|
-
function clearCheckpoint() {
|
|
73
|
-
if (fs.existsSync(CHECKPOINT_FILE)) {
|
|
74
|
-
try {
|
|
75
|
-
fs.unlinkSync(CHECKPOINT_FILE);
|
|
76
|
-
} catch (_) {
|
|
77
|
-
// Ignore errors during cleanup
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
module.exports = { saveCheckpoint, loadCheckpoint, clearCheckpoint };
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
const inquirer = require('inquirer');
|
|
2
|
-
const chalk = require('chalk');
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const os = require('os');
|
|
6
|
-
|
|
7
|
-
const CONFIG_DIR = path.join(os.homedir(), '.shoud');
|
|
8
|
-
const ALLOWLIST_FILE = path.join(CONFIG_DIR, 'allowlist.json');
|
|
9
|
-
|
|
10
|
-
// Ensure config directory exists
|
|
11
|
-
if (!fs.existsSync(CONFIG_DIR)) {
|
|
12
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
13
|
-
}
|
|
14
|
-
|
|
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;
|
|
54
|
-
}
|
|
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
|
-
|
|
81
|
-
console.log(chalk.yellow(`\n⚠ SHOUD requests permission to run: ${chalk.bold(toolName)}`));
|
|
82
|
-
console.log(chalk.gray(`Target: ${target}`));
|
|
83
|
-
|
|
84
|
-
const { permission } = await inquirer.prompt([
|
|
85
|
-
{
|
|
86
|
-
type: 'list',
|
|
87
|
-
name: 'permission',
|
|
88
|
-
message: 'Allow this operation?',
|
|
89
|
-
choices: [
|
|
90
|
-
{ name: 'Allow once', value: 'once' },
|
|
91
|
-
{ name: 'Allow for this session', value: 'session' },
|
|
92
|
-
{ name: 'Always allow (save to config)', value: 'always' },
|
|
93
|
-
{ name: 'Deny', value: 'deny' }
|
|
94
|
-
]
|
|
95
|
-
}
|
|
96
|
-
]);
|
|
97
|
-
|
|
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;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
module.exports = { verifyPermission };
|