@claudemini/ses-cli 1.4.3
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/README.md +465 -0
- package/bin/ses.js +85 -0
- package/lib/agent-review.js +722 -0
- package/lib/checkpoint.js +320 -0
- package/lib/checkpoints.js +54 -0
- package/lib/clean.js +45 -0
- package/lib/commit.js +60 -0
- package/lib/config.js +28 -0
- package/lib/disable.js +152 -0
- package/lib/doctor.js +307 -0
- package/lib/enable.js +294 -0
- package/lib/explain.js +212 -0
- package/lib/extract.js +265 -0
- package/lib/git-shadow.js +136 -0
- package/lib/init.js +83 -0
- package/lib/list.js +62 -0
- package/lib/log.js +77 -0
- package/lib/prompts.js +125 -0
- package/lib/query.js +110 -0
- package/lib/redact.js +170 -0
- package/lib/report.js +296 -0
- package/lib/reset.js +122 -0
- package/lib/resume.js +224 -0
- package/lib/review-common.js +100 -0
- package/lib/review.js +652 -0
- package/lib/rewind.js +198 -0
- package/lib/session.js +225 -0
- package/lib/shadow.js +51 -0
- package/lib/status.js +198 -0
- package/lib/summarize.js +315 -0
- package/lib/view.js +50 -0
- package/lib/webhook.js +224 -0
- package/package.json +41 -0
package/lib/summarize.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AI Summarization module
|
|
5
|
+
* Automatically generates AI-powered summaries of sessions using LLM APIs
|
|
6
|
+
* Supports OpenAI and Anthropic APIs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { getProjectRoot } from './config.js';
|
|
12
|
+
import { SUMMARY_SYSTEM_PROMPT, buildSummarizePrompt } from './prompts.js';
|
|
13
|
+
|
|
14
|
+
// Default configuration
|
|
15
|
+
const DEFAULT_CONFIG = {
|
|
16
|
+
provider: 'openai', // or 'anthropic'
|
|
17
|
+
model: 'gpt-4o-mini',
|
|
18
|
+
max_tokens: 1000,
|
|
19
|
+
temperature: 0.7,
|
|
20
|
+
openai_base_url: 'https://api.openai.com/v1',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get API configuration from environment or config file
|
|
25
|
+
*/
|
|
26
|
+
export function getApiConfig(projectRoot) {
|
|
27
|
+
const config = { ...DEFAULT_CONFIG };
|
|
28
|
+
|
|
29
|
+
// Check for project config
|
|
30
|
+
const configFile = join(projectRoot, '.ses-logs', 'config.json');
|
|
31
|
+
if (existsSync(configFile)) {
|
|
32
|
+
try {
|
|
33
|
+
const fileConfig = JSON.parse(readFileSync(configFile, 'utf-8'));
|
|
34
|
+
Object.assign(config, fileConfig);
|
|
35
|
+
} catch {
|
|
36
|
+
// Use defaults
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Environment variables override file config
|
|
41
|
+
if (process.env.OPENAI_API_KEY) {
|
|
42
|
+
config.provider = 'openai';
|
|
43
|
+
config.api_key = process.env.OPENAI_API_KEY;
|
|
44
|
+
} else if (process.env.ANTHROPIC_API_KEY) {
|
|
45
|
+
config.provider = 'anthropic';
|
|
46
|
+
config.api_key = process.env.ANTHROPIC_API_KEY;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (process.env.OPENAI_BASE_URL) {
|
|
50
|
+
config.openai_base_url = process.env.OPENAI_BASE_URL;
|
|
51
|
+
}
|
|
52
|
+
if (process.env.OPENAI_ENDPOINT) {
|
|
53
|
+
config.openai_endpoint = process.env.OPENAI_ENDPOINT;
|
|
54
|
+
}
|
|
55
|
+
// Universal override for all providers.
|
|
56
|
+
if (process.env.AI_MODEL) {
|
|
57
|
+
config.model = process.env.AI_MODEL;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Provider-specific overrides take precedence when applicable.
|
|
61
|
+
if (config.provider === 'openai' && process.env.OPENAI_MODEL) {
|
|
62
|
+
config.model = process.env.OPENAI_MODEL;
|
|
63
|
+
}
|
|
64
|
+
if (config.provider === 'anthropic' && process.env.ANTHROPIC_MODEL) {
|
|
65
|
+
config.model = process.env.ANTHROPIC_MODEL;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return config;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract relevant context from session for summarization
|
|
73
|
+
*/
|
|
74
|
+
function extractContext(sessionDir) {
|
|
75
|
+
const context = {
|
|
76
|
+
prompts: [],
|
|
77
|
+
changes: [],
|
|
78
|
+
tools: {},
|
|
79
|
+
errors: [],
|
|
80
|
+
summary: null,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Read summary.json
|
|
84
|
+
const summaryFile = join(sessionDir, 'summary.json');
|
|
85
|
+
if (existsSync(summaryFile)) {
|
|
86
|
+
try {
|
|
87
|
+
const summary = JSON.parse(readFileSync(summaryFile, 'utf-8'));
|
|
88
|
+
context.summary = summary;
|
|
89
|
+
context.prompts = summary.prompts || [];
|
|
90
|
+
context.tools = summary.activity?.tools || {};
|
|
91
|
+
context.errors = summary.activity?.errors || [];
|
|
92
|
+
context.changes = summary.changes?.files || [];
|
|
93
|
+
} catch {
|
|
94
|
+
// Best effort
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Read prompts.txt
|
|
99
|
+
const promptsFile = join(sessionDir, 'prompts.txt');
|
|
100
|
+
if (existsSync(promptsFile)) {
|
|
101
|
+
try {
|
|
102
|
+
context.prompts_text = readFileSync(promptsFile, 'utf-8').slice(0, 3000);
|
|
103
|
+
} catch {
|
|
104
|
+
// Best effort
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Read context.md
|
|
109
|
+
const contextFile = join(sessionDir, 'context.md');
|
|
110
|
+
if (existsSync(contextFile)) {
|
|
111
|
+
try {
|
|
112
|
+
context.context_md = readFileSync(contextFile, 'utf-8').slice(0, 2000);
|
|
113
|
+
} catch {
|
|
114
|
+
// Best effort
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return context;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Call OpenAI API
|
|
123
|
+
*/
|
|
124
|
+
function resolveOpenAIEndpoint(config) {
|
|
125
|
+
const explicitEndpoint = (config.openai_endpoint || '').trim();
|
|
126
|
+
if (explicitEndpoint) {
|
|
127
|
+
return explicitEndpoint;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const baseUrl = String(config.openai_base_url || DEFAULT_CONFIG.openai_base_url).trim().replace(/\/+$/, '');
|
|
131
|
+
if (baseUrl.endsWith('/chat/completions')) {
|
|
132
|
+
return baseUrl;
|
|
133
|
+
}
|
|
134
|
+
return `${baseUrl}/chat/completions`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function callOpenAI(apiKey, endpoint, model, prompt, maxTokens, temperature) {
|
|
138
|
+
const response = await fetch(endpoint, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: {
|
|
141
|
+
'Content-Type': 'application/json',
|
|
142
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
143
|
+
},
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
model,
|
|
146
|
+
messages: [
|
|
147
|
+
{ role: 'system', content: SUMMARY_SYSTEM_PROMPT },
|
|
148
|
+
{ role: 'user', content: prompt }
|
|
149
|
+
],
|
|
150
|
+
max_tokens: maxTokens,
|
|
151
|
+
temperature,
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
const error = await response.text();
|
|
157
|
+
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const data = await response.json();
|
|
161
|
+
const msg = data.choices[0].message;
|
|
162
|
+
// Some OpenAI-compatible providers return reasoning output in `reasoning_content`.
|
|
163
|
+
return msg.content || msg.reasoning_content || '';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Call Anthropic API
|
|
168
|
+
*/
|
|
169
|
+
async function callAnthropic(apiKey, model, prompt, maxTokens, temperature) {
|
|
170
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: {
|
|
173
|
+
'Content-Type': 'application/json',
|
|
174
|
+
'x-api-key': apiKey,
|
|
175
|
+
'anthropic-version': '2023-06-01',
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify({
|
|
178
|
+
model,
|
|
179
|
+
max_tokens: maxTokens,
|
|
180
|
+
temperature,
|
|
181
|
+
messages: [
|
|
182
|
+
{ role: 'user', content: prompt }
|
|
183
|
+
],
|
|
184
|
+
}),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
const error = await response.text();
|
|
189
|
+
throw new Error(`Anthropic API error: ${response.status} - ${error}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const data = await response.json();
|
|
193
|
+
return data.content[0].text;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Generate AI summary for a session
|
|
198
|
+
*/
|
|
199
|
+
export async function summarizeSession(projectRoot, sessionId, sessionDir) {
|
|
200
|
+
const config = getApiConfig(projectRoot);
|
|
201
|
+
|
|
202
|
+
if (!config.api_key) {
|
|
203
|
+
return {
|
|
204
|
+
success: false,
|
|
205
|
+
reason: 'No API key configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY environment variable.'
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Extract context from session
|
|
210
|
+
const context = extractContext(sessionDir);
|
|
211
|
+
|
|
212
|
+
// Build prompt
|
|
213
|
+
const prompt = buildSummarizePrompt(context);
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
let summary;
|
|
217
|
+
|
|
218
|
+
if (config.provider === 'anthropic') {
|
|
219
|
+
summary = await callAnthropic(
|
|
220
|
+
config.api_key,
|
|
221
|
+
config.model || 'claude-3-haiku-20240307',
|
|
222
|
+
prompt,
|
|
223
|
+
config.max_tokens,
|
|
224
|
+
config.temperature
|
|
225
|
+
);
|
|
226
|
+
} else {
|
|
227
|
+
const openaiEndpoint = resolveOpenAIEndpoint(config);
|
|
228
|
+
summary = await callOpenAI(
|
|
229
|
+
config.api_key,
|
|
230
|
+
openaiEndpoint,
|
|
231
|
+
config.model || 'gpt-4o-mini',
|
|
232
|
+
prompt,
|
|
233
|
+
config.max_tokens,
|
|
234
|
+
config.temperature
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Save summary
|
|
239
|
+
const aiSummaryFile = join(sessionDir, 'ai-summary.md');
|
|
240
|
+
writeFileSync(aiSummaryFile, summary);
|
|
241
|
+
|
|
242
|
+
// Update state
|
|
243
|
+
const stateFile = join(sessionDir, 'state.json');
|
|
244
|
+
if (existsSync(stateFile)) {
|
|
245
|
+
try {
|
|
246
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
247
|
+
state.ai_summary = {
|
|
248
|
+
provider: config.provider,
|
|
249
|
+
model: config.model,
|
|
250
|
+
generated_at: new Date().toISOString(),
|
|
251
|
+
};
|
|
252
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
253
|
+
} catch {
|
|
254
|
+
// Best effort
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
success: true,
|
|
260
|
+
summary,
|
|
261
|
+
provider: config.provider,
|
|
262
|
+
model: config.model,
|
|
263
|
+
};
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return {
|
|
266
|
+
success: false,
|
|
267
|
+
reason: error.message
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* CLI command for manual summarization
|
|
274
|
+
*/
|
|
275
|
+
export default async function summarize(args) {
|
|
276
|
+
const projectRoot = getProjectRoot();
|
|
277
|
+
const sessionId = args[0];
|
|
278
|
+
|
|
279
|
+
if (!sessionId) {
|
|
280
|
+
console.log('Usage: ses summarize <session-id>');
|
|
281
|
+
console.log('\nEnvironment variables:');
|
|
282
|
+
console.log(' OPENAI_API_KEY # Use OpenAI for summarization');
|
|
283
|
+
console.log(' OPENAI_BASE_URL # OpenAI-compatible base URL (e.g. https://api.openai.com/v1)');
|
|
284
|
+
console.log(' OPENAI_ENDPOINT # Full OpenAI-compatible endpoint (overrides OPENAI_BASE_URL)');
|
|
285
|
+
console.log(' AI_MODEL # Universal model override (applies to all providers)');
|
|
286
|
+
console.log(' OPENAI_MODEL # OpenAI-only model override');
|
|
287
|
+
console.log(' ANTHROPIC_MODEL # Anthropic-only model override');
|
|
288
|
+
console.log(' ANTHROPIC_API_KEY # Use Anthropic for summarization');
|
|
289
|
+
console.log('\nConfiguration (.ses-logs/config.json):');
|
|
290
|
+
console.log(` {"provider": "openai", "model": "gpt-4o-mini", "openai_base_url": "https://api.openai.com/v1"}`);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const sessionDir = join(projectRoot, '.ses-logs', sessionId);
|
|
295
|
+
|
|
296
|
+
if (!existsSync(sessionDir)) {
|
|
297
|
+
console.error(`Session not found: ${sessionId}`);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
console.log(`🤖 Generating AI summary for session: ${sessionId}\n`);
|
|
302
|
+
|
|
303
|
+
const result = await summarizeSession(projectRoot, sessionId, sessionDir);
|
|
304
|
+
|
|
305
|
+
if (result.success) {
|
|
306
|
+
console.log('✅ AI Summary generated!\n');
|
|
307
|
+
console.log(result.summary);
|
|
308
|
+
console.log(`\n---`);
|
|
309
|
+
console.log(`Provider: ${result.provider}`);
|
|
310
|
+
console.log(`Model: ${result.model}`);
|
|
311
|
+
} else {
|
|
312
|
+
console.error('❌ Failed to generate summary:', result.reason);
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
}
|
package/lib/view.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { getProjectRoot, getLogDir } from './config.js';
|
|
6
|
+
|
|
7
|
+
export default async function view(args) {
|
|
8
|
+
const sessionId = args[0];
|
|
9
|
+
if (!sessionId) {
|
|
10
|
+
console.error('Usage: ses view <session-id>');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const logDir = getLogDir(getProjectRoot());
|
|
15
|
+
const sessionDir = join(logDir, sessionId);
|
|
16
|
+
if (!existsSync(sessionDir)) {
|
|
17
|
+
console.error(`Session not found: ${sessionId}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Show summary.txt (contains semantic report)
|
|
22
|
+
const summaryFile = join(sessionDir, 'summary.txt');
|
|
23
|
+
if (existsSync(summaryFile)) {
|
|
24
|
+
console.log(readFileSync(summaryFile, 'utf-8'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Show summary.json v2 data if --json flag
|
|
28
|
+
if (args.includes('--json')) {
|
|
29
|
+
const jsonFile = join(sessionDir, 'summary.json');
|
|
30
|
+
if (existsSync(jsonFile)) {
|
|
31
|
+
console.log('\n--- summary.json ---');
|
|
32
|
+
console.log(readFileSync(jsonFile, 'utf-8'));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Show shadow branch info
|
|
37
|
+
const stateFile = join(sessionDir, 'state.json');
|
|
38
|
+
if (existsSync(stateFile)) {
|
|
39
|
+
try {
|
|
40
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
|
|
41
|
+
if (state.shadow_branch) {
|
|
42
|
+
console.log(`\nShadow branch: ${state.shadow_branch}`);
|
|
43
|
+
console.log(` git log ${state.shadow_branch} --oneline`);
|
|
44
|
+
console.log(` git show ${state.shadow_branch}:.ses-logs/${sessionId}/summary.json`);
|
|
45
|
+
}
|
|
46
|
+
} catch { /* ignore */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log(`\nFull logs: ${sessionDir}`);
|
|
50
|
+
}
|
package/lib/webhook.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook support for ses-cli.
|
|
3
|
+
* Fire-and-forget notifications for session.ended and review.completed events.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, existsSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { createHmac } from 'crypto';
|
|
9
|
+
import { request as httpsRequest } from 'https';
|
|
10
|
+
import { request as httpRequest } from 'http';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load webhook configuration from .ses-logs/config.json + environment variables.
|
|
14
|
+
* Priority (highest wins): process.env > config.json env > config.json webhooks
|
|
15
|
+
*/
|
|
16
|
+
export function loadWebhookConfig(projectRoot) {
|
|
17
|
+
let fileConfig = {};
|
|
18
|
+
let configEnv = {};
|
|
19
|
+
const configPath = join(projectRoot, '.ses-logs', 'config.json');
|
|
20
|
+
if (existsSync(configPath)) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
23
|
+
fileConfig = raw.webhooks || {};
|
|
24
|
+
if (raw.env && typeof raw.env === 'object') {
|
|
25
|
+
configEnv = raw.env;
|
|
26
|
+
}
|
|
27
|
+
} catch { /* ignore malformed config */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Resolve: process.env > config.json env > config.json webhooks field
|
|
31
|
+
const env = (key) => {
|
|
32
|
+
if (key in process.env) return process.env[key];
|
|
33
|
+
if (key in configEnv) return configEnv[key];
|
|
34
|
+
return '';
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const url = env('SES_WEBHOOK_URL') || fileConfig.url;
|
|
38
|
+
if (!url) return null;
|
|
39
|
+
|
|
40
|
+
const envEvents = env('SES_WEBHOOK_EVENTS');
|
|
41
|
+
const events = envEvents
|
|
42
|
+
? envEvents.split(',').map(e => e.trim()).filter(Boolean)
|
|
43
|
+
: (Array.isArray(fileConfig.events) ? fileConfig.events : []);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
url,
|
|
47
|
+
events,
|
|
48
|
+
secret: env('SES_WEBHOOK_SECRET') || fileConfig.secret || '',
|
|
49
|
+
auth_token: env('SES_WEBHOOK_AUTH_TOKEN') || fileConfig.auth_token || '',
|
|
50
|
+
headers: fileConfig.headers || {},
|
|
51
|
+
timeout_ms: fileConfig.timeout_ms || 5000,
|
|
52
|
+
retry: typeof fileConfig.retry === 'number' ? fileConfig.retry : 1,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compute HMAC-SHA256 signature in GitHub-compatible format.
|
|
58
|
+
*/
|
|
59
|
+
function computeSignature(secret, body) {
|
|
60
|
+
const hmac = createHmac('sha256', secret);
|
|
61
|
+
hmac.update(body);
|
|
62
|
+
return `sha256=${hmac.digest('hex')}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Send a single HTTP POST request. Returns a promise that resolves/rejects.
|
|
67
|
+
*/
|
|
68
|
+
function httpPost(url, headers, body, timeoutMs) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const parsed = new URL(url);
|
|
71
|
+
const isHttps = parsed.protocol === 'https:';
|
|
72
|
+
const reqFn = isHttps ? httpsRequest : httpRequest;
|
|
73
|
+
|
|
74
|
+
const options = {
|
|
75
|
+
hostname: parsed.hostname,
|
|
76
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
77
|
+
path: parsed.pathname + parsed.search,
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: {
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
'Content-Length': Buffer.byteLength(body),
|
|
82
|
+
...headers,
|
|
83
|
+
},
|
|
84
|
+
timeout: timeoutMs,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const req = reqFn(options, (res) => {
|
|
88
|
+
let data = '';
|
|
89
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
90
|
+
res.on('end', () => {
|
|
91
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
92
|
+
resolve({ status: res.statusCode, body: data });
|
|
93
|
+
} else {
|
|
94
|
+
reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
req.on('timeout', () => {
|
|
100
|
+
req.destroy();
|
|
101
|
+
reject(new Error(`Request timed out after ${timeoutMs}ms`));
|
|
102
|
+
});
|
|
103
|
+
req.on('error', reject);
|
|
104
|
+
req.write(body);
|
|
105
|
+
req.end();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Send webhook for a single config entry. Supports retry.
|
|
111
|
+
*/
|
|
112
|
+
export async function sendWebhook(config, event, payload) {
|
|
113
|
+
const body = JSON.stringify({
|
|
114
|
+
event,
|
|
115
|
+
timestamp: new Date().toISOString(),
|
|
116
|
+
payload,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const headers = { ...config.headers };
|
|
120
|
+
|
|
121
|
+
// Auth: HMAC signature or Bearer token (mutually exclusive, HMAC preferred)
|
|
122
|
+
if (config.secret) {
|
|
123
|
+
headers['X-Signature-256'] = computeSignature(config.secret, body);
|
|
124
|
+
} else if (config.auth_token) {
|
|
125
|
+
headers['Authorization'] = `Bearer ${config.auth_token}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const maxAttempts = (config.retry || 0) + 1;
|
|
129
|
+
let lastError;
|
|
130
|
+
|
|
131
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
132
|
+
try {
|
|
133
|
+
await httpPost(config.url, headers, body, config.timeout_ms || 5000);
|
|
134
|
+
return; // success
|
|
135
|
+
} catch (err) {
|
|
136
|
+
lastError = err;
|
|
137
|
+
if (attempt < maxAttempts) {
|
|
138
|
+
// Brief delay before retry
|
|
139
|
+
await new Promise(r => setTimeout(r, 500 * attempt));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
throw lastError;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Main entry point: load config, filter by event, dispatch webhook.
|
|
149
|
+
* Non-blocking — logs warnings to stderr, never throws.
|
|
150
|
+
*/
|
|
151
|
+
export async function dispatchWebhook(projectRoot, event, payload) {
|
|
152
|
+
try {
|
|
153
|
+
const config = loadWebhookConfig(projectRoot);
|
|
154
|
+
if (!config) return;
|
|
155
|
+
|
|
156
|
+
// If events list is configured, only fire for matching events
|
|
157
|
+
if (config.events.length > 0 && !config.events.includes(event)) return;
|
|
158
|
+
|
|
159
|
+
await sendWebhook(config, event, payload);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
process.stderr.write(`[ses-cli] webhook warning: ${err.message}\n`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* CLI command: ses webhook
|
|
167
|
+
* Shows current webhook configuration status.
|
|
168
|
+
*/
|
|
169
|
+
export default async function webhook(args) {
|
|
170
|
+
const { getProjectRoot } = await import('./config.js');
|
|
171
|
+
const projectRoot = getProjectRoot();
|
|
172
|
+
const config = loadWebhookConfig(projectRoot);
|
|
173
|
+
|
|
174
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
175
|
+
console.log('Usage: ses webhook [options]');
|
|
176
|
+
console.log('');
|
|
177
|
+
console.log('Show current webhook configuration.');
|
|
178
|
+
console.log('');
|
|
179
|
+
console.log('Options:');
|
|
180
|
+
console.log(' --test Send a test webhook ping');
|
|
181
|
+
console.log(' --help Show this help');
|
|
182
|
+
console.log('');
|
|
183
|
+
console.log('Configuration (highest priority first):');
|
|
184
|
+
console.log(' 1. Environment variables:');
|
|
185
|
+
console.log(' SES_WEBHOOK_URL Webhook endpoint URL');
|
|
186
|
+
console.log(' SES_WEBHOOK_SECRET HMAC-SHA256 signing secret');
|
|
187
|
+
console.log(' SES_WEBHOOK_AUTH_TOKEN Bearer token');
|
|
188
|
+
console.log(' SES_WEBHOOK_EVENTS Comma-separated event list');
|
|
189
|
+
console.log(' 2. .ses-logs/config.json "env" field');
|
|
190
|
+
console.log(' 3. .ses-logs/config.json "webhooks" field');
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!config) {
|
|
195
|
+
console.log('No webhook configured.');
|
|
196
|
+
console.log('');
|
|
197
|
+
console.log('Set SES_WEBHOOK_URL or add "webhooks" to .ses-logs/config.json');
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log('Webhook configuration:');
|
|
202
|
+
console.log(` URL: ${config.url}`);
|
|
203
|
+
console.log(` Events: ${config.events.length > 0 ? config.events.join(', ') : '(all)'}`);
|
|
204
|
+
console.log(` Auth: ${config.secret ? 'HMAC-SHA256' : config.auth_token ? 'Bearer token' : 'none'}`);
|
|
205
|
+
console.log(` Timeout: ${config.timeout_ms}ms`);
|
|
206
|
+
console.log(` Retry: ${config.retry}`);
|
|
207
|
+
if (Object.keys(config.headers).length > 0) {
|
|
208
|
+
console.log(` Headers: ${Object.keys(config.headers).join(', ')}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (args.includes('--test')) {
|
|
212
|
+
console.log('');
|
|
213
|
+
console.log('Sending test ping...');
|
|
214
|
+
try {
|
|
215
|
+
await sendWebhook(config, 'ping', { message: 'ses-cli webhook test' });
|
|
216
|
+
console.log('OK — webhook delivered successfully.');
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.error(`Failed: ${err.message}`);
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@claudemini/ses-cli",
|
|
3
|
+
"version": "1.4.3",
|
|
4
|
+
"description": "Session-based Hook Intelligence Tracker for human-AI coding sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ses": "./bin/ses.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.0.0"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --check bin/ses.js && for f in lib/*.js; do node --check \"$f\"; done"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"claude-code",
|
|
22
|
+
"gemini-cli",
|
|
23
|
+
"cursor",
|
|
24
|
+
"ai-coding",
|
|
25
|
+
"hooks",
|
|
26
|
+
"session-tracking",
|
|
27
|
+
"code-review",
|
|
28
|
+
"checkpoint"
|
|
29
|
+
],
|
|
30
|
+
"homepage": "",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": ""
|
|
34
|
+
},
|
|
35
|
+
"author": "",
|
|
36
|
+
"license": "",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"goatchain": "^0.0.29"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {}
|
|
41
|
+
}
|