@claudemini/shit-cli 1.7.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,6 +124,7 @@ shit summarize <session-id> # Generate AI summary (requires API key)
124
124
  | `doctor` | Diagnose and fix issues |
125
125
  | `shadow` | List/inspect shadow branches |
126
126
  | `clean` | Clean old sessions |
127
+ | `webhook` | Show/test webhook configuration |
127
128
 
128
129
  ## How It Works
129
130
 
@@ -248,6 +249,80 @@ if (history && history.length > 3) {
248
249
  }
249
250
  ```
250
251
 
252
+ ## Webhook
253
+
254
+ shit-cli can send webhook notifications to external systems (Slack, Lark, CI, custom platforms) when key events occur.
255
+
256
+ ### Events
257
+
258
+ | Event | Trigger | Payload |
259
+ |-------|---------|---------|
260
+ | `session.ended` | Session ends (hook `session-end` / `stop`) | `summary.json` content |
261
+ | `review.completed` | `shit review` finishes | Review report |
262
+
263
+ ### Configuration
264
+
265
+ Webhook supports three configuration sources (highest priority first):
266
+
267
+ **1. Environment variables** (highest priority):
268
+
269
+ ```bash
270
+ export SHIT_WEBHOOK_URL=https://example.com/hook
271
+ export SHIT_WEBHOOK_SECRET=my-secret # HMAC-SHA256 signing
272
+ export SHIT_WEBHOOK_AUTH_TOKEN=bearer-token # Bearer auth (alternative to secret)
273
+ export SHIT_WEBHOOK_EVENTS=session.ended,review.completed
274
+ ```
275
+
276
+ **2. `.shit-logs/config.json` `env` field**:
277
+
278
+ ```json
279
+ {
280
+ "env": {
281
+ "SHIT_WEBHOOK_URL": "https://example.com/hook",
282
+ "SHIT_WEBHOOK_SECRET": "my-secret",
283
+ "SHIT_WEBHOOK_EVENTS": "session.ended,review.completed"
284
+ }
285
+ }
286
+ ```
287
+
288
+ **3. `.shit-logs/config.json` `webhooks` field** (lowest priority):
289
+
290
+ ```json
291
+ {
292
+ "webhooks": {
293
+ "url": "https://example.com/hook",
294
+ "events": ["session.ended", "review.completed"],
295
+ "secret": "hmac-secret-key",
296
+ "headers": { "X-Custom": "value" },
297
+ "timeout_ms": 5000,
298
+ "retry": 1
299
+ }
300
+ }
301
+ ```
302
+
303
+ ### Authentication
304
+
305
+ - **HMAC-SHA256** — Set `secret` or `SHIT_WEBHOOK_SECRET`. Adds `X-Signature-256: sha256=<hex>` header (GitHub-compatible format).
306
+ - **Bearer token** — Set `auth_token` or `SHIT_WEBHOOK_AUTH_TOKEN`. Adds `Authorization: Bearer <token>` header.
307
+ - If neither is set, requests are sent without authentication.
308
+
309
+ ### Payload Format
310
+
311
+ ```json
312
+ {
313
+ "event": "session.ended",
314
+ "timestamp": "2026-03-03T12:00:00.000Z",
315
+ "payload": { ... }
316
+ }
317
+ ```
318
+
319
+ ### Commands
320
+
321
+ ```bash
322
+ shit webhook # Show current webhook configuration
323
+ shit webhook --test # Send a test ping to the configured URL
324
+ ```
325
+
251
326
  ## AI Summary
252
327
 
253
328
  Set one of these environment variables to enable AI-powered session summaries:
@@ -272,6 +347,10 @@ shit summarize <session-id>
272
347
  | `OPENAI_BASE_URL` | OpenAI-compatible base URL for summaries (default: `https://api.openai.com/v1`) |
273
348
  | `OPENAI_ENDPOINT` | Full OpenAI-compatible endpoint (overrides `OPENAI_BASE_URL`) |
