stakeout-cli 0.1.0

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.
Files changed (56) hide show
  1. package/LICENSE +131 -0
  2. package/README.md +152 -0
  3. package/dist/commands/chat.d.ts +5 -0
  4. package/dist/commands/chat.js +162 -0
  5. package/dist/commands/clear.d.ts +7 -0
  6. package/dist/commands/clear.js +89 -0
  7. package/dist/commands/config.d.ts +10 -0
  8. package/dist/commands/config.js +64 -0
  9. package/dist/commands/dashboard.d.ts +5 -0
  10. package/dist/commands/dashboard.js +9 -0
  11. package/dist/commands/digest.d.ts +6 -0
  12. package/dist/commands/digest.js +113 -0
  13. package/dist/commands/export.d.ts +8 -0
  14. package/dist/commands/export.js +118 -0
  15. package/dist/commands/hook.d.ts +6 -0
  16. package/dist/commands/hook.js +57 -0
  17. package/dist/commands/init.d.ts +6 -0
  18. package/dist/commands/init.js +70 -0
  19. package/dist/commands/log.d.ts +9 -0
  20. package/dist/commands/log.js +103 -0
  21. package/dist/commands/note.d.ts +9 -0
  22. package/dist/commands/note.js +48 -0
  23. package/dist/commands/record.d.ts +6 -0
  24. package/dist/commands/record.js +106 -0
  25. package/dist/commands/repo.d.ts +7 -0
  26. package/dist/commands/repo.js +60 -0
  27. package/dist/commands/search.d.ts +5 -0
  28. package/dist/commands/search.js +69 -0
  29. package/dist/commands/stats.d.ts +1 -0
  30. package/dist/commands/stats.js +99 -0
  31. package/dist/commands/tag.d.ts +8 -0
  32. package/dist/commands/tag.js +61 -0
  33. package/dist/commands/tui.d.ts +1 -0
  34. package/dist/commands/tui.js +5 -0
  35. package/dist/commands/watch.d.ts +6 -0
  36. package/dist/commands/watch.js +101 -0
  37. package/dist/index.d.ts +2 -0
  38. package/dist/index.js +195 -0
  39. package/dist/lib/config.d.ts +5 -0
  40. package/dist/lib/config.js +51 -0
  41. package/dist/lib/database.d.ts +31 -0
  42. package/dist/lib/database.js +222 -0
  43. package/dist/lib/diff.d.ts +3 -0
  44. package/dist/lib/diff.js +118 -0
  45. package/dist/lib/summarizer.d.ts +2 -0
  46. package/dist/lib/summarizer.js +90 -0
  47. package/dist/tui/App.d.ts +1 -0
  48. package/dist/tui/App.js +125 -0
  49. package/dist/types/index.d.ts +38 -0
  50. package/dist/types/index.js +1 -0
  51. package/dist/web/public/app.js +387 -0
  52. package/dist/web/public/index.html +131 -0
  53. package/dist/web/public/styles.css +571 -0
  54. package/dist/web/server.d.ts +1 -0
  55. package/dist/web/server.js +402 -0
  56. package/package.json +69 -0
