@misterhuydo/sentinel 1.0.82 → 1.0.83
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/.cairn/.hint-lock +1 -1
- package/.cairn/minify-map.json +0 -6
- package/.cairn/session.json +2 -2
- package/lib/generate.js +15 -1
- package/lib/init.js +35 -10
- package/package.json +1 -1
- package/python/scripts/patch_notify.js +200 -0
- package/python/sentinel/config_loader.py +6 -0
- package/python/sentinel/fix_engine.py +177 -160
- package/python/sentinel/main.py +35 -0
- package/python/sentinel/notify.py +88 -0
- package/python/sentinel/sentinel_boss.py +53 -21
- package/templates/sentinel.properties +3 -1
- package/templates/workspace-sentinel.properties +33 -0
- package/.cairn/views/2a85cc_init.js +0 -275
package/.cairn/.hint-lock
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2026-03-
|
|
1
|
+
2026-03-23T05:01:48.297Z
|
package/.cairn/minify-map.json
CHANGED
|
@@ -4,11 +4,5 @@
|
|
|
4
4
|
"state": "compressed",
|
|
5
5
|
"minifiedAt": 1774128147034.2527,
|
|
6
6
|
"readCount": 1
|
|
7
|
-
},
|
|
8
|
-
"J:\\Projects\\Sentinel\\cli\\lib\\init.js": {
|
|
9
|
-
"tempPath": "J:\\Projects\\Sentinel\\cli\\.cairn\\views\\2a85cc_init.js",
|
|
10
|
-
"state": "compressed",
|
|
11
|
-
"minifiedAt": 1774189651415.41,
|
|
12
|
-
"readCount": 1
|
|
13
7
|
}
|
|
14
8
|
}
|
package/.cairn/session.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"message": "Auto-checkpoint at 2026-03-
|
|
3
|
-
"checkpoint_at": "2026-03-
|
|
2
|
+
"message": "Auto-checkpoint at 2026-03-23T05:07:15.110Z",
|
|
3
|
+
"checkpoint_at": "2026-03-23T05:07:15.111Z",
|
|
4
4
|
"active_files": [],
|
|
5
5
|
"notes": [],
|
|
6
6
|
"mtime_snapshot": {}
|
package/lib/generate.js
CHANGED
|
@@ -96,7 +96,7 @@ rm -f "$PID_FILE"
|
|
|
96
96
|
|
|
97
97
|
// ── Workspace-level startAll / stopAll ────────────────────────────────────────
|
|
98
98
|
|
|
99
|
-
function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {}) {
|
|
99
|
+
function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {}, authConfig = {}) {
|
|
100
100
|
// Write shared sentinel.properties once (never overwrite existing)
|
|
101
101
|
const workspaceProps = path.join(workspace, 'sentinel.properties');
|
|
102
102
|
if (!fs.existsSync(workspaceProps)) {
|
|
@@ -108,6 +108,20 @@ function generateWorkspaceScripts(workspace, smtpConfig = {}, slackConfig = {})
|
|
|
108
108
|
if (smtpConfig.password) tpl = tpl.replace('SMTP_PASSWORD=<app-password>', 'SMTP_PASSWORD=' + smtpConfig.password);
|
|
109
109
|
fs.writeFileSync(workspaceProps, tpl);
|
|
110
110
|
}
|
|
111
|
+
// Always upsert auth config so re-runs persist it
|
|
112
|
+
if (authConfig.apiKey || authConfig.claudeProForTasks !== undefined) {
|
|
113
|
+
let props = fs.readFileSync(workspaceProps, 'utf8');
|
|
114
|
+
if (authConfig.apiKey) {
|
|
115
|
+
const replaced = props.replace(/^#?\s*ANTHROPIC_API_KEY=.*/m, 'ANTHROPIC_API_KEY=' + authConfig.apiKey);
|
|
116
|
+
props = replaced !== props ? replaced : props.trimEnd() + '\nANTHROPIC_API_KEY=' + authConfig.apiKey + '\n';
|
|
117
|
+
}
|
|
118
|
+
if (authConfig.claudeProForTasks !== undefined) {
|
|
119
|
+
const val = authConfig.claudeProForTasks ? 'true' : 'false';
|
|
120
|
+
const replaced = props.replace(/^CLAUDE_PRO_FOR_TASKS=.*/m, 'CLAUDE_PRO_FOR_TASKS=' + val);
|
|
121
|
+
props = replaced !== props ? replaced : props.trimEnd() + '\nCLAUDE_PRO_FOR_TASKS=' + val + '\n';
|
|
122
|
+
}
|
|
123
|
+
fs.writeFileSync(workspaceProps, props);
|
|
124
|
+
}
|
|
111
125
|
// Always upsert Slack tokens so re-runs persist them
|
|
112
126
|
if (slackConfig.botToken || slackConfig.appToken) {
|
|
113
127
|
let props = fs.readFileSync(workspaceProps, 'utf8');
|
package/lib/init.js
CHANGED
|
@@ -33,15 +33,26 @@ module.exports = async function init() {
|
|
|
33
33
|
{
|
|
34
34
|
type: 'select',
|
|
35
35
|
name: 'authMode',
|
|
36
|
-
message: '
|
|
36
|
+
message: 'Claude authentication strategy',
|
|
37
|
+
hint: 'Boss = Slack AI; Fix Engine = autonomous code repair',
|
|
37
38
|
choices: [
|
|
38
|
-
{
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
{
|
|
40
|
+
title: 'Both (RECOMMENDED) — API key for Boss, Claude Pro for Fix Engine',
|
|
41
|
+
value: 'both',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
title: 'API key only — full Boss tools; Fix Engine billed per token',
|
|
45
|
+
value: 'apikey',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
title: 'Claude Pro / OAuth only — run `claude login`; Boss has limited tools',
|
|
49
|
+
value: 'oauth',
|
|
50
|
+
},
|
|
51
|
+
{ title: 'Skip (configure manually in sentinel.properties)', value: 'skip' },
|
|
41
52
|
],
|
|
42
53
|
},
|
|
43
54
|
{
|
|
44
|
-
type: prev => prev === 'apikey' ? 'password' : null,
|
|
55
|
+
type: prev => (prev === 'apikey' || prev === 'both') ? 'password' : null,
|
|
45
56
|
name: 'anthropicKey',
|
|
46
57
|
message: 'Anthropic API key (sk-ant-...)',
|
|
47
58
|
validate: v => v.startsWith('sk-ant-') ? true : 'Key should start with sk-ant-',
|
|
@@ -147,12 +158,22 @@ module.exports = async function init() {
|
|
|
147
158
|
|
|
148
159
|
// ── Claude Code auth ─────────────────────────────────────────────────────────
|
|
149
160
|
step('Claude Code authentication…');
|
|
150
|
-
if (authMode === '
|
|
151
|
-
ok('API key
|
|
161
|
+
if (authMode === 'both' && anthropicKey) {
|
|
162
|
+
ok('API key → Sentinel Boss (full tools, structured responses)');
|
|
163
|
+
ok('Claude Pro → Fix Engine + Ask Codebase (heavy coding, Pro subscription)');
|
|
164
|
+
info('Run `claude login` on this server now (or before starting projects)');
|
|
165
|
+
info('CLAUDE_PRO_FOR_TASKS=true written to workspace sentinel.properties');
|
|
166
|
+
} else if (authMode === 'apikey' && anthropicKey) {
|
|
167
|
+
ok('API key → all Claude usage (Boss + Fix Engine billed to your API quota)');
|
|
168
|
+
info('CLAUDE_PRO_FOR_TASKS=false written — Fix Engine will use API key');
|
|
169
|
+
warn('Heavy fix tasks will consume API tokens. Claude Pro is cheaper for those.');
|
|
152
170
|
} else if (authMode === 'oauth') {
|
|
153
|
-
|
|
171
|
+
ok('Claude Pro / OAuth → Fix Engine + Ask Codebase');
|
|
172
|
+
warn('Boss will use CLI fallback — some tools unavailable without an API key');
|
|
173
|
+
info('Run `claude login` on this server to authenticate');
|
|
154
174
|
} else {
|
|
155
|
-
|
|
175
|
+
warn('No auth configured — add ANTHROPIC_API_KEY or run `claude login` before starting');
|
|
176
|
+
info('See: workspace sentinel.properties for full auth documentation');
|
|
156
177
|
}
|
|
157
178
|
|
|
158
179
|
// ── Slack Bot ────────────────────────────────────────────────────────────────
|
|
@@ -179,7 +200,11 @@ module.exports = async function init() {
|
|
|
179
200
|
|
|
180
201
|
// ── Workspace start/stop scripts ─────────────────────────────────────────────
|
|
181
202
|
step('Generating scripts…');
|
|
182
|
-
|
|
203
|
+
const authConfig = {};
|
|
204
|
+
if (anthropicKey) authConfig.apiKey = anthropicKey;
|
|
205
|
+
if (authMode === 'both' || authMode === 'oauth') authConfig.claudeProForTasks = true;
|
|
206
|
+
if (authMode === 'apikey') authConfig.claudeProForTasks = false;
|
|
207
|
+
generateWorkspaceScripts(workspace, { host: smtpHost, user: smtpUser, password: effectiveSmtpPassword }, { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken }, authConfig);
|
|
183
208
|
ok(`${workspace}/startAll.sh`);
|
|
184
209
|
ok(`${workspace}/stopAll.sh`);
|
|
185
210
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
// ── fix_engine.py ─────────────────────────────────────────────────────────────
|
|
5
|
+
{
|
|
6
|
+
const f = 'sentinel/fix_engine.py';
|
|
7
|
+
let c = fs.readFileSync(f, 'utf8').replace(/\r\n/g, '\n');
|
|
8
|
+
|
|
9
|
+
// 1. Add notify import
|
|
10
|
+
const OLD_IMPORT = 'from .config_loader import RepoConfig, SentinelConfig\nfrom .log_parser import ErrorEvent';
|
|
11
|
+
const NEW_IMPORT = 'from .config_loader import RepoConfig, SentinelConfig\nfrom .log_parser import ErrorEvent\nfrom .notify import alert_if_rate_limited, slack_alert';
|
|
12
|
+
if (!c.includes(OLD_IMPORT)) { console.error('fix_engine import not found'); process.exit(1); }
|
|
13
|
+
c = c.replace(OLD_IMPORT, NEW_IMPORT);
|
|
14
|
+
|
|
15
|
+
// 2. Only inject ANTHROPIC_API_KEY when claude_pro_for_tasks is False
|
|
16
|
+
// (so when both are configured, heavy tasks use Claude Pro OAuth)
|
|
17
|
+
const OLD_ENV = ` import os as _os
|
|
18
|
+
env = _os.environ.copy()
|
|
19
|
+
if cfg.anthropic_api_key:
|
|
20
|
+
env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key`;
|
|
21
|
+
const NEW_ENV = ` import os as _os
|
|
22
|
+
env = _os.environ.copy()
|
|
23
|
+
# Inject API key only when Claude Pro is NOT preferred for tasks
|
|
24
|
+
# (when claude_pro_for_tasks=True and API key is set, let claude CLI use OAuth/Pro)
|
|
25
|
+
if cfg.anthropic_api_key and not cfg.claude_pro_for_tasks:
|
|
26
|
+
env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key`;
|
|
27
|
+
if (!c.includes(OLD_ENV)) { console.error('fix_engine env block not found'); process.exit(1); }
|
|
28
|
+
c = c.replace(OLD_ENV, NEW_ENV);
|
|
29
|
+
|
|
30
|
+
// 3. After getting output, check for rate limits and alert Slack
|
|
31
|
+
const OLD_OUTPUT = ` output = (result.stdout or "") + (result.stderr or "")
|
|
32
|
+
|
|
33
|
+
if output.strip().upper().startswith("SKIP:"):`;
|
|
34
|
+
const NEW_OUTPUT = ` output = (result.stdout or "") + (result.stderr or "")
|
|
35
|
+
|
|
36
|
+
# Alert Slack immediately on rate-limit / auth failure — never stay silent
|
|
37
|
+
alert_if_rate_limited(
|
|
38
|
+
cfg.slack_bot_token,
|
|
39
|
+
cfg.slack_channel,
|
|
40
|
+
source=f"fix_engine/{event.fingerprint}",
|
|
41
|
+
output=output,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if output.strip().upper().startswith("SKIP:"):`;
|
|
45
|
+
if (!c.includes(OLD_OUTPUT)) { console.error('fix_engine output block not found'); process.exit(1); }
|
|
46
|
+
c = c.replace(OLD_OUTPUT, NEW_OUTPUT);
|
|
47
|
+
|
|
48
|
+
// 4. Also alert on FileNotFoundError (claude CLI missing)
|
|
49
|
+
const OLD_NOTFOUND = ` except FileNotFoundError:
|
|
50
|
+
logger.error("Claude Code binary not found at '%s'", cfg.claude_code_bin)
|
|
51
|
+
return "error", None, ""`;
|
|
52
|
+
const NEW_NOTFOUND = ' except FileNotFoundError:\n'
|
|
53
|
+
+ ' msg = (\n'
|
|
54
|
+
+ ' f":warning: *Sentinel \u2014 Claude CLI not found*\\n"\n'
|
|
55
|
+
+ ' f"`{cfg.claude_code_bin}` not found. Run: `npm install -g @anthropic-ai/claude-code`\\n"\n'
|
|
56
|
+
+ ' f"Fix engine is disabled until this is resolved."\n'
|
|
57
|
+
+ ' )\n'
|
|
58
|
+
+ ' logger.error("Claude Code binary not found at \'%s\'", cfg.claude_code_bin)\n'
|
|
59
|
+
+ ' slack_alert(cfg.slack_bot_token, cfg.slack_channel, msg)\n'
|
|
60
|
+
+ ' return "error", None, ""';
|
|
61
|
+
if (!c.includes(OLD_NOTFOUND)) { console.error('fix_engine notfound block not found'); process.exit(1); }
|
|
62
|
+
c = c.replace(OLD_NOTFOUND, NEW_NOTFOUND);
|
|
63
|
+
|
|
64
|
+
fs.writeFileSync(f, c, 'utf8');
|
|
65
|
+
console.log('fix_engine.py done. Lines:', c.split('\n').length);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── sentinel_boss.py ──────────────────────────────────────────────────────────
|
|
69
|
+
{
|
|
70
|
+
const f = 'sentinel/sentinel_boss.py';
|
|
71
|
+
let c = fs.readFileSync(f, 'utf8').replace(/\r\n/g, '\n');
|
|
72
|
+
|
|
73
|
+
// 1. Add notify import
|
|
74
|
+
const OLD_IMPORT = 'logger = logging.getLogger(__name__)';
|
|
75
|
+
const NEW_IMPORT = 'from .notify import alert_if_rate_limited, slack_alert, is_rate_limited\n\nlogger = logging.getLogger(__name__)';
|
|
76
|
+
if (!c.includes(OLD_IMPORT)) { console.error('boss import anchor not found'); process.exit(1); }
|
|
77
|
+
c = c.replace(OLD_IMPORT, NEW_IMPORT);
|
|
78
|
+
|
|
79
|
+
// 2. Revert auth priority: API key first, CLI fallback
|
|
80
|
+
// Replace current handle_message body
|
|
81
|
+
const OLD_HM_BODY =
|
|
82
|
+
` # 1st priority: Claude Pro / OAuth via CLI
|
|
83
|
+
cli_reply, cli_done = await _handle_with_cli(
|
|
84
|
+
message, history, cfg_loader, store, slack_client=slack_client, user_name=user_name,
|
|
85
|
+
user_id=user_id, attachments=attachments,
|
|
86
|
+
)
|
|
87
|
+
if not cli_reply.startswith(":warning:"):
|
|
88
|
+
return cli_reply, cli_done
|
|
89
|
+
|
|
90
|
+
# CLI failed — try ANTHROPIC_API_KEY fallback
|
|
91
|
+
try:
|
|
92
|
+
import anthropic # noqa: F401
|
|
93
|
+
except ImportError:
|
|
94
|
+
return (
|
|
95
|
+
":warning: \`anthropic\` package not installed. Run: \`pip install anthropic\`",
|
|
96
|
+
True,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
100
|
+
if not api_key:
|
|
101
|
+
return cli_reply, cli_done # No fallback available
|
|
102
|
+
|
|
103
|
+
logger.info("Boss: CLI path failed (%s…), falling back to ANTHROPIC_API_KEY", cli_reply[:60])
|
|
104
|
+
return await _handle_with_api(
|
|
105
|
+
message, history, cfg_loader, store, slack_client=slack_client, user_name=user_name,
|
|
106
|
+
user_id=user_id, attachments=attachments,
|
|
107
|
+
)`;
|
|
108
|
+
|
|
109
|
+
const NEW_HM_BODY =
|
|
110
|
+
` api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
111
|
+
|
|
112
|
+
# 1st priority: ANTHROPIC_API_KEY — full structured tools, cheap per-token for Boss queries
|
|
113
|
+
if api_key:
|
|
114
|
+
try:
|
|
115
|
+
import anthropic # noqa: F401
|
|
116
|
+
return await _handle_with_api(
|
|
117
|
+
message, history, cfg_loader, store, slack_client=slack_client,
|
|
118
|
+
user_name=user_name, user_id=user_id, attachments=attachments,
|
|
119
|
+
)
|
|
120
|
+
except Exception as api_err:
|
|
121
|
+
err_str = str(api_err)
|
|
122
|
+
# Detect rate-limit / auth failure and alert Slack before falling through
|
|
123
|
+
cfg = cfg_loader.sentinel
|
|
124
|
+
if is_rate_limited(err_str):
|
|
125
|
+
from .notify import rate_limit_message
|
|
126
|
+
alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
|
|
127
|
+
"sentinel_boss/api", err_str)
|
|
128
|
+
logger.warning("Boss: API key path failed (%s), trying CLI fallback", err_str[:80])
|
|
129
|
+
|
|
130
|
+
# 2nd priority: Claude Pro / OAuth via CLI (limited tools but no API key needed)
|
|
131
|
+
cli_reply, cli_done = await _handle_with_cli(
|
|
132
|
+
message, history, cfg_loader, store, slack_client=slack_client, user_name=user_name,
|
|
133
|
+
user_id=user_id, attachments=attachments,
|
|
134
|
+
)
|
|
135
|
+
if not cli_reply.startswith(":warning:"):
|
|
136
|
+
return cli_reply, cli_done
|
|
137
|
+
|
|
138
|
+
# Both paths failed — alert Slack and return error
|
|
139
|
+
cfg = cfg_loader.sentinel
|
|
140
|
+
err_output = cli_reply
|
|
141
|
+
alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
|
|
142
|
+
"sentinel_boss/cli", err_output)
|
|
143
|
+
if not api_key:
|
|
144
|
+
# No auth at all configured
|
|
145
|
+
no_auth_msg = (
|
|
146
|
+
":warning: *Sentinel Boss — no Claude auth configured*\\n"
|
|
147
|
+
"Configure at least one of:\\n"
|
|
148
|
+
"\u2022 \`ANTHROPIC_API_KEY\` in \`sentinel.properties\` \u2014 full features\\n"
|
|
149
|
+
"\u2022 Claude Pro OAuth: run \`claude login\` on the server \u2014 required for fix_engine\\n"
|
|
150
|
+
"See: https://github.com/misterhuydo/Sentinel#authentication"
|
|
151
|
+
)
|
|
152
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel, no_auth_msg)
|
|
153
|
+
return ":warning: No Claude authentication configured. See Slack for details.", True
|
|
154
|
+
return cli_reply, cli_done`;
|
|
155
|
+
|
|
156
|
+
if (!c.includes(OLD_HM_BODY)) { console.error('handle_message body not found'); process.exit(1); }
|
|
157
|
+
c = c.replace(OLD_HM_BODY, NEW_HM_BODY);
|
|
158
|
+
|
|
159
|
+
// 3. In _handle_with_cli, alert Slack on rate-limit detected in CLI output
|
|
160
|
+
const OLD_CLI_FAIL =
|
|
161
|
+
` if result.returncode != 0 and not output:
|
|
162
|
+
return f":warning: \`claude --print\` failed (exit {result.returncode}): {(result.stderr or '').strip()[:300]}", True`;
|
|
163
|
+
const NEW_CLI_FAIL =
|
|
164
|
+
` raw_err = (result.stderr or "").strip()
|
|
165
|
+
if result.returncode != 0 and not output:
|
|
166
|
+
full_err = f"exit {result.returncode}: {raw_err[:300]}"
|
|
167
|
+
cfg = cfg_loader.sentinel
|
|
168
|
+
alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
|
|
169
|
+
"sentinel_boss/cli", raw_err or full_err)
|
|
170
|
+
return f":warning: \`claude --print\` failed ({full_err})", True`;
|
|
171
|
+
if (!c.includes(OLD_CLI_FAIL)) { console.error('cli fail block not found'); process.exit(1); }
|
|
172
|
+
c = c.replace(OLD_CLI_FAIL, NEW_CLI_FAIL);
|
|
173
|
+
|
|
174
|
+
// 4. Also alert in ask_codebase when claude --print fails
|
|
175
|
+
const OLD_ASK_FAIL = ` if r.returncode != 0 and not output:
|
|
176
|
+
return {"repo": repo_name, "error": f"claude --print failed (rc={r.returncode}): {(r.stderr or '')[:200]}"}`;
|
|
177
|
+
const NEW_ASK_FAIL = ` if r.returncode != 0 and not output:
|
|
178
|
+
raw_err = (r.stderr or "")
|
|
179
|
+
alert_if_rate_limited(
|
|
180
|
+
cfg.slack_bot_token, cfg.slack_channel,
|
|
181
|
+
f"ask_codebase/{repo_name}", raw_err,
|
|
182
|
+
)
|
|
183
|
+
return {"repo": repo_name, "error": f"claude --print failed (rc={r.returncode}): {raw_err[:200]}"}`;
|
|
184
|
+
if (!c.includes(OLD_ASK_FAIL)) { console.error('ask_codebase fail block not found'); process.exit(1); }
|
|
185
|
+
c = c.replace(OLD_ASK_FAIL, NEW_ASK_FAIL);
|
|
186
|
+
|
|
187
|
+
// 5. ask_codebase: respect claude_pro_for_tasks (don't inject API key when Pro preferred)
|
|
188
|
+
const OLD_ASK_ENV = ` env = os.environ.copy()
|
|
189
|
+
if cfg.anthropic_api_key:
|
|
190
|
+
env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key`;
|
|
191
|
+
const NEW_ASK_ENV = ` env = os.environ.copy()
|
|
192
|
+
# Only inject API key when Claude Pro is NOT preferred for heavy tasks
|
|
193
|
+
if cfg.anthropic_api_key and not cfg.claude_pro_for_tasks:
|
|
194
|
+
env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key`;
|
|
195
|
+
if (!c.includes(OLD_ASK_ENV)) { console.error('ask_codebase env not found'); process.exit(1); }
|
|
196
|
+
c = c.replace(OLD_ASK_ENV, NEW_ASK_ENV);
|
|
197
|
+
|
|
198
|
+
fs.writeFileSync(f, c, 'utf8');
|
|
199
|
+
console.log('sentinel_boss.py done. Lines:', c.split('\n').length);
|
|
200
|
+
}
|
|
@@ -63,6 +63,11 @@ class SentinelConfig:
|
|
|
63
63
|
slack_watch_bot_ids: list[str] = field(default_factory=list) # pre-configured bot IDs to watch passively
|
|
64
64
|
slack_allowed_users: list[str] = field(default_factory=list) # if set, only these Slack user IDs can talk to Boss
|
|
65
65
|
project_name: str = "" # optional: friendly name used by Sentinel Boss (e.g. "1881")
|
|
66
|
+
# Auth strategy:
|
|
67
|
+
# ANTHROPIC_API_KEY — used by Sentinel Boss conversation (structured tools, cheap per-token)
|
|
68
|
+
# Claude Pro / OAuth — used by fix_engine + ask_codebase when CLAUDE_PRO_FOR_TASKS=true
|
|
69
|
+
# At least one must be configured. Both = ideal split (Boss=API key, heavy tasks=Pro).
|
|
70
|
+
claude_pro_for_tasks: bool = True # when True + API key set, fix_engine/ask_codebase use claude CLI (Pro billing)
|
|
66
71
|
|
|
67
72
|
|
|
68
73
|
@dataclass
|
|
@@ -158,6 +163,7 @@ class ConfigLoader:
|
|
|
158
163
|
c.slack_watch_bot_ids = _csv(d.get("SLACK_WATCH_BOT_IDS", ""))
|
|
159
164
|
c.slack_allowed_users = _csv(d.get("SLACK_ALLOWED_USERS", ""))
|
|
160
165
|
c.project_name = d.get("PROJECT_NAME", "")
|
|
166
|
+
c.claude_pro_for_tasks = d.get("CLAUDE_PRO_FOR_TASKS", "true").lower() != "false"
|
|
161
167
|
self.sentinel = c
|
|
162
168
|
|
|
163
169
|
def _load_log_sources(self):
|
|
@@ -1,160 +1,177 @@
|
|
|
1
|
-
"""
|
|
2
|
-
fix_engine.py — Generate code fixes via Claude Code (headless).
|
|
3
|
-
|
|
4
|
-
Invokes: claude --print "<prompt>" 2>&1
|
|
5
|
-
|
|
6
|
-
Cairn MCP context is fetched automatically by Claude Code via its MCP tool
|
|
7
|
-
connection — Sentinel does not need to query or inject it explicitly.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import logging
|
|
11
|
-
import re
|
|
12
|
-
import subprocess
|
|
13
|
-
import textwrap
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
from .config_loader import RepoConfig, SentinelConfig
|
|
17
|
-
from .log_parser import ErrorEvent
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
f'
|
|
50
|
-
f'
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
f"
|
|
68
|
-
"",
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
event.
|
|
77
|
-
|
|
78
|
-
"
|
|
79
|
-
|
|
80
|
-
"
|
|
81
|
-
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
status
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
except
|
|
136
|
-
logger.error("Claude Code
|
|
137
|
-
return "error", None, ""
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
1
|
+
"""
|
|
2
|
+
fix_engine.py — Generate code fixes via Claude Code (headless).
|
|
3
|
+
|
|
4
|
+
Invokes: claude --print "<prompt>" 2>&1
|
|
5
|
+
|
|
6
|
+
Cairn MCP context is fetched automatically by Claude Code via its MCP tool
|
|
7
|
+
connection — Sentinel does not need to query or inject it explicitly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import textwrap
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .config_loader import RepoConfig, SentinelConfig
|
|
17
|
+
from .log_parser import ErrorEvent
|
|
18
|
+
from .notify import alert_if_rate_limited, slack_alert
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
SUBPROCESS_TIMEOUT = 120
|
|
23
|
+
MAX_FILES_IN_PATCH = 5
|
|
24
|
+
MAX_LINES_IN_PATCH = 200
|
|
25
|
+
|
|
26
|
+
_DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
|
|
27
|
+
_DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_prompt(event, repo: RepoConfig, log_file, marker: str, stale_markers: list[str] = None) -> str:
|
|
31
|
+
if log_file and log_file.exists():
|
|
32
|
+
ctx = (
|
|
33
|
+
"LOG FILE: " + str(log_file) + "\n"
|
|
34
|
+
"Read this file first -- it contains the last 48h of logs from "
|
|
35
|
+
+ event.source + ".\n"
|
|
36
|
+
"Use it to understand frequency, context, and preceding warnings."
|
|
37
|
+
)
|
|
38
|
+
step1 = "Read the log file above to understand what led up to this error."
|
|
39
|
+
else:
|
|
40
|
+
ctx = (
|
|
41
|
+
"SOURCE: " + event.source + "\n"
|
|
42
|
+
"No rolling log file available. The full issue description is below."
|
|
43
|
+
)
|
|
44
|
+
step1 = "Use the issue description above as your primary context."
|
|
45
|
+
|
|
46
|
+
marker_label = marker + " sentinel-auto-fix [safe to remove after verification]"
|
|
47
|
+
marker_instruction = "\n".join([
|
|
48
|
+
"For EVERY method and constructor you modify, add this as the FIRST executable line:",
|
|
49
|
+
f' Java/Kotlin : log.info("{marker_label}");',
|
|
50
|
+
f' Python : logger.info("{marker_label}")',
|
|
51
|
+
f' Node.js : logger.info("{marker_label}")',
|
|
52
|
+
"Use the logger already present in the file. Do not add new imports.",
|
|
53
|
+
"This applies to ALL modified methods and constructors without exception.",
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
cleanup = ""
|
|
57
|
+
if stale_markers:
|
|
58
|
+
marker_list = "\n".join(f" - {m}" for m in stale_markers)
|
|
59
|
+
cleanup = (
|
|
60
|
+
"CLEANUP (do this first, before the fix):\n"
|
|
61
|
+
"Remove any log lines containing these stale Sentinel markers from the codebase:\n"
|
|
62
|
+
+ marker_list + "\n"
|
|
63
|
+
"Commit the cleanup separately with message: 'chore(sentinel): remove stale markers'\n"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
lines_out = [
|
|
67
|
+
f"You are fixing a production bug in the repository at {repo.local_path}.",
|
|
68
|
+
f"Repository: {repo.repo_name}",
|
|
69
|
+
"",
|
|
70
|
+
]
|
|
71
|
+
if cleanup:
|
|
72
|
+
lines_out += [cleanup, ""]
|
|
73
|
+
lines_out += [
|
|
74
|
+
ctx,
|
|
75
|
+
"",
|
|
76
|
+
f"ISSUE TO FIX (from {event.source}):",
|
|
77
|
+
event.full_text(),
|
|
78
|
+
"",
|
|
79
|
+
"Task:",
|
|
80
|
+
f"1. {step1}",
|
|
81
|
+
"2. Use your available tools to explore the codebase and identify the root cause.",
|
|
82
|
+
f"3. {marker_instruction}",
|
|
83
|
+
"4. Output ONLY a unified diff patch (git diff format) fixing the issue.",
|
|
84
|
+
"5. Do not explain. Output only the patch.",
|
|
85
|
+
"6. If you cannot determine a safe fix, output: SKIP: <reason>",
|
|
86
|
+
]
|
|
87
|
+
return "\n".join(lines_out)
|
|
88
|
+
|
|
89
|
+
def _validate_patch(patch: str) -> tuple[bool, str]:
|
|
90
|
+
files_changed = len(re.findall(r"^diff --git", patch, re.MULTILINE))
|
|
91
|
+
lines_changed = len([
|
|
92
|
+
l for l in patch.splitlines()
|
|
93
|
+
if l.startswith(("+", "-")) and not l.startswith(("+++", "---"))
|
|
94
|
+
])
|
|
95
|
+
if files_changed > MAX_FILES_IN_PATCH:
|
|
96
|
+
return False, f"Patch touches {files_changed} files (limit {MAX_FILES_IN_PATCH})"
|
|
97
|
+
if lines_changed > MAX_LINES_IN_PATCH:
|
|
98
|
+
return False, f"Patch changes {lines_changed} lines (limit {MAX_LINES_IN_PATCH})"
|
|
99
|
+
return True, ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def generate_fix(
|
|
103
|
+
event: ErrorEvent,
|
|
104
|
+
repo: RepoConfig,
|
|
105
|
+
cfg: SentinelConfig,
|
|
106
|
+
patches_dir: Path,
|
|
107
|
+
) -> tuple[str, Path | None]:
|
|
108
|
+
"""
|
|
109
|
+
Generate a fix for the given error event.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
(status, patch_path)
|
|
113
|
+
status: "patch" | "skip" | "error"
|
|
114
|
+
"""
|
|
115
|
+
# Issues have source like "issues/filename" — no rolling log file exists
|
|
116
|
+
log_file = Path(cfg.workspace_dir) / "fetched" / f"{event.source}.log"
|
|
117
|
+
if not log_file.exists():
|
|
118
|
+
log_file = None
|
|
119
|
+
prompt = _build_prompt(event, repo, log_file)
|
|
120
|
+
|
|
121
|
+
logger.info("Invoking Claude Code for %s (fp=%s)", event.source, event.fingerprint)
|
|
122
|
+
import os as _os
|
|
123
|
+
env = _os.environ.copy()
|
|
124
|
+
# Inject API key only when Claude Pro is NOT preferred for tasks
|
|
125
|
+
# (when claude_pro_for_tasks=True and API key is set, let claude CLI use OAuth/Pro)
|
|
126
|
+
if cfg.anthropic_api_key and not cfg.claude_pro_for_tasks:
|
|
127
|
+
env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
|
|
128
|
+
try:
|
|
129
|
+
result = subprocess.run(
|
|
130
|
+
([cfg.claude_code_bin, "--dangerously-skip-permissions", "--print", prompt]
|
|
131
|
+
if os.getuid() != 0 else
|
|
132
|
+
[cfg.claude_code_bin, "--print", prompt]),
|
|
133
|
+
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT, env=env,
|
|
134
|
+
)
|
|
135
|
+
except subprocess.TimeoutExpired:
|
|
136
|
+
logger.error("Claude Code timed out for %s", event.fingerprint)
|
|
137
|
+
return "error", None, ""
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
msg = (
|
|
140
|
+
f":warning: *Sentinel — Claude CLI not found*\n"
|
|
141
|
+
f"`{cfg.claude_code_bin}` not found. Run: `npm install -g @anthropic-ai/claude-code`\n"
|
|
142
|
+
f"Fix engine is disabled until this is resolved."
|
|
143
|
+
)
|
|
144
|
+
logger.error("Claude Code binary not found at '%s'", cfg.claude_code_bin)
|
|
145
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel, msg)
|
|
146
|
+
return "error", None, ""
|
|
147
|
+
|
|
148
|
+
output = (result.stdout or "") + (result.stderr or "")
|
|
149
|
+
|
|
150
|
+
# Alert Slack immediately on rate-limit / auth failure — never stay silent
|
|
151
|
+
alert_if_rate_limited(
|
|
152
|
+
cfg.slack_bot_token,
|
|
153
|
+
cfg.slack_channel,
|
|
154
|
+
source=f"fix_engine/{event.fingerprint}",
|
|
155
|
+
output=output,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if output.strip().upper().startswith("SKIP:"):
|
|
159
|
+
reason = output.strip()[5:].strip()
|
|
160
|
+
logger.info("Claude skipped fix for %s: %s", event.fingerprint, reason)
|
|
161
|
+
return "skip", None, ""
|
|
162
|
+
|
|
163
|
+
patch = _extract_patch(output)
|
|
164
|
+
if not patch:
|
|
165
|
+
logger.warning("No patch found in Claude output for %s", event.fingerprint)
|
|
166
|
+
return "error", None, ""
|
|
167
|
+
|
|
168
|
+
ok, reason = _validate_patch(patch)
|
|
169
|
+
if not ok:
|
|
170
|
+
logger.warning("Patch rejected for %s: %s", event.fingerprint, reason)
|
|
171
|
+
return "skip", None, ""
|
|
172
|
+
|
|
173
|
+
patches_dir.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
patch_path = patches_dir / f"{event.fingerprint}.diff"
|
|
175
|
+
patch_path.write_text(patch, encoding="utf-8")
|
|
176
|
+
logger.info("Patch written to %s", patch_path)
|
|
177
|
+
return "patch", patch_path, marker
|
package/python/sentinel/main.py
CHANGED
|
@@ -541,10 +541,45 @@ async def _upgrade_check_loop(cfg_loader: ConfigLoader):
|
|
|
541
541
|
|
|
542
542
|
# ── Entry point ──────────────────────────────────────────────────────────────────────────────────
|
|
543
543
|
|
|
544
|
+
def _log_auth_status(cfg: SentinelConfig) -> None:
|
|
545
|
+
"""Log Claude auth configuration at startup and post to Slack if nothing is configured."""
|
|
546
|
+
has_api_key = bool(cfg.anthropic_api_key)
|
|
547
|
+
has_claude_bin = bool(shutil.which(cfg.claude_code_bin))
|
|
548
|
+
pro_for_tasks = cfg.claude_pro_for_tasks
|
|
549
|
+
|
|
550
|
+
if has_api_key and pro_for_tasks:
|
|
551
|
+
logger.info(
|
|
552
|
+
"Claude auth: API key ✓ (Boss) + Claude Pro preferred for Fix Engine/Ask Codebase. "
|
|
553
|
+
"Run `claude login` if not already authenticated."
|
|
554
|
+
)
|
|
555
|
+
elif has_api_key and not pro_for_tasks:
|
|
556
|
+
logger.info(
|
|
557
|
+
"Claude auth: API key ✓ (Boss + Fix Engine). "
|
|
558
|
+
"CLAUDE_PRO_FOR_TASKS=false — all tasks billed to API quota."
|
|
559
|
+
)
|
|
560
|
+
elif not has_api_key and has_claude_bin:
|
|
561
|
+
logger.warning(
|
|
562
|
+
"Claude auth: no ANTHROPIC_API_KEY — Boss will use CLI fallback (limited tools). "
|
|
563
|
+
"Fix Engine uses Claude Pro via `claude` CLI."
|
|
564
|
+
)
|
|
565
|
+
else:
|
|
566
|
+
msg = (
|
|
567
|
+
":warning: *Sentinel — no Claude authentication configured*\n"
|
|
568
|
+
"Sentinel needs at least one of:\n"
|
|
569
|
+
"• `ANTHROPIC_API_KEY` in `sentinel.properties` — full Boss tools, API billing\n"
|
|
570
|
+
"• Claude Pro OAuth: run `claude login` on the server — required for Fix Engine\n"
|
|
571
|
+
"See the auth section in your workspace `sentinel.properties` for guidance."
|
|
572
|
+
)
|
|
573
|
+
logger.error("Claude auth: NOTHING configured — Boss and Fix Engine will fail!")
|
|
574
|
+
from .notify import slack_alert
|
|
575
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel, msg)
|
|
576
|
+
|
|
577
|
+
|
|
544
578
|
async def run_loop(cfg_loader: ConfigLoader, store: StateStore):
|
|
545
579
|
interval = cfg_loader.sentinel.poll_interval_seconds
|
|
546
580
|
logger.info("Sentinel starting — poll interval: %ds, repos: %s",
|
|
547
581
|
interval, list(cfg_loader.repos.keys()))
|
|
582
|
+
_log_auth_status(cfg_loader.sentinel)
|
|
548
583
|
|
|
549
584
|
results = await _startup_checks(cfg_loader)
|
|
550
585
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
notify.py — Best-effort Slack alerts from any Sentinel module.
|
|
3
|
+
|
|
4
|
+
Uses the Slack Web API directly (no Bolt / Socket Mode required).
|
|
5
|
+
Calls never raise — failures are logged and silently dropped.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# ── Rate-limit / auth-failure detector ────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
_RATE_LIMIT_RE = re.compile(
|
|
18
|
+
r"rate.?limit|usage.?limit|too many requests|quota.?exceeded"
|
|
19
|
+
r"|overloaded|credit.?balance|billing|529"
|
|
20
|
+
r"|not.?authenticated|invalid.?api.?key|authentication.?fail"
|
|
21
|
+
r"|claude\.ai subscription|pro.?plan|login required",
|
|
22
|
+
re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_rate_limited(text: str) -> bool:
|
|
27
|
+
"""Return True if the text contains a rate-limit or auth-failure signal."""
|
|
28
|
+
return bool(_RATE_LIMIT_RE.search(text))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rate_limit_message(source: str, raw: str) -> str:
|
|
32
|
+
"""Produce a human-readable Slack alert for a rate-limit event."""
|
|
33
|
+
snippet = raw.strip()[:300].replace("\n", " ")
|
|
34
|
+
return (
|
|
35
|
+
f":warning: *Sentinel — Claude usage/auth problem ({source})*\n"
|
|
36
|
+
f"Claude returned an error that requires admin attention:\n"
|
|
37
|
+
f"```{snippet}```\n"
|
|
38
|
+
f"*What to check:*\n"
|
|
39
|
+
f"• API key: verify `ANTHROPIC_API_KEY` in `sentinel.properties` is valid and has credit\n"
|
|
40
|
+
f"• Claude Pro: run `claude login` on the server to refresh OAuth\n"
|
|
41
|
+
f"• Both: at least one auth method must be working\n"
|
|
42
|
+
f"Sentinel will retry on the next poll cycle."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Alert dispatcher ──────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def slack_alert(bot_token: str, channel: str, text: str) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Post a plain-text alert to a Slack channel.
|
|
51
|
+
Best-effort: logs on failure, never raises.
|
|
52
|
+
"""
|
|
53
|
+
if not bot_token or not channel:
|
|
54
|
+
logger.debug("slack_alert: no token/channel configured — logging only: %s", text[:120])
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
resp = requests.post(
|
|
58
|
+
"https://slack.com/api/chat.postMessage",
|
|
59
|
+
headers={
|
|
60
|
+
"Authorization": f"Bearer {bot_token}",
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
},
|
|
63
|
+
json={"channel": channel, "text": text},
|
|
64
|
+
timeout=10,
|
|
65
|
+
)
|
|
66
|
+
data = resp.json()
|
|
67
|
+
if not data.get("ok"):
|
|
68
|
+
logger.warning("slack_alert: Slack API error: %s", data.get("error"))
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
logger.warning("slack_alert: failed to post: %s", exc)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def alert_if_rate_limited(
|
|
74
|
+
bot_token: str,
|
|
75
|
+
channel: str,
|
|
76
|
+
source: str,
|
|
77
|
+
output: str,
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""
|
|
80
|
+
Check output for rate-limit / auth signals.
|
|
81
|
+
If found, post a Slack alert and return True.
|
|
82
|
+
"""
|
|
83
|
+
if not is_rate_limited(output):
|
|
84
|
+
return False
|
|
85
|
+
msg = rate_limit_message(source, output)
|
|
86
|
+
logger.error("Claude rate-limit/auth failure in %s: %s", source, output[:200])
|
|
87
|
+
slack_alert(bot_token, channel, msg)
|
|
88
|
+
return True
|
|
@@ -16,6 +16,8 @@ from datetime import datetime, timezone
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Optional
|
|
18
18
|
|
|
19
|
+
from .notify import alert_if_rate_limited, slack_alert, is_rate_limited
|
|
20
|
+
|
|
19
21
|
logger = logging.getLogger(__name__)
|
|
20
22
|
|
|
21
23
|
# ── System prompt ────────────────────────────────────────────────────────────
|
|
@@ -1098,7 +1100,8 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1098
1100
|
|
|
1099
1101
|
cfg = cfg_loader.sentinel
|
|
1100
1102
|
env = os.environ.copy()
|
|
1101
|
-
|
|
1103
|
+
# Only inject API key when Claude Pro is NOT preferred for heavy tasks
|
|
1104
|
+
if cfg.anthropic_api_key and not cfg.claude_pro_for_tasks:
|
|
1102
1105
|
env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
|
|
1103
1106
|
|
|
1104
1107
|
def _ask_one(repo_name, repo_cfg) -> dict:
|
|
@@ -1121,7 +1124,12 @@ async def _run_tool(name: str, inputs: dict, cfg_loader, store, slack_client=Non
|
|
|
1121
1124
|
output = (r.stdout or "").strip()
|
|
1122
1125
|
logger.info("Boss ask_codebase %s rc=%d len=%d", repo_name, r.returncode, len(output))
|
|
1123
1126
|
if r.returncode != 0 and not output:
|
|
1124
|
-
|
|
1127
|
+
raw_err = (r.stderr or "")
|
|
1128
|
+
alert_if_rate_limited(
|
|
1129
|
+
cfg.slack_bot_token, cfg.slack_channel,
|
|
1130
|
+
f"ask_codebase/{repo_name}", raw_err,
|
|
1131
|
+
)
|
|
1132
|
+
return {"repo": repo_name, "error": f"claude --print failed (rc={r.returncode}): {raw_err[:200]}"}
|
|
1125
1133
|
return {"repo": repo_name, "answer": output[:3000]}
|
|
1126
1134
|
except subprocess.TimeoutExpired:
|
|
1127
1135
|
return {"repo": repo_name, "error": "timed out after 180s"}
|
|
@@ -1412,8 +1420,13 @@ async def _handle_with_cli(
|
|
|
1412
1420
|
"Boss CLI call failed (rc=%d): stdout=%r stderr=%r",
|
|
1413
1421
|
result.returncode, output[:200], stderr[:200],
|
|
1414
1422
|
)
|
|
1423
|
+
raw_err = (result.stderr or "").strip()
|
|
1415
1424
|
if result.returncode != 0 and not output:
|
|
1416
|
-
|
|
1425
|
+
full_err = f"exit {result.returncode}: {raw_err[:300]}"
|
|
1426
|
+
cfg = cfg_loader.sentinel
|
|
1427
|
+
alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
|
|
1428
|
+
"sentinel_boss/cli", raw_err or full_err)
|
|
1429
|
+
return f":warning: `claude --print` failed ({full_err})", True
|
|
1417
1430
|
except Exception as e:
|
|
1418
1431
|
logger.error("Boss CLI call failed: %s", e)
|
|
1419
1432
|
return f":warning: Boss unavailable: {e}", True
|
|
@@ -1545,7 +1558,27 @@ async def handle_message(
|
|
|
1545
1558
|
is_done=True → session complete, release the Slack queue slot.
|
|
1546
1559
|
is_done=False → waiting for user follow-up, keep the slot.
|
|
1547
1560
|
"""
|
|
1548
|
-
|
|
1561
|
+
api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
1562
|
+
|
|
1563
|
+
# 1st priority: ANTHROPIC_API_KEY — full structured tools, cheap per-token for Boss queries
|
|
1564
|
+
if api_key:
|
|
1565
|
+
try:
|
|
1566
|
+
import anthropic # noqa: F401
|
|
1567
|
+
return await _handle_with_api(
|
|
1568
|
+
message, history, cfg_loader, store, slack_client=slack_client,
|
|
1569
|
+
user_name=user_name, user_id=user_id, attachments=attachments,
|
|
1570
|
+
)
|
|
1571
|
+
except Exception as api_err:
|
|
1572
|
+
err_str = str(api_err)
|
|
1573
|
+
# Detect rate-limit / auth failure and alert Slack before falling through
|
|
1574
|
+
cfg = cfg_loader.sentinel
|
|
1575
|
+
if is_rate_limited(err_str):
|
|
1576
|
+
from .notify import rate_limit_message
|
|
1577
|
+
alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
|
|
1578
|
+
"sentinel_boss/api", err_str)
|
|
1579
|
+
logger.warning("Boss: API key path failed (%s), trying CLI fallback", err_str[:80])
|
|
1580
|
+
|
|
1581
|
+
# 2nd priority: Claude Pro / OAuth via CLI (limited tools but no API key needed)
|
|
1549
1582
|
cli_reply, cli_done = await _handle_with_cli(
|
|
1550
1583
|
message, history, cfg_loader, store, slack_client=slack_client, user_name=user_name,
|
|
1551
1584
|
user_id=user_id, attachments=attachments,
|
|
@@ -1553,21 +1586,20 @@ async def handle_message(
|
|
|
1553
1586
|
if not cli_reply.startswith(":warning:"):
|
|
1554
1587
|
return cli_reply, cli_done
|
|
1555
1588
|
|
|
1556
|
-
#
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
":warning: `anthropic` package not installed. Run: `pip install anthropic`",
|
|
1562
|
-
True,
|
|
1563
|
-
)
|
|
1564
|
-
|
|
1565
|
-
api_key = cfg_loader.sentinel.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
|
1589
|
+
# Both paths failed — alert Slack and return error
|
|
1590
|
+
cfg = cfg_loader.sentinel
|
|
1591
|
+
err_output = cli_reply
|
|
1592
|
+
alert_if_rate_limited(cfg.slack_bot_token, cfg.slack_channel,
|
|
1593
|
+
"sentinel_boss/cli", err_output)
|
|
1566
1594
|
if not api_key:
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1595
|
+
# No auth at all configured
|
|
1596
|
+
no_auth_msg = (
|
|
1597
|
+
":warning: *Sentinel Boss — no Claude auth configured*\n"
|
|
1598
|
+
"Configure at least one of:\n"
|
|
1599
|
+
"• `ANTHROPIC_API_KEY` in `sentinel.properties` — full features\n"
|
|
1600
|
+
"• Claude Pro OAuth: run `claude login` on the server — required for fix_engine\n"
|
|
1601
|
+
"See: https://github.com/misterhuydo/Sentinel#authentication"
|
|
1602
|
+
)
|
|
1603
|
+
slack_alert(cfg.slack_bot_token, cfg.slack_channel, no_auth_msg)
|
|
1604
|
+
return ":warning: No Claude authentication configured. See Slack for details.", True
|
|
1605
|
+
return cli_reply, cli_done
|
|
@@ -22,8 +22,10 @@ REPORT_INTERVAL_HOURS=1
|
|
|
22
22
|
STATE_DB=./sentinel.db
|
|
23
23
|
WORKSPACE_DIR=./workspace
|
|
24
24
|
|
|
25
|
-
# Claude
|
|
25
|
+
# Claude authentication — see workspace sentinel.properties for full documentation.
|
|
26
|
+
# Override here only if this project needs different credentials than the workspace default.
|
|
26
27
|
# ANTHROPIC_API_KEY=sk-ant-...
|
|
28
|
+
# CLAUDE_PRO_FOR_TASKS=true
|
|
27
29
|
|
|
28
30
|
# Slack Bot (optional) — Sentinel Boss conversational interface
|
|
29
31
|
# Create a Slack App at api.slack.com, enable Socket Mode, add scopes:
|
|
@@ -23,6 +23,39 @@ LOG_RETENTION_HOURS=48
|
|
|
23
23
|
# Claude Code binary path
|
|
24
24
|
CLAUDE_CODE_BIN=claude
|
|
25
25
|
|
|
26
|
+
# ── Claude authentication ─────────────────────────────────────────────────────
|
|
27
|
+
#
|
|
28
|
+
# Sentinel uses Claude for two very different workloads:
|
|
29
|
+
#
|
|
30
|
+
# • Sentinel Boss — conversational AI in Slack (structured tools, many short queries)
|
|
31
|
+
# • Fix Engine — autonomous code repair (long context, heavy token usage)
|
|
32
|
+
#
|
|
33
|
+
# Authentication options:
|
|
34
|
+
#
|
|
35
|
+
# A) ANTHROPIC_API_KEY only ← simplest setup
|
|
36
|
+
# Boss: full structured tools, all features ✅
|
|
37
|
+
# Fix Engine: billed per token against your API quota ⚠️ (can be expensive)
|
|
38
|
+
#
|
|
39
|
+
# B) Claude Pro / OAuth only ← run `claude login` on the server
|
|
40
|
+
# Boss: limited tools (CLI-based, no native image support) ⚠️
|
|
41
|
+
# Fix Engine: uses your Claude Pro subscription ✅ (no per-token cost)
|
|
42
|
+
#
|
|
43
|
+
# C) Both API key + Claude Pro ← RECOMMENDED for production
|
|
44
|
+
# Boss: full structured tools ✅
|
|
45
|
+
# Fix Engine: uses Claude Pro subscription ✅ (set CLAUDE_PRO_FOR_TASKS=true)
|
|
46
|
+
#
|
|
47
|
+
# At least one must be configured. Sentinel will alert Slack if neither is working.
|
|
48
|
+
#
|
|
49
|
+
# To set up Claude Pro OAuth: run `claude login` on the server once.
|
|
50
|
+
# To renew an expired session: run `claude login` again — Sentinel will detect the failure
|
|
51
|
+
# and post a Slack alert if it can't proceed.
|
|
52
|
+
#
|
|
53
|
+
# ANTHROPIC_API_KEY=sk-ant-...
|
|
54
|
+
#
|
|
55
|
+
# When true (default): fix_engine / ask_codebase use `claude` CLI (Claude Pro billing).
|
|
56
|
+
# Set to false if you only have an API key and no Claude Pro subscription.
|
|
57
|
+
CLAUDE_PRO_FOR_TASKS=true
|
|
58
|
+
|
|
26
59
|
# Auto-upgrade: check npm for a newer @misterhuydo/sentinel every N hours and restart
|
|
27
60
|
AUTO_UPGRADE=true
|
|
28
61
|
UPGRADE_CHECK_HOURS=6
|
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
const fs = require('fs-extra');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const os = require('os');
|
|
5
|
-
const { execSync, spawnSync } = require('child_process');
|
|
6
|
-
const prompts = require('prompts');
|
|
7
|
-
const chalk = require('chalk');
|
|
8
|
-
const { generateProjectScripts, generateWorkspaceScripts, writeExampleProject } = require('./generate');
|
|
9
|
-
const ok = msg => console.log(chalk.green(' ✔'), msg);
|
|
10
|
-
const info = msg => console.log(chalk.cyan(' →'), msg);
|
|
11
|
-
const warn = msg => console.log(chalk.yellow(' ⚠'), msg);
|
|
12
|
-
const step = msg => console.log('\n' + chalk.bold.white(msg));
|
|
13
|
-
module.exports = async function init() {
|
|
14
|
-
const defaultWorkspace = path.join(os.homedir(), 'sentinel');
|
|
15
|
-
const existing = readExistingConfig(defaultWorkspace);
|
|
16
|
-
if (Object.keys(existing).length) {
|
|
17
|
-
console.log(chalk.cyan('\n → Existing workspace config found — showing current values as defaults\n'));
|
|
18
|
-
}
|
|
19
|
-
const answers = await prompts([
|
|
20
|
-
{
|
|
21
|
-
type: 'text',
|
|
22
|
-
name: 'workspace',
|
|
23
|
-
message: 'Workspace directory (each project lives here as a subdirectory)',
|
|
24
|
-
initial: path.join(os.homedir(), 'sentinel'),
|
|
25
|
-
format: v => v.replace(/^~/, os.homedir()),
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
type: 'select',
|
|
29
|
-
name: 'authMode',
|
|
30
|
-
message: 'How will Claude Code authenticate?',
|
|
31
|
-
choices: [
|
|
32
|
-
{ title: 'API key (Anthropic API account — recommended for servers)', value: 'apikey' },
|
|
33
|
-
{ title: 'Claude Pro / OAuth (will give you a URL to open in any browser)', value: 'oauth' },
|
|
34
|
-
{ title: 'Skip (I will configure this later)', value: 'skip' },
|
|
35
|
-
],
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
type: prev => prev === 'apikey' ? 'password' : null,
|
|
39
|
-
name: 'anthropicKey',
|
|
40
|
-
message: 'Anthropic API key (sk-ant-...)',
|
|
41
|
-
validate: v => v.startsWith('sk-ant-') ? true : 'Key should start with sk-ant-',
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
type: 'confirm',
|
|
45
|
-
name: 'example',
|
|
46
|
-
message: 'Create an example project to show how to configure?',
|
|
47
|
-
initial: true,
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
type: 'confirm',
|
|
51
|
-
name: 'systemd',
|
|
52
|
-
message: 'Set up systemd service for auto-start on reboot?',
|
|
53
|
-
initial: process.platform === 'linux',
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
type: 'text',
|
|
57
|
-
name: 'smtpUser',
|
|
58
|
-
message: 'SMTP sender address',
|
|
59
|
-
initial: existing.SMTP_USER || 'sentinel@yourdomain.com',
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
type: prev => prev ? 'password' : null,
|
|
63
|
-
name: 'smtpPassword',
|
|
64
|
-
message: existing.SMTP_PASSWORD ? 'SMTP password / app password (press Enter to keep current)' : 'SMTP password / app password',
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
type: prev => prev ? 'text' : null,
|
|
68
|
-
name: 'smtpHost',
|
|
69
|
-
message: 'SMTP host',
|
|
70
|
-
initial: existing.SMTP_HOST || 'smtp.gmail.com',
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
type: 'confirm',
|
|
74
|
-
name: 'setupSlack',
|
|
75
|
-
message: 'Set up Slack Bot (Sentinel Boss — conversational AI interface)?',
|
|
76
|
-
initial: !!(existing.SLACK_BOT_TOKEN),
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
type: prev => prev ? 'password' : null,
|
|
80
|
-
name: 'slackBotToken',
|
|
81
|
-
message: existing.SLACK_BOT_TOKEN ? 'Slack Bot Token (press Enter to keep current)' : 'Slack Bot Token (xoxb-...)',
|
|
82
|
-
validate: v => !v || v.startsWith('xoxb-') ? true : 'Should start with xoxb-',
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
type: (_, { setupSlack }) => setupSlack ? 'password' : null,
|
|
86
|
-
name: 'slackAppToken',
|
|
87
|
-
message: existing.SLACK_APP_TOKEN ? 'Slack App-Level Token (press Enter to keep current)' : 'Slack App-Level Token (xapp-...)',
|
|
88
|
-
validate: v => !v || v.startsWith('xapp-') ? true : 'Should start with xapp-',
|
|
89
|
-
},
|
|
90
|
-
], { onCancel: () => process.exit(0) });
|
|
91
|
-
const { workspace, authMode, anthropicKey, example, systemd, smtpUser, smtpPassword, smtpHost, setupSlack, slackBotToken, slackAppToken } = answers;
|
|
92
|
-
const effectiveSmtpPassword = smtpPassword || existing.SMTP_PASSWORD || '';
|
|
93
|
-
const effectiveSlackBotToken = slackBotToken || existing.SLACK_BOT_TOKEN || '';
|
|
94
|
-
const effectiveSlackAppToken = slackAppToken || existing.SLACK_APP_TOKEN || '';
|
|
95
|
-
const codeDir = path.join(workspace, 'code');
|
|
96
|
-
step('Checking Python…');
|
|
97
|
-
const python = findPython();
|
|
98
|
-
if (!python) {
|
|
99
|
-
console.error(chalk.red(' ✖ python3 not found. Install it first:'));
|
|
100
|
-
console.error(' sudo apt-get install -y python3 python3-pip python3-venv');
|
|
101
|
-
process.exit(1);
|
|
102
|
-
}
|
|
103
|
-
ok(`Python: ${run(python, ['--version']).trim()}`);
|
|
104
|
-
step('Installing Sentinel code…');
|
|
105
|
-
const bundledPython = path.join(__dirname, '..', 'python');
|
|
106
|
-
if (!fs.existsSync(bundledPython)) {
|
|
107
|
-
console.error(chalk.red(' ✖ Bundled Python source not found (run npm publish from the Sentinel repo)'));
|
|
108
|
-
process.exit(1);
|
|
109
|
-
}
|
|
110
|
-
fs.ensureDirSync(codeDir);
|
|
111
|
-
fs.copySync(bundledPython, codeDir, { overwrite: true });
|
|
112
|
-
ok(`Sentinel code → ${codeDir}`);
|
|
113
|
-
step('Setting up Python environment…');
|
|
114
|
-
const venv = path.join(codeDir, '.venv');
|
|
115
|
-
if (!fs.existsSync(venv)) {
|
|
116
|
-
info('Creating virtual environment…');
|
|
117
|
-
runLive(python, ['-m', 'venv', venv]);
|
|
118
|
-
}
|
|
119
|
-
const pip = path.join(venv, 'bin', 'pip3');
|
|
120
|
-
const pythonBin = path.join(venv, 'bin', 'python3');
|
|
121
|
-
info('Installing Python packages…');
|
|
122
|
-
runLive(pip, ['install', '--quiet', '--upgrade', 'pip']);
|
|
123
|
-
runLive(pip, ['install', '--quiet', '-r', path.join(codeDir, 'requirements.txt')]);
|
|
124
|
-
ok('Python packages installed');
|
|
125
|
-
step('Installing Node tools…');
|
|
126
|
-
installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
|
|
127
|
-
installNpmGlobal('@anthropic-ai/claude-code', 'claude');
|
|
128
|
-
info('Hooking Cairn MCP into Claude Code…');
|
|
129
|
-
runLive('cairn', ['install']);
|
|
130
|
-
step('Claude Code authentication…');
|
|
131
|
-
if (authMode === 'apikey' && anthropicKey) {
|
|
132
|
-
ok('API key will be written to each project\'s sentinel.properties');
|
|
133
|
-
} else if (authMode === 'oauth') {
|
|
134
|
-
info('OAuth selected — start.sh will prompt for login if not yet authenticated');
|
|
135
|
-
} else {
|
|
136
|
-
info('Skipping auth — start.sh will prompt for login if needed');
|
|
137
|
-
}
|
|
138
|
-
if (effectiveSlackBotToken && effectiveSlackAppToken) {
|
|
139
|
-
step('Slack Bot (Sentinel Boss)…');
|
|
140
|
-
ok('Tokens will be written to workspace sentinel.properties');
|
|
141
|
-
info('Sentinel Boss starts automatically when the project starts');
|
|
142
|
-
} else if (setupSlack) {
|
|
143
|
-
warn('Slack tokens not provided — add them to config/sentinel.properties later');
|
|
144
|
-
}
|
|
145
|
-
step('Creating workspace…');
|
|
146
|
-
fs.ensureDirSync(workspace);
|
|
147
|
-
ok(`Workspace: ${workspace}`);
|
|
148
|
-
if (example) {
|
|
149
|
-
step('Creating example project…');
|
|
150
|
-
const exampleDir = path.join(workspace, 'my-project');
|
|
151
|
-
writeExampleProject(exampleDir, codeDir, pythonBin, anthropicKey || '', { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken });
|
|
152
|
-
ok(`Example project: ${exampleDir}`);
|
|
153
|
-
}
|
|
154
|
-
step('Generating scripts…');
|
|
155
|
-
generateWorkspaceScripts(workspace, { host: smtpHost, user: smtpUser, password: effectiveSmtpPassword }, { botToken: effectiveSlackBotToken, appToken: effectiveSlackAppToken });
|
|
156
|
-
ok(`${workspace}/startAll.sh`);
|
|
157
|
-
ok(`${workspace}/stopAll.sh`);
|
|
158
|
-
if (systemd) {
|
|
159
|
-
step('Setting up systemd…');
|
|
160
|
-
setupSystemd(workspace);
|
|
161
|
-
}
|
|
162
|
-
console.log('\n' + chalk.bold.green('══ Done! ══') + '\n');
|
|
163
|
-
console.log(`${chalk.bold('Next steps:')}`);
|
|
164
|
-
if (example) {
|
|
165
|
-
console.log(`
|
|
166
|
-
1. Configure your first project:
|
|
167
|
-
${chalk.cyan(`${workspace}/my-project/config/sentinel.properties`)}
|
|
168
|
-
${chalk.cyan(`${workspace}/my-project/config/log-configs/`)}
|
|
169
|
-
${chalk.cyan(`${workspace}/my-project/config/repo-configs/`)}
|
|
170
|
-
2. Start all projects (Sentinel clones repos, indexes, and monitors automatically):
|
|
171
|
-
${chalk.cyan(`${workspace}/startAll.sh`)}
|
|
172
|
-
3. Stop all projects:
|
|
173
|
-
${chalk.cyan(`${workspace}/stopAll.sh`)}
|
|
174
|
-
`);
|
|
175
|
-
}
|
|
176
|
-
if (systemd) {
|
|
177
|
-
console.log(` Auto-start is enabled. To manage:
|
|
178
|
-
${chalk.cyan('sudo systemctl start sentinel')}
|
|
179
|
-
${chalk.cyan('sudo systemctl status sentinel')}
|
|
180
|
-
${chalk.cyan('journalctl -u sentinel -f')}
|
|
181
|
-
`);
|
|
182
|
-
}
|
|
183
|
-
console.log(` Add another project anytime:
|
|
184
|
-
${chalk.cyan('sentinel add <project-name>')}
|
|
185
|
-
`);
|
|
186
|
-
};
|
|
187
|
-
function readExistingConfig(workspace) {
|
|
188
|
-
const result = {};
|
|
189
|
-
_parsePropsInto(path.join(workspace, 'sentinel.properties'), result);
|
|
190
|
-
if (!result.SLACK_BOT_TOKEN || !result.SLACK_APP_TOKEN) {
|
|
191
|
-
try {
|
|
192
|
-
for (const entry of fs.readdirSync(workspace)) {
|
|
193
|
-
const p = path.join(workspace, entry, 'config', 'sentinel.properties');
|
|
194
|
-
const proj = {};
|
|
195
|
-
_parsePropsInto(p, proj);
|
|
196
|
-
if (!result.SLACK_BOT_TOKEN && proj.SLACK_BOT_TOKEN) result.SLACK_BOT_TOKEN = proj.SLACK_BOT_TOKEN;
|
|
197
|
-
if (!result.SLACK_APP_TOKEN && proj.SLACK_APP_TOKEN) result.SLACK_APP_TOKEN = proj.SLACK_APP_TOKEN;
|
|
198
|
-
if (result.SLACK_BOT_TOKEN && result.SLACK_APP_TOKEN) break;
|
|
199
|
-
}
|
|
200
|
-
} catch (_) {}
|
|
201
|
-
}
|
|
202
|
-
return result;
|
|
203
|
-
}
|
|
204
|
-
function _parsePropsInto(propsPath, result) {
|
|
205
|
-
if (!fs.existsSync(propsPath)) return;
|
|
206
|
-
try {
|
|
207
|
-
const lines = fs.readFileSync(propsPath, 'utf8').split(/\r?\n/);
|
|
208
|
-
for (const raw of lines) {
|
|
209
|
-
const line = raw.trim();
|
|
210
|
-
if (!line || line.startsWith('#')) continue;
|
|
211
|
-
const idx = line.indexOf('=');
|
|
212
|
-
if (idx === -1) continue;
|
|
213
|
-
result[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
214
|
-
}
|
|
215
|
-
} catch (_) {}
|
|
216
|
-
}
|
|
217
|
-
function findPython() {
|
|
218
|
-
for (const bin of ['python3', 'python']) {
|
|
219
|
-
try {
|
|
220
|
-
execSync(`${bin} --version`, { stdio: 'pipe' });
|
|
221
|
-
return bin;
|
|
222
|
-
} catch (_) {}
|
|
223
|
-
}
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
function run(bin, args) {
|
|
227
|
-
const r = spawnSync(bin, args, { encoding: 'utf8' });
|
|
228
|
-
return (r.stdout || '') + (r.stderr || '');
|
|
229
|
-
}
|
|
230
|
-
function runLive(bin, args) {
|
|
231
|
-
const r = spawnSync(bin, args, { stdio: 'inherit' });
|
|
232
|
-
if (r.status !== 0) {
|
|
233
|
-
console.error(chalk.red(` ✖ Command failed: ${bin} ${args.join(' ')}`));
|
|
234
|
-
process.exit(1);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
function installNpmGlobal(pkg, checkBin) {
|
|
238
|
-
try {
|
|
239
|
-
execSync(`${checkBin} --version`, { stdio: 'pipe' });
|
|
240
|
-
ok(`${pkg} already installed`);
|
|
241
|
-
} catch (_) {
|
|
242
|
-
info(`Installing ${pkg}…`);
|
|
243
|
-
runLive('npm', ['install', '-g', pkg]);
|
|
244
|
-
ok(`${pkg} installed`);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
function setupSystemd(workspace) {
|
|
248
|
-
const user = os.userInfo().username;
|
|
249
|
-
const svc = `/etc/systemd/system/sentinel.service`;
|
|
250
|
-
const content = `[Unit]
|
|
251
|
-
Description=Sentinel — Autonomous DevOps Agent
|
|
252
|
-
After=network-online.target
|
|
253
|
-
Wants=network-online.target
|
|
254
|
-
[Service]
|
|
255
|
-
Type=forking
|
|
256
|
-
User=${user}
|
|
257
|
-
WorkingDirectory=${workspace}
|
|
258
|
-
ExecStart=${workspace}/startAll.sh
|
|
259
|
-
ExecStop=${workspace}/stopAll.sh
|
|
260
|
-
Restart=on-failure
|
|
261
|
-
RestartSec=10
|
|
262
|
-
[Install]
|
|
263
|
-
WantedBy=multi-user.target
|
|
264
|
-
`;
|
|
265
|
-
try {
|
|
266
|
-
fs.writeFileSync('/tmp/sentinel.service', content);
|
|
267
|
-
execSync(`sudo mv /tmp/sentinel.service ${svc}`);
|
|
268
|
-
execSync('sudo systemctl daemon-reload');
|
|
269
|
-
execSync('sudo systemctl enable sentinel');
|
|
270
|
-
ok('sentinel.service enabled');
|
|
271
|
-
} catch (e) {
|
|
272
|
-
warn(`Could not write systemd service (need sudo): ${e.message}`);
|
|
273
|
-
warn(`Manually create ${svc} to auto-start on reboot`);
|
|
274
|
-
}
|
|
275
|
-
}
|