274
349
  | `ANTHROPIC_API_KEY` | Enable AI summaries via Anthropic |
350
+ | `SHIT_WEBHOOK_URL` | Webhook endpoint URL |
351
+ | `SHIT_WEBHOOK_SECRET` | HMAC-SHA256 signing secret for webhooks |
352
+ | `SHIT_WEBHOOK_AUTH_TOKEN` | Bearer token for webhook authentication |
353
+ | `SHIT_WEBHOOK_EVENTS` | Comma-separated list of webhook events to subscribe to |
275
354
 
276
355
  ## Security
277
356
 
package/bin/shit.js CHANGED
@@ -23,6 +23,7 @@ const commands = {
23
23
  doctor: 'Fix or clean stuck sessions',
24
24
  shadow: 'List shadow branches',
25
25
  clean: 'Clean old sessions',
26
+ webhook: 'Manage webhook configuration',
26
27
  help: 'Show help',
27
28
  };
28
29
 
package/lib/log.js CHANGED
@@ -5,12 +5,13 @@
5
5
  * Reads stdin, parses event, delegates to session/extract/report modules.
6
6
  */
7
7
 
8
- import { appendFileSync, mkdirSync } from 'fs';
8
+ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { getProjectRoot, getLogDir } from './config.js';
11
11
  import { loadState, saveState, processEvent, updateIndex } from './session.js';
12
12
  import { extractIntent, extractChanges, classifySession } from './extract.js';
13
13
  import { generateReports } from './report.js';
14
+ import { dispatchWebhook } from './webhook.js';
14
15
 
