costpilot-scanner 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.
- package/dist/api.d.ts +24 -0
- package/dist/api.js +45 -0
- package/dist/commands/setup.d.ts +4 -0
- package/dist/commands/setup.js +31 -0
- package/dist/commands/start.d.ts +1 -0
- package/dist/commands/start.js +11 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +41 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +53 -0
- package/dist/parsers/claude-code.d.ts +11 -0
- package/dist/parsers/claude-code.js +35 -0
- package/dist/watcher.d.ts +2 -0
- package/dist/watcher.js +104 -0
- package/package.json +33 -0
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ScannerConfig } from './config.js';
|
|
2
|
+
export interface IngestPayload {
|
|
3
|
+
source: 'local-scanner';
|
|
4
|
+
model: string;
|
|
5
|
+
eventId: string;
|
|
6
|
+
inputTokens: number;
|
|
7
|
+
outputTokens: number;
|
|
8
|
+
cacheCreationTokens?: number;
|
|
9
|
+
cacheReadTokens?: number;
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
agentTool?: string;
|
|
12
|
+
timestamp?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface HeartbeatPayload {
|
|
15
|
+
source: 'local-scanner';
|
|
16
|
+
version: string;
|
|
17
|
+
hostname: string;
|
|
18
|
+
toolsDetected: string[];
|
|
19
|
+
eventsLast24h: number;
|
|
20
|
+
errorsLast24h: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function ingest(cfg: ScannerConfig, payload: IngestPayload): Promise<boolean>;
|
|
23
|
+
export declare function heartbeat(cfg: ScannerConfig, payload: HeartbeatPayload): Promise<boolean>;
|
|
24
|
+
export declare function validateKey(cfg: ScannerConfig): Promise<boolean>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
async function post(cfg, path, body) {
|
|
2
|
+
return fetch(`${cfg.apiUrl}${path}`, {
|
|
3
|
+
method: 'POST',
|
|
4
|
+
headers: {
|
|
5
|
+
'Content-Type': 'application/json',
|
|
6
|
+
Authorization: `Bearer ${cfg.key}`,
|
|
7
|
+
},
|
|
8
|
+
body: JSON.stringify(body),
|
|
9
|
+
signal: AbortSignal.timeout(10_000),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export async function ingest(cfg, payload) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await post(cfg, '/v1/ingest', payload);
|
|
15
|
+
return res.ok;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function heartbeat(cfg, payload) {
|
|
22
|
+
try {
|
|
23
|
+
const res = await post(cfg, '/v1/scanner/heartbeat', payload);
|
|
24
|
+
return res.ok;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function validateKey(cfg) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await post(cfg, '/v1/scanner/heartbeat', {
|
|
33
|
+
source: 'local-scanner',
|
|
34
|
+
version: '0.1.0',
|
|
35
|
+
hostname: 'setup-check',
|
|
36
|
+
toolsDetected: [],
|
|
37
|
+
eventsLast24h: 0,
|
|
38
|
+
errorsLast24h: 0,
|
|
39
|
+
});
|
|
40
|
+
return res.ok;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { saveConfig } from '../config.js';
|
|
2
|
+
import { validateKey } from '../api.js';
|
|
3
|
+
export async function setupCommand({ key, apiUrl, }) {
|
|
4
|
+
if (!key) {
|
|
5
|
+
console.error('Error: --key is required\n');
|
|
6
|
+
console.error('Usage: costpilot-scanner setup --key pilot_live_...');
|
|
7
|
+
console.error('\nGet a scanner key from:');
|
|
8
|
+
console.error(' https://app.aicostpilot.online → Developer Hub → Local Scanner');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
if (!key.startsWith('pilot_live_')) {
|
|
12
|
+
console.error('Error: key must start with pilot_live_');
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
const cfg = {
|
|
16
|
+
key,
|
|
17
|
+
apiUrl: apiUrl ?? 'https://api.aicostpilot.online',
|
|
18
|
+
};
|
|
19
|
+
process.stdout.write('Validating key with CostPilot API...\n');
|
|
20
|
+
const valid = await validateKey(cfg);
|
|
21
|
+
if (!valid) {
|
|
22
|
+
console.error('\nError: key validation failed — check the key and try again.');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
saveConfig(cfg);
|
|
26
|
+
console.log('\n✓ Scanner configured successfully');
|
|
27
|
+
console.log('\nTo start scanning, run:');
|
|
28
|
+
console.log(' costpilot-scanner start\n');
|
|
29
|
+
console.log('Claude Code usage will sync to CostPilot automatically.');
|
|
30
|
+
console.log('Keep the process running (use PM2, tmux, or a startup script).');
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startCommand(): Promise<void>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js';
|
|
2
|
+
import { startWatcher } from '../watcher.js';
|
|
3
|
+
export async function startCommand() {
|
|
4
|
+
const cfg = loadConfig();
|
|
5
|
+
if (!cfg) {
|
|
6
|
+
console.error('Scanner not configured. Run first:');
|
|
7
|
+
console.error(' costpilot-scanner setup --key pilot_live_...\n');
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
await startWatcher(cfg);
|
|
11
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ScannerConfig {
|
|
2
|
+
key: string;
|
|
3
|
+
apiUrl: string;
|
|
4
|
+
}
|
|
5
|
+
export interface FileState {
|
|
6
|
+
bytesRead: number;
|
|
7
|
+
lineIndex: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ScannerState {
|
|
10
|
+
files: Record<string, FileState>;
|
|
11
|
+
}
|
|
12
|
+
export declare function loadConfig(): ScannerConfig | null;
|
|
13
|
+
export declare function saveConfig(cfg: ScannerConfig): void;
|
|
14
|
+
export declare function loadState(): ScannerState;
|
|
15
|
+
export declare function saveState(state: ScannerState): void;
|
|
16
|
+
export declare function getClaudeProjectsDir(): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
const COSTPILOT_DIR = join(homedir(), '.costpilot');
|
|
5
|
+
const CONFIG_PATH = join(COSTPILOT_DIR, 'scanner.json');
|
|
6
|
+
const STATE_PATH = join(COSTPILOT_DIR, 'scanner-state.json');
|
|
7
|
+
function ensureDir() {
|
|
8
|
+
if (!existsSync(COSTPILOT_DIR))
|
|
9
|
+
mkdirSync(COSTPILOT_DIR, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
export function loadConfig() {
|
|
12
|
+
if (!existsSync(CONFIG_PATH))
|
|
13
|
+
return null;
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function saveConfig(cfg) {
|
|
22
|
+
ensureDir();
|
|
23
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf-8');
|
|
24
|
+
}
|
|
25
|
+
export function loadState() {
|
|
26
|
+
if (!existsSync(STATE_PATH))
|
|
27
|
+
return { files: {} };
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(STATE_PATH, 'utf-8'));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { files: {} };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function saveState(state) {
|
|
36
|
+
ensureDir();
|
|
37
|
+
writeFileSync(STATE_PATH, JSON.stringify(state), 'utf-8');
|
|
38
|
+
}
|
|
39
|
+
export function getClaudeProjectsDir() {
|
|
40
|
+
return join(homedir(), '.claude', 'projects');
|
|
41
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
const { values, positionals } = parseArgs({
|
|
4
|
+
args: process.argv.slice(2),
|
|
5
|
+
options: {
|
|
6
|
+
key: { type: 'string', short: 'k' },
|
|
7
|
+
'api-url': { type: 'string' },
|
|
8
|
+
help: { type: 'boolean', short: 'h' },
|
|
9
|
+
},
|
|
10
|
+
allowPositionals: true,
|
|
11
|
+
strict: false,
|
|
12
|
+
});
|
|
13
|
+
const command = (positionals[0] ?? 'help');
|
|
14
|
+
async function main() {
|
|
15
|
+
if (values.help || command === 'help') {
|
|
16
|
+
console.log(`
|
|
17
|
+
costpilot-scanner — local AI usage scanner for CostPilot
|
|
18
|
+
|
|
19
|
+
Commands:
|
|
20
|
+
setup --key <pilot_live_...> Configure with your CostPilot scanner key
|
|
21
|
+
start Start watching for AI usage (foreground process)
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--key, -k CostPilot scanner key (from Developer Hub → Local Scanner)
|
|
25
|
+
--api-url Override API base (default: https://api.aicostpilot.online)
|
|
26
|
+
--help, -h Show this message
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
npx costpilot-scanner setup --key pilot_live_abc123
|
|
30
|
+
npx costpilot-scanner start
|
|
31
|
+
`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (command === 'setup') {
|
|
35
|
+
const { setupCommand } = await import('./commands/setup.js');
|
|
36
|
+
await setupCommand({
|
|
37
|
+
key: values.key,
|
|
38
|
+
apiUrl: values['api-url'],
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (command === 'start') {
|
|
43
|
+
const { startCommand } = await import('./commands/start.js');
|
|
44
|
+
await startCommand();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
console.error(`Unknown command: "${command}". Run costpilot-scanner help`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
main().catch((err) => {
|
|
51
|
+
console.error('Fatal:', err.message);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ClaudeCodeEvent {
|
|
2
|
+
model: string;
|
|
3
|
+
sessionId: string;
|
|
4
|
+
lineIndex: number;
|
|
5
|
+
inputTokens: number;
|
|
6
|
+
outputTokens: number;
|
|
7
|
+
cacheCreationTokens: number;
|
|
8
|
+
cacheReadTokens: number;
|
|
9
|
+
timestamp?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function parseClaudeCodeLine(line: string, sessionId: string, lineIndex: number): ClaudeCodeEvent | null;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function parseClaudeCodeLine(line, sessionId, lineIndex) {
|
|
2
|
+
let entry;
|
|
3
|
+
try {
|
|
4
|
+
entry = JSON.parse(line);
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
if (entry.type !== 'assistant')
|
|
10
|
+
return null;
|
|
11
|
+
const message = entry.message;
|
|
12
|
+
if (!message)
|
|
13
|
+
return null;
|
|
14
|
+
const model = message.model ?? 'unknown';
|
|
15
|
+
const usage = message.usage;
|
|
16
|
+
if (!usage)
|
|
17
|
+
return null;
|
|
18
|
+
const inputTokens = usage.input_tokens ?? 0;
|
|
19
|
+
const outputTokens = usage.output_tokens ?? 0;
|
|
20
|
+
const cacheCreationTokens = usage.cache_creation_input_tokens ?? 0;
|
|
21
|
+
const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
|
|
22
|
+
// Skip entries with no useful token data
|
|
23
|
+
if (inputTokens === 0 && outputTokens === 0)
|
|
24
|
+
return null;
|
|
25
|
+
return {
|
|
26
|
+
model,
|
|
27
|
+
sessionId,
|
|
28
|
+
lineIndex,
|
|
29
|
+
inputTokens,
|
|
30
|
+
outputTokens,
|
|
31
|
+
cacheCreationTokens,
|
|
32
|
+
cacheReadTokens,
|
|
33
|
+
timestamp: entry.timestamp,
|
|
34
|
+
};
|
|
35
|
+
}
|
package/dist/watcher.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { loadState, saveState, getClaudeProjectsDir, } from './config.js';
|
|
5
|
+
import { parseClaudeCodeLine } from './parsers/claude-code.js';
|
|
6
|
+
import { ingest, heartbeat } from './api.js';
|
|
7
|
+
const POLL_MS = 30_000; // scan every 30s
|
|
8
|
+
const HEARTBEAT_MS = 2 * 60_000; // heartbeat every 2min
|
|
9
|
+
const VERSION = '0.1.0';
|
|
10
|
+
function findJsonlFiles(dir) {
|
|
11
|
+
if (!existsSync(dir))
|
|
12
|
+
return [];
|
|
13
|
+
const files = [];
|
|
14
|
+
try {
|
|
15
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
16
|
+
if (!entry.isDirectory())
|
|
17
|
+
continue;
|
|
18
|
+
const sub = join(dir, entry.name);
|
|
19
|
+
for (const f of readdirSync(sub)) {
|
|
20
|
+
if (f.endsWith('.jsonl'))
|
|
21
|
+
files.push(join(sub, f));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch { /* unreadable dir */ }
|
|
26
|
+
return files;
|
|
27
|
+
}
|
|
28
|
+
async function processFile(filePath, state, cfg, counters) {
|
|
29
|
+
let size;
|
|
30
|
+
try {
|
|
31
|
+
size = statSync(filePath).size;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const prev = state.files[filePath] ?? { bytesRead: 0, lineIndex: 0 };
|
|
37
|
+
if (size <= prev.bytesRead)
|
|
38
|
+
return; // no new bytes
|
|
39
|
+
const sessionId = basename(filePath, '.jsonl');
|
|
40
|
+
let content;
|
|
41
|
+
try {
|
|
42
|
+
content = readFileSync(filePath, 'utf-8');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const lines = content.split('\n');
|
|
48
|
+
for (let i = prev.lineIndex; i < lines.length; i++) {
|
|
49
|
+
const line = lines[i].trim();
|
|
50
|
+
if (!line)
|
|
51
|
+
continue;
|
|
52
|
+
const event = parseClaudeCodeLine(line, sessionId, i);
|
|
53
|
+
if (!event)
|
|
54
|
+
continue;
|
|
55
|
+
const ok = await ingest(cfg, {
|
|
56
|
+
source: 'local-scanner',
|
|
57
|
+
model: event.model,
|
|
58
|
+
eventId: `${sessionId}:${i}`,
|
|
59
|
+
inputTokens: event.inputTokens,
|
|
60
|
+
outputTokens: event.outputTokens,
|
|
61
|
+
cacheCreationTokens: event.cacheCreationTokens || undefined,
|
|
62
|
+
cacheReadTokens: event.cacheReadTokens || undefined,
|
|
63
|
+
sessionId,
|
|
64
|
+
agentTool: 'claude-code',
|
|
65
|
+
timestamp: event.timestamp,
|
|
66
|
+
});
|
|
67
|
+
if (ok)
|
|
68
|
+
counters.sent++;
|
|
69
|
+
else
|
|
70
|
+
counters.errors++;
|
|
71
|
+
}
|
|
72
|
+
state.files[filePath] = { bytesRead: size, lineIndex: lines.length };
|
|
73
|
+
}
|
|
74
|
+
export async function startWatcher(cfg) {
|
|
75
|
+
const projectsDir = getClaudeProjectsDir();
|
|
76
|
+
const counters = { sent: 0, errors: 0 };
|
|
77
|
+
let lastHeartbeat = 0;
|
|
78
|
+
console.log(`[costpilot-scanner] v${VERSION} — watching ${projectsDir}`);
|
|
79
|
+
console.log('[costpilot-scanner] Press Ctrl+C to stop\n');
|
|
80
|
+
async function poll() {
|
|
81
|
+
const state = loadState();
|
|
82
|
+
for (const file of findJsonlFiles(projectsDir)) {
|
|
83
|
+
await processFile(file, state, cfg, counters);
|
|
84
|
+
}
|
|
85
|
+
saveState(state);
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
if (now - lastHeartbeat > HEARTBEAT_MS) {
|
|
88
|
+
lastHeartbeat = now;
|
|
89
|
+
const tools = existsSync(projectsDir) ? ['claude-code'] : [];
|
|
90
|
+
await heartbeat(cfg, {
|
|
91
|
+
source: 'local-scanner',
|
|
92
|
+
version: VERSION,
|
|
93
|
+
hostname: hostname(),
|
|
94
|
+
toolsDetected: tools,
|
|
95
|
+
eventsLast24h: counters.sent,
|
|
96
|
+
errorsLast24h: counters.errors,
|
|
97
|
+
});
|
|
98
|
+
process.stdout.write(`[costpilot-scanner] heartbeat — ${counters.sent} events sent, ${counters.errors} errors\n`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Run immediately on start
|
|
102
|
+
await poll();
|
|
103
|
+
setInterval(() => { poll().catch(console.error); }, POLL_MS);
|
|
104
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "costpilot-scanner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CostPilot local scanner — watches Claude Code, Cursor, and Windsurf usage and syncs to CostPilot",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"costpilot-scanner": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"dev": "tsx src/index.ts",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/solemn-cap/ai-cost-pilot.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://aicostpilot.online",
|
|
24
|
+
"keywords": ["claude-code", "ai", "cost", "scanner", "tracking", "costpilot"],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^20.14.12",
|
|
30
|
+
"tsx": "^4.16.2",
|
|
31
|
+
"typescript": "^5.5.4"
|
|
32
|
+
}
|
|
33
|
+
}
|