Skip to content

Commit dfa64f8

Browse files
committed
Add local provider reader foundation
1 parent 790637d commit dfa64f8

9 files changed

Lines changed: 347 additions & 10 deletions

File tree

.vscodeignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,5 @@ assets/**
2121
!out/*.js.map
2222
!out/core/*.js
2323
!out/core/*.js.map
24+
!out/providers/*.js
25+
!out/providers/*.js.map

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
"validate:install": "powershell -NoProfile -ExecutionPolicy Bypass -File ./tools/dev-validate-install.ps1",
109109
"validate:manifest": "node scripts/validate-extension-manifest.cjs",
110110
"smoke:core": "node scripts/smoke-core.cjs",
111+
"smoke:providers": "node scripts/smoke-providers.cjs",
111112
"package": "npx --yes @vscode/vsce package --no-dependencies",
112113
"test": "node ./out/test/runTest.js"
113114
},

scripts/smoke-providers.cjs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
'use strict';
2+
3+
const path = require('path');
4+
const os = require('os');
5+
const assert = require('assert');
6+
7+
const OUT = path.resolve(__dirname, '../out');
8+
9+
const { ClaudeLocalReader } = require(path.join(OUT, 'providers/claudeLocal'));
10+
const { CodexLocalReader } = require(path.join(OUT, 'providers/codexLocal'));
11+
const { runEnabledReaders } = require(path.join(OUT, 'providers/readProviders'));
12+
const { formatRefreshSummary } = require(path.join(OUT, 'core/formatQuota'));
13+
14+
let pass = 0;
15+
let fail = 0;
16+
17+
async function test(name, fn) {
18+
try {
19+
await fn();
20+
console.log(` PASS ${name}`);
21+
pass++;
22+
} catch (e) {
23+
console.error(` FAIL ${name}: ${e.message}`);
24+
fail++;
25+
}
26+
}
27+
28+
const ABSENT = path.join(os.tmpdir(), `pf-smoke-absent-${Date.now()}`);
29+
30+
async function main() {
31+
// --- ClaudeLocalReader ---
32+
33+
await test('ClaudeLocalReader: returns not-found for absent path', async () => {
34+
const reader = new ClaudeLocalReader(ABSENT);
35+
const result = await reader.read();
36+
assert.strictEqual(result.providerId, 'claude');
37+
assert.strictEqual(result.status, 'not-found');
38+
});
39+
40+
await test('ClaudeLocalReader: providerId is claude', async () => {
41+
const reader = new ClaudeLocalReader(ABSENT);
42+
assert.strictEqual(reader.providerId, 'claude');
43+
});
44+
45+
// --- CodexLocalReader ---
46+
47+
await test('CodexLocalReader: returns not-found for absent path', async () => {
48+
const reader = new CodexLocalReader(ABSENT);
49+
const result = await reader.read();
50+
assert.strictEqual(result.providerId, 'codex');
51+
assert.strictEqual(result.status, 'not-found');
52+
});
53+
54+
await test('CodexLocalReader: providerId is codex', async () => {
55+
const reader = new CodexLocalReader(ABSENT);
56+
assert.strictEqual(reader.providerId, 'codex');
57+
});
58+
59+
// --- runEnabledReaders ---
60+
61+
await test('runEnabledReaders: empty enabledProviders returns empty array', async () => {
62+
const readers = [new ClaudeLocalReader(ABSENT), new CodexLocalReader(ABSENT)];
63+
const results = await runEnabledReaders(readers, []);
64+
assert.strictEqual(results.length, 0);
65+
});
66+
67+
await test('runEnabledReaders: only enabled providers run', async () => {
68+
const readers = [new ClaudeLocalReader(ABSENT), new CodexLocalReader(ABSENT)];
69+
const results = await runEnabledReaders(readers, ['claude']);
70+
assert.strictEqual(results.length, 1);
71+
assert.strictEqual(results[0].providerId, 'claude');
72+
});
73+
74+
await test('runEnabledReaders: unknown provider id is skipped', async () => {
75+
const readers = [new ClaudeLocalReader(ABSENT)];
76+
const results = await runEnabledReaders(readers, ['openai']);
77+
assert.strictEqual(results.length, 0);
78+
});
79+
80+
await test('runEnabledReaders: both providers run when both enabled', async () => {
81+
const readers = [new ClaudeLocalReader(ABSENT), new CodexLocalReader(ABSENT)];
82+
const results = await runEnabledReaders(readers, ['claude', 'codex']);
83+
assert.strictEqual(results.length, 2);
84+
const ids = results.map(r => r.providerId).sort();
85+
assert.deepStrictEqual(ids, ['claude', 'codex']);
86+
});
87+
88+
// --- formatRefreshSummary ---
89+
90+
await test('formatRefreshSummary: empty results returns no-providers message', async () => {
91+
const s = formatRefreshSummary([]);
92+
assert.ok(s.includes('no providers'), `expected "no providers" in "${s}"`);
93+
});
94+
95+
await test('formatRefreshSummary: not-found includes label and "not found"', async () => {
96+
const s = formatRefreshSummary([{ providerId: 'claude', status: 'not-found' }]);
97+
assert.ok(s.includes('Claude'), `expected "Claude" in "${s}"`);
98+
assert.ok(s.includes('not found'), `expected "not found" in "${s}"`);
99+
});
100+
101+
await test('formatRefreshSummary: ok includes file count', async () => {
102+
const s = formatRefreshSummary([{ providerId: 'claude', status: 'ok', filesFound: 5 }]);
103+
assert.ok(s.includes('Claude'), `expected "Claude" in "${s}"`);
104+
assert.ok(s.includes('5'), `expected "5" in "${s}"`);
105+
});
106+
107+
await test('formatRefreshSummary: ok with 1 file uses singular', async () => {
108+
const s = formatRefreshSummary([{ providerId: 'claude', status: 'ok', filesFound: 1 }]);
109+
assert.ok(s.includes('1 session file'), `expected singular "1 session file" in "${s}"`);
110+
assert.ok(!s.includes('1 session files'), `expected no plural "1 session files" in "${s}"`);
111+
});
112+
113+
await test('formatRefreshSummary: no-data includes label and "no session files"', async () => {
114+
const s = formatRefreshSummary([{ providerId: 'codex', status: 'no-data', filesFound: 0 }]);
115+
assert.ok(s.includes('Codex'), `expected "Codex" in "${s}"`);
116+
assert.ok(s.includes('no session files'), `expected "no session files" in "${s}"`);
117+
});
118+
119+
await test('formatRefreshSummary: error includes label and "read error"', async () => {
120+
const s = formatRefreshSummary([{ providerId: 'claude', status: 'error' }]);
121+
assert.ok(s.includes('Claude'), `expected "Claude" in "${s}"`);
122+
assert.ok(s.includes('read error'), `expected "read error" in "${s}"`);
123+
});
124+
125+
await test('formatRefreshSummary: mixed states joined with " | "', async () => {
126+
const s = formatRefreshSummary([
127+
{ providerId: 'claude', status: 'ok', filesFound: 3 },
128+
{ providerId: 'codex', status: 'not-found' },
129+
]);
130+
assert.ok(s.includes(' | '), `expected " | " separator in "${s}"`);
131+
});
132+
133+
console.log('');
134+
console.log(`smoke-providers: ${pass} passed, ${fail} failed`);
135+
if (fail > 0) {
136+
process.exit(1);
137+
}
138+
}
139+
140+
main().catch(e => {
141+
console.error('smoke-providers: fatal error:', e);
142+
process.exit(1);
143+
});

src/core/formatQuota.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { PROVIDER_LABELS, ProviderId } from './providers';
22
import { ProviderQuotaState } from './quotaTypes';
3+
import { ReadResult } from './providerReader';
34

45
export function formatProviderText(state: ProviderQuotaState): string {
56
const label = PROVIDER_LABELS[state.providerId as ProviderId] ?? state.providerId;
@@ -21,3 +22,27 @@ export function formatStatusBarText(states: ProviderQuotaState[]): string {
2122
}
2223
return `⛽ ${parts.join(' | ')}`;
2324
}
25+
26+
export function formatRefreshSummary(results: ReadResult[]): string {
27+
if (results.length === 0) {
28+
return 'no providers enabled';
29+
}
30+
const parts = results.map(r => {
31+
const label = PROVIDER_LABELS[r.providerId as ProviderId] ?? r.providerId;
32+
switch (r.status) {
33+
case 'ok': {
34+
const n = r.filesFound ?? 0;
35+
return `${label}: ${n} session file${n !== 1 ? 's' : ''}`;
36+
}
37+
case 'no-data':
38+
return `${label}: no session files`;
39+
case 'not-found':
40+
return `${label}: not found`;
41+
case 'error':
42+
return `${label}: read error`;
43+
default:
44+
return `${label}: unknown`;
45+
}
46+
});
47+
return parts.join(' | ');
48+
}

src/core/providerReader.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type ReadResultStatus = 'ok' | 'not-found' | 'no-data' | 'error';
2+
3+
export interface ReadResult {
4+
providerId: string;
5+
status: ReadResultStatus;
6+
filesFound?: number;
7+
detail?: string;
8+
}
9+
10+
export interface ProviderReader {
11+
readonly providerId: string;
12+
read(): Promise<ReadResult>;
13+
}

src/extension.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,67 @@
11
import * as vscode from 'vscode';
22
import { getConfig } from './config';
33
import { getDataFolderUri } from './dataFolder';
4-
import { formatStatusBarText } from './core/formatQuota';
5-
import { ProviderQuotaState } from './core/quotaTypes';
4+
import { formatRefreshSummary, formatStatusBarText } from './core/formatQuota';
5+
import { ReadResult } from './core/providerReader';
6+
import { ProviderQuotaState, ProviderQuotaStatus } from './core/quotaTypes';
7+
import { ClaudeLocalReader } from './providers/claudeLocal';
8+
import { CodexLocalReader } from './providers/codexLocal';
9+
import { runEnabledReaders } from './providers/readProviders';
610

711
let statusBarItem: vscode.StatusBarItem | undefined;
812

9-
function buildPlaceholderStates(enabledProviders: string[]): ProviderQuotaState[] {
10-
return enabledProviders.map(id => ({ providerId: id, status: 'no-data' as const }));
13+
function resultToQuotaState(result: ReadResult): ProviderQuotaState {
14+
const status: ProviderQuotaStatus = result.status === 'error' ? 'unknown' : 'no-data';
15+
return { providerId: result.providerId, status };
1116
}
1217

13-
function refreshStatusBar(): void {
18+
function initStatusBar(): void {
1419
if (!statusBarItem) {
1520
return;
1621
}
1722
const cfg = getConfig();
18-
const states = buildPlaceholderStates(cfg.enabledProviders);
23+
const states: ProviderQuotaState[] = cfg.enabledProviders.map(id => ({
24+
providerId: id,
25+
status: 'no-data' as const,
26+
}));
1927
statusBarItem.text = formatStatusBarText(states);
20-
statusBarItem.tooltip = 'PromptFuel — provider data not yet implemented';
28+
statusBarItem.tooltip = 'PromptFuel — loading...';
29+
}
30+
31+
async function runRefresh(): Promise<void> {
32+
if (!statusBarItem) {
33+
return;
34+
}
35+
const cfg = getConfig();
36+
const readers = [new ClaudeLocalReader(), new CodexLocalReader()];
37+
38+
let results: ReadResult[];
39+
try {
40+
results = await runEnabledReaders(readers, cfg.enabledProviders);
41+
} catch {
42+
results = cfg.enabledProviders.map(id => ({ providerId: id, status: 'error' as const }));
43+
}
44+
45+
const states = results.map(resultToQuotaState);
46+
statusBarItem.text = formatStatusBarText(states);
47+
statusBarItem.tooltip = 'PromptFuel — click to open dashboard';
48+
49+
const summary = formatRefreshSummary(results);
50+
vscode.window.showInformationMessage(`PromptFuel: ${summary}`);
2151
}
2252

2353
export function activate(context: vscode.ExtensionContext) {
2454
statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 10);
2555
statusBarItem.command = 'promptFuel.openDashboard';
2656
statusBarItem.show();
27-
refreshStatusBar();
57+
initStatusBar();
2858

2959
const openDashboard = vscode.commands.registerCommand('promptFuel.openDashboard', () => {
3060
vscode.window.showInformationMessage('PromptFuel: Usage Dashboard — coming soon.');
3161
});
3262

3363
const refresh = vscode.commands.registerCommand('promptFuel.refresh', () => {
34-
refreshStatusBar();
35-
vscode.window.showInformationMessage('PromptFuel: refreshed.');
64+
void runRefresh();
3665
});
3766

3867
const openDataFolder = vscode.commands.registerCommand('promptFuel.openDataFolder', () => {

src/providers/claudeLocal.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as fsp from 'node:fs/promises';
2+
import * as os from 'node:os';
3+
import * as path from 'node:path';
4+
import { ProviderReader, ReadResult } from '../core/providerReader';
5+
6+
export function defaultClaudeProjectsPath(): string {
7+
return path.join(os.homedir(), '.claude', 'projects');
8+
}
9+
10+
export class ClaudeLocalReader implements ProviderReader {
11+
readonly providerId = 'claude';
12+
private readonly projectsRoot: string;
13+
14+
constructor(projectsRoot?: string) {
15+
this.projectsRoot = projectsRoot ?? defaultClaudeProjectsPath();
16+
}
17+
18+
async read(): Promise<ReadResult> {
19+
try {
20+
await fsp.access(this.projectsRoot);
21+
} catch {
22+
return { providerId: 'claude', status: 'not-found' };
23+
}
24+
25+
const count = await countJsonlFiles(this.projectsRoot, 4);
26+
if (count === 0) {
27+
return { providerId: 'claude', status: 'no-data', filesFound: 0 };
28+
}
29+
return { providerId: 'claude', status: 'ok', filesFound: count };
30+
}
31+
}
32+
33+
async function countJsonlFiles(root: string, maxDepth: number): Promise<number> {
34+
let count = 0;
35+
36+
async function walk(dir: string, depth: number): Promise<void> {
37+
if (depth > maxDepth) {
38+
return;
39+
}
40+
let entries;
41+
try {
42+
entries = await fsp.readdir(dir, { withFileTypes: true });
43+
} catch {
44+
return;
45+
}
46+
for (const entry of entries) {
47+
if (entry.isDirectory()) {
48+
await walk(path.join(dir, entry.name), depth + 1);
49+
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
50+
count++;
51+
}
52+
}
53+
}
54+
55+
await walk(root, 0);
56+
return count;
57+
}

0 commit comments

Comments
 (0)