15
16
  export default async function log(args) {
16
17
  const hookType = args[0] || 'unknown';
@@ -50,8 +51,11 @@ export default async function log(args) {
50
51
  const classification = classifySession(intent, changes);
51
52
  generateReports(sessionDir, sessionId, state, intent, changes, classification);
52
53
 
53
- // 4. On session end: checkpoint + update index
54
- if (hookType === 'session-end' || hookType === 'stop') {
54
+ // 4. On session end: checkpoint + update index + webhook (idempotent — run once per session)
55
+ const isSessionEnd = ['session-end', 'SessionEnd', 'stop', 'session_end', 'end', 'onSessionEnd'].includes(hookType);
56
+ const endedSentinel = join(sessionDir, '.ended');
57
+ if (isSessionEnd && !existsSync(endedSentinel)) {
58
+ writeFileSync(endedSentinel, new Date().toISOString());
55
59
  try {
56
60
  const { commitCheckpoint } = await import('./checkpoint.js');
57
61
  await commitCheckpoint(projectRoot, sessionDir, sessionId);
@@ -60,6 +64,14 @@ export default async function log(args) {
60
64
  try {
61
65
  updateIndex(logDir, sessionId, intent, classification, changes, state);
62
66
  } catch { /* index is best-effort */ }
67
+
68
+ // Dispatch webhook for session end — must await before exit
69
+ try {
70
+ const { readFileSync } = await import('fs');
71
+ const summaryPath = join(sessionDir, 'summary.json');
72
+ const summary = JSON.parse(readFileSync(summaryPath, 'utf-8'));
73
+ await dispatchWebhook(projectRoot, 'session.ended', summary);
74
+ } catch { /* webhook is best-effort */ }
63
75
  }
64
76
 
65
77
  process.exit(0);
package/lib/review.js CHANGED
@@ -12,6 +12,7 @@ import { join } from 'path';
12
12
  import { createHash } from 'crypto';
13
13
  import { getLogDir, getProjectRoot, SESSION_ID_REGEX } from './config.js';
14
14
  import { redactSecrets } from './redact.js';
15
+ import { dispatchWebhook } from './webhook.js';
15
16
 
16
17
  const SEVERITY_SCORE = {
17
18
  info: 1,
@@ -708,6 +709,9 @@ export default async function review(args) {
708
709
 
709
710
  const report = buildReport(selectedSessions, options);
710
711
 
712
+ // Dispatch webhook for review completion
713
+ dispatchWebhook(projectRoot, 'review.completed', report).catch(() => {});
714
+
711
715
  if (options.format === 'json') {
712
716
  console.log(JSON.stringify(report, null, 2));
713
717
  } else if (options.format === 'markdown') {
package/lib/webhook.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Webhook support for shit-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 .shit-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, '.shit-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) => process.env[key] || configEnv[key] || '';
32
+
33
+ const url = env('SHIT_WEBHOOK_URL') || fileConfig.url;
34
+ if (!url) return null;
35
+
36
+ const envEvents = env('SHIT_WEBHOOK_EVENTS');
37
+ const events = envEvents
38
+ ? envEvents.split(',').map(e => e.trim()).filter(Boolean)
39
+ : (Array.isArray(fileConfig.events) ? fileConfig.events : []);
40
+
41
+ return {
42
+ url,
43
+ events,
44
+ secret: env('SHIT_WEBHOOK_SECRET') || fileConfig.secret || '',
45
+ auth_token: env('SHIT_WEBHOOK_AUTH_TOKEN') || fileConfig.auth_token || '',
46
+ headers: fileConfig.headers || {},
47
+ timeout_ms: fileConfig.timeout_ms || 5000,
48
+ retry: typeof fileConfig.retry === 'number' ? fileConfig.retry : 1,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Compute HMAC-SHA256 signature in GitHub-compatible format.
54
+ */
55
+ function computeSignature(secret, body) {
56
+ const hmac = createHmac('sha256', secret);
57
+ hmac.update(body);
58
+ return `sha256=${hmac.digest('hex')}`;
59
+ }
60
+
61
+ /**
62
+ * Send a single HTTP POST request. Returns a promise that resolves/rejects.
63
+ */
64
+ function httpPost(url, headers, body, timeoutMs) {
65
+ return new Promise((resolve, reject) => {
66
+ const parsed = new URL(url);
67
+ const isHttps = parsed.protocol === 'https:';
68
+ const reqFn = isHttps ? httpsRequest : httpRequest;
69
+
70
+ const options = {
71
+ hostname: parsed.hostname,
72
+ port: parsed.port || (isHttps ? 443 : 80),
73
+ path: parsed.pathname + parsed.search,
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ 'Content-Length': Buffer.byteLength(body),
78
+ ...headers,
79
+ },
80
+ timeout: timeoutMs,
81
+ };
82
+
83
+ const req = reqFn(options, (res) => {
84
+ let data = '';
85
+ res.on('data', (chunk) => { data += chunk; });
86
+ res.on('end', () => {
87
+ if (res.statusCode >= 200 && res.statusCode < 300) {
88
+ resolve({ status: res.statusCode, body: data });
89
+ } else {
90
+ reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`));
91
+ }
92
+ });
93
+ });
94
+
95
+ req.on('timeout', () => {
96
+ req.destroy();
97
+ reject(new Error(`Request timed out after ${timeoutMs}ms`));
98
+ });
99
+ req.on('error', reject);
100
+ req.write(body);
101
+ req.end();
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Send webhook for a single config entry. Supports retry.
107
+ */
108
+ export async function sendWebhook(config, event, payload) {
109
+ const body = JSON.stringify({
110
+ event,
111
+ timestamp: new Date().toISOString(),
112
+ payload,
113
+ });
114
+
115
+ const headers = { ...config.headers };
116
+
117
+ // Auth: HMAC signature or Bearer token (mutually exclusive, HMAC preferred)
118
+ if (config.secret) {
119
+ headers['X-Signature-256'] = computeSignature(config.secret, body);
120
+ } else if (config.auth_token) {
121
+ headers['Authorization'] = `Bearer ${config.auth_token}`;
122
+ }
123
+
124
+ const maxAttempts = (config.retry || 0) + 1;
125
+ let lastError;
126
+
127
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
128
+ try {
129
+ await httpPost(config.url, headers, body, config.timeout_ms || 5000);
130
+ return; // success
131
+ } catch (err) {
132
+ lastError = err;
133
+ if (attempt < maxAttempts) {
134
+ // Brief delay before retry
135
+ await new Promise(r => setTimeout(r, 500 * attempt));
136
+ }
137
+ }
138
+ }
139
+
140
+ throw lastError;
141
+ }
142
+
143
+ /**
144
+ * Main entry point: load config, filter by event, dispatch webhook.
145
+ * Non-blocking — logs warnings to stderr, never throws.
146
+ */
147
+ export async function dispatchWebhook(projectRoot, event, payload) {
148
+ try {
149
+ const config = loadWebhookConfig(projectRoot);
150
+ if (!config) return;
151
+
152
+ // If events list is configured, only fire for matching events
153
+ if (config.events.length > 0 && !config.events.includes(event)) return;
154
+
155
+ await sendWebhook(config, event, payload);
156
+ } catch (err) {
157
+ process.stderr.write(`[shit-cli] webhook warning: ${err.message}\n`);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * CLI command: shit webhook
163
+ * Shows current webhook configuration status.
164
+ */
165
+ export default async function webhook(args) {
166
+ const { getProjectRoot } = await import('./config.js');
167
+ const projectRoot = getProjectRoot();
168
+ const config = loadWebhookConfig(projectRoot);
169
+
170
+ if (args.includes('--help') || args.includes('-h')) {
171
+ console.log('Usage: shit webhook [options]');
172
+ console.log('');
173
+ console.log('Show current webhook configuration.');
174
+ console.log('');
175
+ console.log('Options:');
176
+ console.log(' --test Send a test webhook ping');
177
+ console.log(' --help Show this help');
178
+ console.log('');
179
+ console.log('Configuration (highest priority first):');
180
+ console.log(' 1. Environment variables:');
181
+ console.log(' SHIT_WEBHOOK_URL Webhook endpoint URL');
182
+ console.log(' SHIT_WEBHOOK_SECRET HMAC-SHA256 signing secret');
183
+ console.log(' SHIT_WEBHOOK_AUTH_TOKEN Bearer token');
184
+ console.log(' SHIT_WEBHOOK_EVENTS Comma-separated event list');
185
+ console.log(' 2. .shit-logs/config.json "env" field');
186
+ console.log(' 3. .shit-logs/config.json "webhooks" field');
187
+ return 0;
188
+ }
189
+
190
+ if (!config) {
191
+ console.log('No webhook configured.');
192
+ console.log('');
193
+ console.log('Set SHIT_WEBHOOK_URL or add "webhooks" to .shit-logs/config.json');
194
+ return 0;
195
+ }
196
+
197
+ console.log('Webhook configuration:');
198
+ console.log(` URL: ${config.url}`);
199
+ console.log(` Events: ${config.events.length > 0 ? config.events.join(', ') : '(all)'}`);
200
+ console.log(` Auth: ${config.secret ? 'HMAC-SHA256' : config.auth_token ? 'Bearer token' : 'none'}`);
201
+ console.log(` Timeout: ${config.timeout_ms}ms`);
202
+ console.log(` Retry: ${config.retry}`);
203
+ if (Object.keys(config.headers).length > 0) {
204
+ console.log(` Headers: ${Object.keys(config.headers).join(', ')}`);
205
+ }
206
+
207
+ if (args.includes('--test')) {
208
+ console.log('');
209
+ console.log('Sending test ping...');
210
+ try {
211
+ await sendWebhook(config, 'ping', { message: 'shit-cli webhook test' });
212
+ console.log('OK — webhook delivered successfully.');
213
+ } catch (err) {
214
+ console.error(`Failed: ${err.message}`);
215
+ return 1;
216
+ }
217
+ }
218
+
219
+ return 0;
220
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claudemini/shit-cli",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "Session-based Hook Intelligence Tracker - Zero-dependency memory system for human-AI coding sessions",
5
5
  "type": "module",
6
6
  "bin": {