@@ -0,0 +1,402 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+ import { getEntries, insertEntry, getDb, getEntry, updateEntry, getRepos, addChatMessage } from '../lib/database.js';
6
+ import { collectDiff, collectLastCommit } from '../lib/diff.js';
7
+ import { summarize } from '../lib/summarizer.js';
8
+ import { loadConfig, saveConfig } from '../lib/config.js';
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ export function createServer(port = 3333) {
12
+ const app = express();
13
+ app.use(cors());
14
+ app.use(express.json());
15
+ app.use(express.static(join(__dirname, 'public')));
16
+ // API Routes
17
+ // Get entries
18
+ app.get('/api/entries', (req, res) => {
19
+ try {
20
+ const limit = req.query.limit ? parseInt(req.query.limit, 10) : 50;
21
+ const since = req.query.since;
22
+ const path = req.query.path;
23
+ const entries = getEntries({ limit, since, path });
24
+ res.json({ entries });
25
+ }
26
+ catch (error) {
27
+ res.status(500).json({ error: error.message });
28
+ }
29
+ });
30
+ // Get stats
31
+ app.get('/api/stats', (req, res) => {
32
+ try {
33
+ const db = getDb();
34
+ const totalEntries = db.prepare('SELECT COUNT(*) as count FROM entries').get();
35
+ const today = new Date();
36
+ today.setHours(0, 0, 0, 0);
37
+ const todayEntries = db.prepare('SELECT COUNT(*) as count FROM entries WHERE timestamp >= ?').get(today.toISOString());
38
+ const weekAgo = new Date();
39
+ weekAgo.setDate(weekAgo.getDate() - 7);
40
+ const weekEntries = db.prepare('SELECT COUNT(*) as count FROM entries WHERE timestamp >= ?').get(weekAgo.toISOString());
41
+ // Activity by day (last 14 days)
42
+ const activity = db.prepare(`
43
+ SELECT DATE(timestamp) as date, COUNT(*) as count
44
+ FROM entries
45
+ WHERE timestamp >= datetime('now', '-14 days')
46
+ GROUP BY DATE(timestamp)
47
+ ORDER BY date ASC
48
+ `).all();
49
+ res.json({
50
+ total: totalEntries.count,
51
+ today: todayEntries.count,
52
+ thisWeek: weekEntries.count,
53
+ activity
54
+ });
55
+ }
56
+ catch (error) {
57
+ res.status(500).json({ error: error.message });
58
+ }
59
+ });
60
+ // Record changes
61
+ app.post('/api/record', async (req, res) => {
62
+ try {
63
+ const { path, lastCommit } = req.body;
64
+ const diff = lastCommit
65
+ ? await collectLastCommit(path)
66
+ : await collectDiff(path);
67
+ if (!diff) {
68
+ res.json({ success: false, message: 'No changes detected' });
69
+ return;
70
+ }
71
+ const summary = await summarize(diff);
72
+ const entry = {
73
+ timestamp: new Date().toISOString(),
74
+ files_changed: diff.files,
75
+ directories: diff.directories,
76
+ summary,
77
+ diff_hash: diff.diff_hash,
78
+ commit_hash: diff.commit_hash,
79
+ commit_message: diff.commit_message
80
+ };
81
+ const id = insertEntry(entry);
82
+ res.json({
83
+ success: true,
84
+ entry: { ...entry, id }
85
+ });
86
+ }
87
+ catch (error) {
88
+ res.status(500).json({ error: error.message });
89
+ }
90
+ });
91
+ // Get config
92
+ app.get('/api/config', (req, res) => {
93
+ try {
94
+ const config = loadConfig();
95
+ // Don't expose full API key
96
+ if (config.openai_api_key) {
97
+ config.openai_api_key = '****' + config.openai_api_key.slice(-4);
98
+ }
99
+ res.json(config);
100
+ }
101
+ catch (error) {
102
+ res.status(500).json({ error: error.message });
103
+ }
104
+ });
105
+ // Update config
106
+ app.patch('/api/config', (req, res) => {
107
+ try {
108
+ const current = loadConfig();
109
+ const updated = { ...current, ...req.body };
110
+ saveConfig(updated);
111
+ res.json({ success: true });
112
+ }
113
+ catch (error) {
114
+ res.status(500).json({ error: error.message });
115
+ }
116
+ });
117
+ // Search entries
118
+ app.get('/api/search', (req, res) => {
119
+ try {
120
+ const query = req.query.q;
121
+ if (!query) {
122
+ res.json({ entries: [] });
123
+ return;
124
+ }
125
+ const db = getDb();
126
+ const pattern = `%${query}%`;
127
+ const stmt = db.prepare(`
128
+ SELECT * FROM entries
129
+ WHERE summary LIKE ?
130
+ OR commit_message LIKE ?
131
+ OR files_changed LIKE ?
132
+ OR directories LIKE ?
133
+ ORDER BY timestamp DESC
134
+ LIMIT 50
135
+ `);
136
+ const rows = stmt.all(pattern, pattern, pattern, pattern);
137
+ const entries = rows.map(row => ({
138
+ id: row.id,
139
+ timestamp: row.timestamp,
140
+ files_changed: JSON.parse(row.files_changed),
141
+ directories: JSON.parse(row.directories),
142
+ summary: row.summary,
143
+ diff_hash: row.diff_hash,
144
+ commit_hash: row.commit_hash,
145
+ commit_message: row.commit_message
146
+ }));
147
+ res.json({ entries });
148
+ }
149
+ catch (error) {
150
+ res.status(500).json({ error: error.message });
151
+ }
152
+ });
153
+ // Detailed stats
154
+ app.get('/api/stats/detailed', (req, res) => {
155
+ try {
156
+ const db = getDb();
157
+ const total = db.prepare('SELECT COUNT(*) as count FROM entries').get().count;
158
+ const today = new Date();
159
+ today.setHours(0, 0, 0, 0);
160
+ const todayCount = db.prepare('SELECT COUNT(*) as count FROM entries WHERE timestamp >= ?').get(today.toISOString()).count;
161
+ const weekAgo = new Date();
162
+ weekAgo.setDate(weekAgo.getDate() - 7);
163
+ const thisWeek = db.prepare('SELECT COUNT(*) as count FROM entries WHERE timestamp >= ?').get(weekAgo.toISOString()).count;
164
+ const monthAgo = new Date();
165
+ monthAgo.setMonth(monthAgo.getMonth() - 1);
166
+ const thisMonth = db.prepare('SELECT COUNT(*) as count FROM entries WHERE timestamp >= ?').get(monthAgo.toISOString()).count;
167
+ const first = db.prepare('SELECT timestamp FROM entries ORDER BY timestamp ASC LIMIT 1').get();
168
+ // Top directories
169
+ const allDirs = db.prepare('SELECT directories FROM entries').all();
170
+ const dirCounts = {};
171
+ for (const row of allDirs) {
172
+ const dirs = JSON.parse(row.directories);
173
+ for (const dir of dirs) {
174
+ dirCounts[dir] = (dirCounts[dir] || 0) + 1;
175
+ }
176
+ }
177
+ const topDirs = Object.entries(dirCounts).sort((a, b) => b[1] - a[1]).slice(0, 10);
178
+ // Activity by day of week
179
+ const byDayOfWeek = db.prepare(`
180
+ SELECT
181
+ CASE CAST(strftime('%w', timestamp) AS INTEGER)
182
+ WHEN 0 THEN 'Sun'
183
+ WHEN 1 THEN 'Mon'
184
+ WHEN 2 THEN 'Tue'
185
+ WHEN 3 THEN 'Wed'
186
+ WHEN 4 THEN 'Thu'
187
+ WHEN 5 THEN 'Fri'
188
+ WHEN 6 THEN 'Sat'
189
+ END as day,
190
+ COUNT(*) as count
191
+ FROM entries
192
+ GROUP BY strftime('%w', timestamp)
193
+ ORDER BY CAST(strftime('%w', timestamp) AS INTEGER)
194
+ `).all();
195
+ // Peak hours
196
+ const peakHours = db.prepare(`
197
+ SELECT CAST(strftime('%H', timestamp) AS INTEGER) as hour, COUNT(*) as count
198
+ FROM entries
199
+ GROUP BY strftime('%H', timestamp)
200
+ ORDER BY count DESC
201
+ `).all();
202
+ res.json({
203
+ total,
204
+ today: todayCount,
205
+ thisWeek,
206
+ thisMonth,
207
+ firstEntry: first?.timestamp,
208
+ topDirs,
209
+ byDayOfWeek,
210
+ peakHours
211
+ });
212
+ }
213
+ catch (error) {
214
+ res.status(500).json({ error: error.message });
215
+ }
216
+ });
217
+ // Export entries
218
+ app.get('/api/export', (req, res) => {
219
+ try {
220
+ const format = req.query.format || 'markdown';
221
+ const since = req.query.since;
222
+ const entries = getEntries({ limit: 1000, since });
223
+ if (format === 'json') {
224
+ res.setHeader('Content-Type', 'application/json');
225
+ res.setHeader('Content-Disposition', 'attachment; filename=stakeout-export.json');
226
+ res.send(JSON.stringify(entries, null, 2));
227
+ }
228
+ else if (format === 'csv') {
229
+ res.setHeader('Content-Type', 'text/csv');
230
+ res.setHeader('Content-Disposition', 'attachment; filename=stakeout-export.csv');
231
+ const headers = ['id', 'timestamp', 'commit_hash', 'summary', 'directories', 'file_count'];
232
+ const rows = entries.map(e => [
233
+ e.id,
234
+ e.timestamp,
235
+ e.commit_hash || '',
236
+ `"${e.summary.replace(/"/g, '""')}"`,
237
+ `"${e.directories.join(', ')}"`,
238
+ e.files_changed.length
239
+ ]);
240
+ res.send([headers.join(','), ...rows.map(r => r.join(','))].join('\n'));
241
+ }
242
+ else {
243
+ res.setHeader('Content-Type', 'text/markdown');
244
+ res.setHeader('Content-Disposition', 'attachment; filename=stakeout-export.md');
245
+ let md = `# STAKEOUT Export\n\nGenerated: ${new Date().toISOString()}\nEntries: ${entries.length}\n\n---\n\n`;
246
+ for (const entry of entries) {
247
+ md += `## Entry #${entry.id}\n\n`;
248
+ md += `**Date:** ${new Date(entry.timestamp).toLocaleString()}\n`;
249
+ if (entry.commit_hash)
250
+ md += `**Commit:** \`${entry.commit_hash.slice(0, 7)}\`\n`;
251
+ md += `\n### Summary\n\n${entry.summary}\n\n`;
252
+ if (entry.directories.length > 0) {
253
+ md += `### Directories\n\n${entry.directories.map(d => `- \`${d}\``).join('\n')}\n\n`;
254
+ }
255
+ md += `---\n\n`;
256
+ }
257
+ res.send(md);
258
+ }
259
+ }
260
+ catch (error) {
261
+ res.status(500).json({ error: error.message });
262
+ }
263
+ });
264
+ // Get single entry
265
+ app.get('/api/entries/:id', (req, res) => {
266
+ try {
267
+ const id = parseInt(req.params.id, 10);
268
+ const entry = getEntry(id);
269
+ if (!entry) {
270
+ res.status(404).json({ error: 'Entry not found' });
271
+ return;
272
+ }
273
+ res.json(entry);
274
+ }
275
+ catch (error) {
276
+ res.status(500).json({ error: error.message });
277
+ }
278
+ });
279
+ // Update entry (tags, favorite, notes, breaking)
280
+ app.patch('/api/entries/:id', (req, res) => {
281
+ try {
282
+ const id = parseInt(req.params.id, 10);
283
+ const { tags, favorite, notes, is_breaking } = req.body;
284
+ updateEntry(id, { tags, favorite, notes, is_breaking });
285
+ res.json({ success: true });
286
+ }
287
+ catch (error) {
288
+ res.status(500).json({ error: error.message });
289
+ }
290
+ });
291
+ // Get velocity data
292
+ app.get('/api/velocity', (req, res) => {
293
+ try {
294
+ const db = getDb();
295
+ const days = parseInt(req.query.days || '30', 10);
296
+ const velocity = db.prepare(`
297
+ SELECT DATE(timestamp) as date, COUNT(*) as count
298
+ FROM entries
299
+ WHERE timestamp >= datetime('now', '-' || ? || ' days')
300
+ GROUP BY DATE(timestamp)
301
+ ORDER BY date ASC
302
+ `).all(days);
303
+ const counts = velocity.map(v => v.count);
304
+ const avg = counts.length > 0 ? counts.reduce((a, b) => a + b, 0) / counts.length : 0;
305
+ const max = counts.length > 0 ? Math.max(...counts) : 0;
306
+ const recent = velocity.slice(-7);
307
+ const previous = velocity.slice(-14, -7);
308
+ const recentAvg = recent.length > 0 ? recent.reduce((a, b) => a + b.count, 0) / recent.length : 0;
309
+ const previousAvg = previous.length > 0 ? previous.reduce((a, b) => a + b.count, 0) / previous.length : 0;
310
+ const trend = previousAvg > 0 ? ((recentAvg - previousAvg) / previousAvg) * 100 : 0;
311
+ res.json({ velocity, average: Math.round(avg * 10) / 10, max, trend: Math.round(trend) });
312
+ }
313
+ catch (error) {
314
+ res.status(500).json({ error: error.message });
315
+ }
316
+ });
317
+ // Get repos
318
+ app.get('/api/repos', (req, res) => {
319
+ try {
320
+ const repos = getRepos();
321
+ res.json({ repos });
322
+ }
323
+ catch (error) {
324
+ res.status(500).json({ error: error.message });
325
+ }
326
+ });
327
+ // Chat API
328
+ app.post('/api/chat', async (req, res) => {
329
+ try {
330
+ const { message } = req.body;
331
+ if (!message) {
332
+ res.status(400).json({ error: 'Message required' });
333
+ return;
334
+ }
335
+ const db = getDb();
336
+ const recentEntries = getEntries({ limit: 10 });
337
+ const total = db.prepare('SELECT COUNT(*) as count FROM entries').get().count;
338
+ const keywords = message.toLowerCase().split(/\s+/).filter((w) => w.length > 2);
339
+ let relevantEntries = [];
340
+ if (keywords.length > 0) {
341
+ const conditions = keywords.map(() => '(summary LIKE ? OR directories LIKE ?)').join(' OR ');
342
+ const params = keywords.flatMap((k) => [`%${k}%`, `%${k}%`]);
343
+ relevantEntries = db.prepare(`SELECT * FROM entries WHERE ${conditions} ORDER BY timestamp DESC LIMIT 5`).all(...params);
344
+ }
345
+ let context = `Total entries: ${total}\n\n`;
346
+ if (relevantEntries.length > 0) {
347
+ context += 'Relevant entries:\n';
348
+ for (const e of relevantEntries)
349
+ context += `[#${e.id}] ${e.summary}\n`;
350
+ context += '\n';
351
+ }
352
+ context += 'Recent entries:\n';
353
+ for (const e of recentEntries.slice(0, 5))
354
+ context += `[#${e.id}] ${e.summary}\n`;
355
+ const config = loadConfig();
356
+ let response;
357
+ const systemPrompt = `You are STAKEOUT AI. Answer questions about the codebase history. Be concise.`;
358
+ if (config.llm_provider === 'ollama') {
359
+ const { Ollama } = await import('ollama');
360
+ const ollama = new Ollama({ host: config.ollama_host });
361
+ const result = await ollama.chat({
362
+ model: config.ollama_model,
363
+ messages: [
364
+ { role: 'system', content: systemPrompt },
365
+ { role: 'system', content: `Context:\n${context}` },
366
+ { role: 'user', content: message }
367
+ ]
368
+ });
369
+ response = result.message.content;
370
+ }
371
+ else {
372
+ const OpenAI = (await import('openai')).default;
373
+ if (!config.openai_api_key)
374
+ throw new Error('OpenAI key not configured');
375
+ const openai = new OpenAI({ apiKey: config.openai_api_key });
376
+ const result = await openai.chat.completions.create({
377
+ model: config.openai_model,
378
+ messages: [
379
+ { role: 'system', content: systemPrompt },
380
+ { role: 'system', content: `Context:\n${context}` },
381
+ { role: 'user', content: message }
382
+ ]
383
+ });
384
+ response = result.choices[0]?.message?.content || 'Unable to generate response';
385
+ }
386
+ addChatMessage('user', message);
387
+ addChatMessage('assistant', response);
388
+ res.json({ response });
389
+ }
390
+ catch (error) {
391
+ res.status(500).json({ error: error.message });
392
+ }
393
+ });
394
+ // Serve the dashboard for the root route
395
+ app.get('/', (req, res) => {
396
+ res.sendFile(join(__dirname, 'public', 'index.html'));
397
+ });
398
+ const server = app.listen(port, () => {
399
+ console.log(`\n🔍 STAKEOUT Dashboard running at http://localhost:${port}\n`);
400
+ });
401
+ return server;
402
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "stakeout-cli",
3
+ "version": "0.1.0",
4
+ "description": "Surveillance ops for your codebase - AI-powered change tracking",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "license": "PolyForm-Noncommercial-1.0.0",
8
+ "author": "Casey",
9
+ "keywords": [
10
+ "git",
11
+ "changelog",
12
+ "ai",
13
+ "llm",
14
+ "ollama",
15
+ "openai",
16
+ "diff",
17
+ "summary",
18
+ "developer-tools",
19
+ "cli"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/Kaidorespy/stakeout.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/Kaidorespy/stakeout/issues"
27
+ },
28
+ "homepage": "https://github.com/Kaidorespy/stakeout#readme",
29
+ "bin": {
30
+ "stakeout": "./dist/index.js",
31
+ "sto": "./dist/index.js"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "LICENSE",
36
+ "README.md"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc && cp -r src/web/public dist/web/",
43
+ "dev": "tsc --watch",
44
+ "start": "node dist/index.js",
45
+ "dashboard": "node dist/index.js dashboard"
46
+ },
47
+ "dependencies": {
48
+ "better-sqlite3": "^11.0.0",
49
+ "chalk": "^5.3.0",
50
+ "commander": "^12.0.0",
51
+ "cors": "^2.8.6",
52
+ "express": "^5.2.1",
53
+ "ink": "^6.6.0",
54
+ "ink-spinner": "^5.0.0",
55
+ "ink-text-input": "^6.0.0",
56
+ "ollama": "^0.5.0",
57
+ "openai": "^4.0.0",
58
+ "react": "^19.2.4",
59
+ "simple-git": "^3.22.0"
60
+ },
61
+ "devDependencies": {
62
+ "@types/better-sqlite3": "^7.6.9",
63
+ "@types/cors": "^2.8.19",
64
+ "@types/express": "^5.0.6",
65
+ "@types/node": "^20.11.0",
66
+ "@types/react": "^19.2.13",
67
+ "typescript": "^5.3.0"
68
+ }
69
+ }