@sumant.pathak/devjar 1.0.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/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/devjar.js +55 -0
- package/package.json +35 -0
- package/src/config.js +103 -0
- package/src/init.js +388 -0
- package/src/prompt.js +135 -0
- package/src/providers/anthropic.js +27 -0
- package/src/providers/gemini.js +37 -0
- package/src/providers/index.js +54 -0
- package/src/providers/ollama.js +40 -0
- package/src/providers/openai.js +37 -0
- package/src/stats.js +173 -0
- package/src/update.js +235 -0
package/src/init.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// devjar init — 3-phase project scanner
|
|
2
|
+
// Phase 1: Static analysis (offline)
|
|
3
|
+
// Phase 2: Pattern extraction via single Haiku call
|
|
4
|
+
// Phase 3: Write CLAUDE.md
|
|
5
|
+
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
|
|
11
|
+
// ── Constants ──────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const IGNORE_DIRS = new Set([
|
|
14
|
+
'node_modules', '.git', 'dist', '.next', 'out', 'build',
|
|
15
|
+
'.cache', 'coverage', '__pycache__', '.turbo', '.vercel', 'public'
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const BINARY_EXTS = new Set([
|
|
19
|
+
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.webp',
|
|
20
|
+
'.woff', '.woff2', '.ttf', '.eot', '.mp4', '.mp3', '.pdf',
|
|
21
|
+
'.zip', '.exe', '.dll', '.bin', '.map'
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const SKIP_FILES = new Set([
|
|
25
|
+
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb',
|
|
26
|
+
'.env', '.env.local', '.env.production', '.DS_Store'
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// Entry point patterns — ordered by priority
|
|
30
|
+
const ENTRY_PATTERNS = [
|
|
31
|
+
'src/index.js', 'src/index.ts', 'src/index.jsx', 'src/index.tsx',
|
|
32
|
+
'src/main.js', 'src/main.ts', 'src/main.jsx', 'src/main.tsx',
|
|
33
|
+
'src/App.jsx', 'src/App.tsx', 'src/app.js',
|
|
34
|
+
'server/index.js', 'workers/index.js',
|
|
35
|
+
'index.js', 'main.js', 'app.js', 'server.js',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// ── Spinner ────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function spinner(msg) {
|
|
41
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
42
|
+
let i = 0;
|
|
43
|
+
const id = setInterval(() => {
|
|
44
|
+
process.stdout.write(`\r${chalk.cyan(frames[i++ % frames.length])} ${chalk.yellow(msg)}`);
|
|
45
|
+
}, 80);
|
|
46
|
+
return () => {
|
|
47
|
+
clearInterval(id);
|
|
48
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Phase 1: Static Analysis ───────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export function walkDir(dir, baseDir, depth = 0, maxDepth = 2) {
|
|
55
|
+
const entries = [];
|
|
56
|
+
if (depth > maxDepth) return entries;
|
|
57
|
+
let items;
|
|
58
|
+
try { items = fs.readdirSync(dir, { withFileTypes: true }); } catch { return entries; }
|
|
59
|
+
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
if (item.name.startsWith('.') && item.name !== '.env.example') continue;
|
|
62
|
+
if (IGNORE_DIRS.has(item.name)) continue;
|
|
63
|
+
|
|
64
|
+
const fullPath = path.join(dir, item.name);
|
|
65
|
+
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
|
|
66
|
+
|
|
67
|
+
if (item.isDirectory()) {
|
|
68
|
+
entries.push({ type: 'dir', path: relPath, name: item.name });
|
|
69
|
+
entries.push(...walkDir(fullPath, baseDir, depth + 1, maxDepth));
|
|
70
|
+
} else if (item.isFile()) {
|
|
71
|
+
const ext = path.extname(item.name).toLowerCase();
|
|
72
|
+
if (BINARY_EXTS.has(ext) || SKIP_FILES.has(item.name)) continue;
|
|
73
|
+
const size = fs.statSync(fullPath).size;
|
|
74
|
+
entries.push({ type: 'file', path: relPath, name: item.name, size });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readJsonSafe(filePath) {
|
|
81
|
+
try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readFileSafe(filePath) {
|
|
85
|
+
try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function detectStack(pkg, files, hasWrangler) {
|
|
89
|
+
if (!pkg) return { name: 'Unknown', stack: [], scripts: {} };
|
|
90
|
+
|
|
91
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
92
|
+
const stack = [];
|
|
93
|
+
|
|
94
|
+
if (deps['next']) stack.push('Next.js');
|
|
95
|
+
else if (deps['react']) stack.push('React');
|
|
96
|
+
if (deps['vue']) stack.push('Vue');
|
|
97
|
+
if (deps['svelte']) stack.push('Svelte');
|
|
98
|
+
if (deps['vite']) stack.push('Vite');
|
|
99
|
+
if (deps['typescript'] || deps['ts-node']) stack.push('TypeScript');
|
|
100
|
+
if (deps['express']) stack.push('Express');
|
|
101
|
+
if (deps['fastify']) stack.push('Fastify');
|
|
102
|
+
if (deps['hono']) stack.push('Hono');
|
|
103
|
+
if (hasWrangler || deps['wrangler']) stack.push('Cloudflare Workers');
|
|
104
|
+
if (deps['@prisma/client']) stack.push('Prisma');
|
|
105
|
+
if (deps['drizzle-orm']) stack.push('Drizzle ORM');
|
|
106
|
+
if (deps['tailwindcss']) stack.push('Tailwind CSS');
|
|
107
|
+
if (stack.length === 0) stack.push('Node.js');
|
|
108
|
+
|
|
109
|
+
return { name: pkg.name || 'project', stack, scripts: pkg.scripts || {} };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseWranglerToml(content) {
|
|
113
|
+
const bindings = {};
|
|
114
|
+
const lines = content.split('\n');
|
|
115
|
+
let section = '';
|
|
116
|
+
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
const trimmed = line.trim();
|
|
119
|
+
if (trimmed.startsWith('[')) section = trimmed;
|
|
120
|
+
|
|
121
|
+
// D1 databases
|
|
122
|
+
if (section.includes('d1_databases')) {
|
|
123
|
+
const bMatch = trimmed.match(/binding\s*=\s*"([^"]+)"/);
|
|
124
|
+
if (bMatch) bindings.D1 = (bindings.D1 || []).concat(bMatch[1]);
|
|
125
|
+
}
|
|
126
|
+
// KV namespaces
|
|
127
|
+
if (section.includes('kv_namespaces')) {
|
|
128
|
+
const bMatch = trimmed.match(/binding\s*=\s*"([^"]+)"/);
|
|
129
|
+
if (bMatch) bindings.KV = (bindings.KV || []).concat(bMatch[1]);
|
|
130
|
+
}
|
|
131
|
+
// R2
|
|
132
|
+
if (section.includes('r2_buckets')) {
|
|
133
|
+
const bMatch = trimmed.match(/binding\s*=\s*"([^"]+)"/);
|
|
134
|
+
if (bMatch) bindings.R2 = (bindings.R2 || []).concat(bMatch[1]);
|
|
135
|
+
}
|
|
136
|
+
// Routes
|
|
137
|
+
const routeMatch = trimmed.match(/^pattern\s*=\s*"([^"]+)"/);
|
|
138
|
+
if (routeMatch) bindings.routes = (bindings.routes || []).concat(routeMatch[1]);
|
|
139
|
+
// Name
|
|
140
|
+
const nameMatch = trimmed.match(/^name\s*=\s*"([^"]+)"/);
|
|
141
|
+
if (nameMatch && !bindings.workerName) bindings.workerName = nameMatch[1];
|
|
142
|
+
}
|
|
143
|
+
return bindings;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function buildFileTree(entries) {
|
|
147
|
+
const lines = [];
|
|
148
|
+
for (const e of entries) {
|
|
149
|
+
const depth = e.path.split('/').length - 1;
|
|
150
|
+
const indent = ' '.repeat(depth);
|
|
151
|
+
const icon = e.type === 'dir' ? '📁' : '📄';
|
|
152
|
+
lines.push(`${indent}${icon} ${e.name}`);
|
|
153
|
+
}
|
|
154
|
+
return lines.join('\n');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Phase 2: Key File Selection ─────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export function pickKeyFiles(baseDir, allFiles) {
|
|
160
|
+
const selected = [];
|
|
161
|
+
|
|
162
|
+
// 1. Entry point
|
|
163
|
+
for (const pattern of ENTRY_PATTERNS) {
|
|
164
|
+
if (allFiles.some(f => f.path === pattern)) {
|
|
165
|
+
selected.push(pattern);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 2. Largest file in src/ (likely most complex component/route)
|
|
171
|
+
const srcFiles = allFiles
|
|
172
|
+
.filter(f => f.type === 'file' && f.path.startsWith('src/') && !selected.includes(f.path))
|
|
173
|
+
.sort((a, b) => b.size - a.size);
|
|
174
|
+
if (srcFiles[0]) selected.push(srcFiles[0].path);
|
|
175
|
+
if (srcFiles[1] && srcFiles[1].path !== srcFiles[0]?.path) selected.push(srcFiles[1].path);
|
|
176
|
+
|
|
177
|
+
// 3. Main route/api file
|
|
178
|
+
const routeFile = allFiles.find(f =>
|
|
179
|
+
f.type === 'file' && (
|
|
180
|
+
f.path.includes('routes/auth') || f.path.includes('routes/index') ||
|
|
181
|
+
f.path.includes('api/index') || f.path.includes('pages/api') ||
|
|
182
|
+
f.path.includes('router') || f.path.includes('routes.')
|
|
183
|
+
) && !selected.includes(f.path)
|
|
184
|
+
);
|
|
185
|
+
if (routeFile) selected.push(routeFile.path);
|
|
186
|
+
|
|
187
|
+
// Read contents (truncated to ~400 chars each to stay under token limit)
|
|
188
|
+
const fileData = [];
|
|
189
|
+
for (const relPath of selected.slice(0, 5)) {
|
|
190
|
+
const content = readFileSafe(path.join(baseDir, relPath));
|
|
191
|
+
if (content) {
|
|
192
|
+
fileData.push(`// FILE: ${relPath}\n${content.slice(0, 400)}${content.length > 400 ? '\n// [truncated]' : ''}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { paths: selected, contents: fileData };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Phase 2: Haiku Call ─────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
export async function extractPatterns(fileContents) {
|
|
201
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
202
|
+
if (!apiKey) return null;
|
|
203
|
+
|
|
204
|
+
const client = new Anthropic({ apiKey });
|
|
205
|
+
|
|
206
|
+
const userMsg = `Analyze these project files and extract coding patterns.
|
|
207
|
+
|
|
208
|
+
${fileContents.join('\n\n---\n\n')}
|
|
209
|
+
|
|
210
|
+
Return ONLY valid JSON, no markdown:
|
|
211
|
+
{
|
|
212
|
+
"naming": "description of naming convention used",
|
|
213
|
+
"imports": "named/default/mixed — describe pattern",
|
|
214
|
+
"errorHandling": "how errors are handled",
|
|
215
|
+
"stateManagement": "how state is managed",
|
|
216
|
+
"rules": [
|
|
217
|
+
"rule 1 — must follow exactly",
|
|
218
|
+
"rule 2",
|
|
219
|
+
"rule 3",
|
|
220
|
+
"rule 4",
|
|
221
|
+
"rule 5"
|
|
222
|
+
]
|
|
223
|
+
}`;
|
|
224
|
+
|
|
225
|
+
const msg = await client.messages.create({
|
|
226
|
+
model: 'claude-haiku-4-5-20251001',
|
|
227
|
+
max_tokens: 600,
|
|
228
|
+
messages: [{ role: 'user', content: userMsg }],
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const text = msg.content[0].text.trim();
|
|
233
|
+
const jsonStr = text.startsWith('{') ? text : text.slice(text.indexOf('{'));
|
|
234
|
+
return JSON.parse(jsonStr);
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Phase 3: Write CLAUDE.md ───────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
function generateClaudeMd(opts) {
|
|
243
|
+
const { projectName, stack, scripts, allFiles, wranglerBindings, keyFilePaths, patterns, fileTree } = opts;
|
|
244
|
+
|
|
245
|
+
const stackLine = stack.length ? stack.join(' + ') : 'Node.js';
|
|
246
|
+
|
|
247
|
+
// File map — key files with purpose guesses
|
|
248
|
+
const fileMapLines = [];
|
|
249
|
+
for (const f of allFiles.filter(e => e.type === 'file').slice(0, 30)) {
|
|
250
|
+
fileMapLines.push(` ${f.path}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Scripts section
|
|
254
|
+
const scriptLines = Object.entries(scripts)
|
|
255
|
+
.map(([k, v]) => ` ${k}: ${v}`)
|
|
256
|
+
.join('\n');
|
|
257
|
+
|
|
258
|
+
// Rules
|
|
259
|
+
const rules = patterns?.rules || [
|
|
260
|
+
'Match existing naming conventions before adding new code',
|
|
261
|
+
'Never write files outside the project root',
|
|
262
|
+
'Read the key files listed in Project Map before editing',
|
|
263
|
+
];
|
|
264
|
+
const rulesText = rules.slice(0, 7).map((r, i) => `${i + 1}. ${r}`).join('\n');
|
|
265
|
+
|
|
266
|
+
// Bindings
|
|
267
|
+
let bindingsSection = '';
|
|
268
|
+
if (wranglerBindings && Object.keys(wranglerBindings).length) {
|
|
269
|
+
const lines = [];
|
|
270
|
+
if (wranglerBindings.workerName) lines.push(`Worker: ${wranglerBindings.workerName}`);
|
|
271
|
+
if (wranglerBindings.D1) lines.push(`D1 (binding): ${wranglerBindings.D1.join(', ')}`);
|
|
272
|
+
if (wranglerBindings.KV) lines.push(`KV (binding): ${wranglerBindings.KV.join(', ')}`);
|
|
273
|
+
if (wranglerBindings.R2) lines.push(`R2 (binding): ${wranglerBindings.R2.join(', ')}`);
|
|
274
|
+
if (wranglerBindings.routes) lines.push(`Routes: ${wranglerBindings.routes.join(', ')}`);
|
|
275
|
+
bindingsSection = `\n## Bindings & Config\n${lines.join('\n')}\n`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Key patterns
|
|
279
|
+
const patternsSection = patterns ? `
|
|
280
|
+
## Key Patterns
|
|
281
|
+
- **Naming**: ${patterns.naming}
|
|
282
|
+
- **Imports**: ${patterns.imports}
|
|
283
|
+
- **Error handling**: ${patterns.errorHandling}
|
|
284
|
+
- **State management**: ${patterns.stateManagement}
|
|
285
|
+
` : '';
|
|
286
|
+
|
|
287
|
+
return `# ${projectName} — Claude Code Orientation
|
|
288
|
+
|
|
289
|
+
## Stack
|
|
290
|
+
${stackLine}
|
|
291
|
+
|
|
292
|
+
${scriptLines ? `## Scripts\n${scriptLines}\n` : ''}
|
|
293
|
+
## Project Map
|
|
294
|
+
${fileTree}
|
|
295
|
+
|
|
296
|
+
## Key Files Analyzed
|
|
297
|
+
${keyFilePaths.map(p => `- ${p}`).join('\n')}
|
|
298
|
+
${patternsSection}
|
|
299
|
+
## Unbreakable Rules
|
|
300
|
+
${rulesText}
|
|
301
|
+
${bindingsSection}
|
|
302
|
+
---
|
|
303
|
+
*Generated by devjar init — do not edit manually, run \`devjar init\` to regenerate*
|
|
304
|
+
`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Main Export ────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
export async function init(dir = '.', options = {}) {
|
|
310
|
+
const baseDir = path.resolve(dir);
|
|
311
|
+
const maxDepth = parseInt(options.depth || '2', 10);
|
|
312
|
+
|
|
313
|
+
if (!fs.existsSync(baseDir)) {
|
|
314
|
+
console.error(chalk.red(`✗ Directory not found: ${baseDir}`));
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
console.log(chalk.bold.blue('⬡ devjar') + chalk.gray(` init → ${baseDir}`));
|
|
319
|
+
console.log(chalk.gray('─'.repeat(52)));
|
|
320
|
+
|
|
321
|
+
// ── Phase 1 ──
|
|
322
|
+
console.log(chalk.bold.white('Phase 1') + chalk.gray(' Scanning project structure...'));
|
|
323
|
+
|
|
324
|
+
const allEntries = walkDir(baseDir, baseDir, 0, maxDepth);
|
|
325
|
+
const fileCount = allEntries.filter(e => e.type === 'file').length;
|
|
326
|
+
|
|
327
|
+
const pkg = readJsonSafe(path.join(baseDir, 'package.json'));
|
|
328
|
+
const wranglerRaw = readFileSafe(path.join(baseDir, 'wrangler.toml'));
|
|
329
|
+
const wranglerBindings = wranglerRaw ? parseWranglerToml(wranglerRaw) : null;
|
|
330
|
+
|
|
331
|
+
const { name: projectName, stack, scripts } = detectStack(pkg, allEntries, !!wranglerRaw);
|
|
332
|
+
const fileTree = buildFileTree(allEntries);
|
|
333
|
+
|
|
334
|
+
console.log(
|
|
335
|
+
chalk.green(' ✓') +
|
|
336
|
+
chalk.white(` ${fileCount} files`) +
|
|
337
|
+
chalk.gray(' | Stack: ') +
|
|
338
|
+
chalk.cyan(stack.join(' + ') || 'Node.js')
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// ── Phase 2 ──
|
|
342
|
+
const { paths: keyFilePaths, contents: keyFileContents } = pickKeyFiles(baseDir, allEntries);
|
|
343
|
+
let patterns = null;
|
|
344
|
+
|
|
345
|
+
console.log(chalk.bold.white('Phase 2') + chalk.gray(' Pattern extraction via Haiku...'));
|
|
346
|
+
if (keyFileContents.length > 0 && process.env.ANTHROPIC_API_KEY) {
|
|
347
|
+
const stop = spinner(' Calling Haiku...');
|
|
348
|
+
try {
|
|
349
|
+
patterns = await extractPatterns(keyFileContents);
|
|
350
|
+
} finally {
|
|
351
|
+
stop();
|
|
352
|
+
}
|
|
353
|
+
if (patterns) {
|
|
354
|
+
console.log(
|
|
355
|
+
chalk.green(' ✓') +
|
|
356
|
+
chalk.white(` ${patterns.rules?.length || 0} rules extracted`) +
|
|
357
|
+
chalk.gray(` from ${keyFilePaths.length} key files`)
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
console.log(chalk.yellow(' ⚠ Haiku response could not be parsed — using defaults'));
|
|
361
|
+
}
|
|
362
|
+
} else if (!process.env.ANTHROPIC_API_KEY) {
|
|
363
|
+
console.log(chalk.yellow(' ⚠ ANTHROPIC_API_KEY not set — skipping (offline mode)'));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Phase 3 ──
|
|
367
|
+
console.log(chalk.bold.white('Phase 3') + chalk.gray(' Writing CLAUDE.md...'));
|
|
368
|
+
|
|
369
|
+
const content = generateClaudeMd({
|
|
370
|
+
projectName, stack, scripts, allFiles: allEntries,
|
|
371
|
+
wranglerBindings, keyFilePaths, patterns, fileTree,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const outputPath = options.output
|
|
375
|
+
? path.resolve(options.output)
|
|
376
|
+
: path.join(baseDir, 'CLAUDE.md');
|
|
377
|
+
|
|
378
|
+
fs.writeFileSync(outputPath, content, 'utf8');
|
|
379
|
+
|
|
380
|
+
const ruleCount = patterns?.rules?.length || 3;
|
|
381
|
+
console.log(chalk.gray('─'.repeat(52)));
|
|
382
|
+
console.log(
|
|
383
|
+
chalk.bold.green('✓ CLAUDE.md generated') +
|
|
384
|
+
chalk.gray(` — ${fileCount} files scanned, `) +
|
|
385
|
+
chalk.white(`${ruleCount} rules extracted`)
|
|
386
|
+
);
|
|
387
|
+
console.log(chalk.gray(' → ') + chalk.underline.cyan(outputPath));
|
|
388
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// devjar prompt — STAR-C normalizer
|
|
2
|
+
// Provider-agnostic: works with ollama (free/local), anthropic, gemini, openai
|
|
3
|
+
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import readline from 'readline';
|
|
8
|
+
import clipboard from 'clipboardy';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { loadConfig, getProvider, buildSystemPrompt } from './providers/index.js';
|
|
11
|
+
import { runWizard } from './config.js';
|
|
12
|
+
|
|
13
|
+
const HISTORY_FILE = path.join(os.homedir(), '.devjar', 'history.json');
|
|
14
|
+
const MAX_HISTORY = 100;
|
|
15
|
+
|
|
16
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function findClaudeMd() {
|
|
19
|
+
for (const loc of [path.join(process.cwd(), 'CLAUDE.md'), path.join(process.cwd(), '..', 'CLAUDE.md')]) {
|
|
20
|
+
if (fs.existsSync(loc)) return fs.readFileSync(loc, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readProjectName() {
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).name || path.basename(process.cwd()); }
|
|
27
|
+
catch { return path.basename(process.cwd()); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function spinner(msg) {
|
|
31
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
32
|
+
let i = 0;
|
|
33
|
+
const id = setInterval(() => process.stdout.write(`\r${chalk.cyan(frames[i++ % frames.length])} ${chalk.yellow(msg)}`), 80);
|
|
34
|
+
return () => { clearInterval(id); process.stdout.write('\r' + ' '.repeat(60) + '\r'); };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ask(question) {
|
|
38
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
39
|
+
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function saveHistory(entry) {
|
|
43
|
+
try {
|
|
44
|
+
const dir = path.dirname(HISTORY_FILE);
|
|
45
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
46
|
+
let h = fs.existsSync(HISTORY_FILE) ? JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf8')) : [];
|
|
47
|
+
h.unshift(entry);
|
|
48
|
+
if (h.length > MAX_HISTORY) h = h.slice(0, MAX_HISTORY);
|
|
49
|
+
fs.writeFileSync(HISTORY_FILE, JSON.stringify(h, null, 2), 'utf8');
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function displayResult(structured) {
|
|
54
|
+
console.log();
|
|
55
|
+
console.log(chalk.bold.blue('─── STAR-C Prompt ') + chalk.gray('─'.repeat(34)));
|
|
56
|
+
const colors = { S: chalk.cyan, T: chalk.green, A: chalk.yellow, R: chalk.magenta, C: chalk.red };
|
|
57
|
+
for (const line of structured.split('\n')) {
|
|
58
|
+
const m = line.match(/^([STARC])\s*[—\-–]\s*(.*)/);
|
|
59
|
+
if (m) console.log((colors[m[1]] || chalk.white).bold(`${m[1]} —`) + chalk.white(` ${m[2]}`));
|
|
60
|
+
else if (line.trim()) console.log(chalk.gray(' ') + chalk.white(line));
|
|
61
|
+
}
|
|
62
|
+
console.log(chalk.gray('─'.repeat(52)));
|
|
63
|
+
console.log();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Main Export ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export async function prompt(textParts = [], options = {}) {
|
|
69
|
+
console.log(chalk.bold.blue('⬡ devjar') + chalk.gray(' prompt'));
|
|
70
|
+
console.log(chalk.gray('─'.repeat(52)));
|
|
71
|
+
|
|
72
|
+
// ── First run: no config → wizard ──
|
|
73
|
+
let config = loadConfig();
|
|
74
|
+
if (!config) {
|
|
75
|
+
console.log(chalk.yellow('No provider configured — running setup wizard...\n'));
|
|
76
|
+
config = await runWizard();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const providerLabel = chalk.cyan(config.provider) + chalk.gray(`/${config.model}`);
|
|
80
|
+
console.log(chalk.gray(' Provider: ') + providerLabel);
|
|
81
|
+
|
|
82
|
+
// ── Get input ──
|
|
83
|
+
let rawInput;
|
|
84
|
+
if (textParts.length > 0) {
|
|
85
|
+
rawInput = textParts.join(' ');
|
|
86
|
+
console.log(chalk.gray(' Input: ') + chalk.white(rawInput));
|
|
87
|
+
} else {
|
|
88
|
+
console.log(chalk.gray('\nInteractive mode — describe what you want to do:'));
|
|
89
|
+
rawInput = await ask(chalk.cyan('> '));
|
|
90
|
+
if (!rawInput) { console.log(chalk.yellow('No input. Exiting.')); process.exit(0); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Load context ──
|
|
94
|
+
const projectName = readProjectName();
|
|
95
|
+
const claudeMd = findClaudeMd();
|
|
96
|
+
if (claudeMd) {
|
|
97
|
+
console.log(chalk.green(' ✓') + chalk.gray(` CLAUDE.md loaded (${claudeMd.length} chars)`));
|
|
98
|
+
} else {
|
|
99
|
+
console.log(chalk.yellow(' ⚠ No CLAUDE.md — run `devjar init` for better results'));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Normalize ──
|
|
103
|
+
const stop = spinner(`Normalizing via ${config.provider}...`);
|
|
104
|
+
let structured;
|
|
105
|
+
try {
|
|
106
|
+
const provider = await getProvider(config);
|
|
107
|
+
const systemPrompt = buildSystemPrompt(claudeMd, projectName);
|
|
108
|
+
structured = await provider.normalize(rawInput, systemPrompt, config);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
stop();
|
|
111
|
+
console.log(chalk.red('\n✗ ' + e.message));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
stop();
|
|
115
|
+
|
|
116
|
+
// ── Display ──
|
|
117
|
+
displayResult(structured);
|
|
118
|
+
|
|
119
|
+
// ── Clipboard ──
|
|
120
|
+
const answer = await ask(chalk.bold('Copy to clipboard?') + chalk.gray(' (Y/n) '));
|
|
121
|
+
if (answer === '' || answer.toLowerCase() === 'y') {
|
|
122
|
+
try {
|
|
123
|
+
await clipboard.write(structured);
|
|
124
|
+
console.log(chalk.bold.green('✓ Copied'));
|
|
125
|
+
} catch {
|
|
126
|
+
console.log(chalk.yellow('⚠ Clipboard unavailable'));
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
console.log(chalk.gray('Skipped.'));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── History ──
|
|
133
|
+
saveHistory({ timestamp: new Date().toISOString(), project: projectName, provider: config.provider, original: rawInput, structured });
|
|
134
|
+
console.log(chalk.gray(` Saved to history`));
|
|
135
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Provider: Anthropic — Haiku (fast, cheap)
|
|
2
|
+
// Requires: ANTHROPIC_API_KEY or key in ~/.devjar/config.json
|
|
3
|
+
|
|
4
|
+
export const name = 'anthropic';
|
|
5
|
+
export const defaultModel = 'claude-haiku-4-5-20251001';
|
|
6
|
+
|
|
7
|
+
export async function normalize(userPrompt, systemPrompt, config = {}) {
|
|
8
|
+
const apiKey = config.apiKey || process.env.ANTHROPIC_API_KEY;
|
|
9
|
+
if (!apiKey) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
'Anthropic API key required.\n' +
|
|
12
|
+
' Set it: devjar config --provider anthropic --key sk-ant-...\n' +
|
|
13
|
+
' Or use: export ANTHROPIC_API_KEY=sk-ant-...'
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
18
|
+
const client = new Anthropic({ apiKey });
|
|
19
|
+
const msg = await client.messages.create({
|
|
20
|
+
model: config.model || defaultModel,
|
|
21
|
+
max_tokens: 500,
|
|
22
|
+
system: systemPrompt,
|
|
23
|
+
messages: [{ role: 'user', content: userPrompt }],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return msg.content[0].text.trim();
|
|
27
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Provider: Google Gemini — gemini-2.0-flash (free tier available)
|
|
2
|
+
// Requires: API key from https://aistudio.google.com
|
|
3
|
+
|
|
4
|
+
export const name = 'gemini';
|
|
5
|
+
export const defaultModel = 'gemini-2.0-flash';
|
|
6
|
+
|
|
7
|
+
export async function normalize(userPrompt, systemPrompt, config = {}) {
|
|
8
|
+
const apiKey = config.apiKey || process.env.GEMINI_API_KEY;
|
|
9
|
+
if (!apiKey) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
'Gemini API key required.\n' +
|
|
12
|
+
' Get free key: https://aistudio.google.com\n' +
|
|
13
|
+
' Set it: devjar config --provider gemini --key AIza...'
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const model = config.model || defaultModel;
|
|
18
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
|
|
19
|
+
|
|
20
|
+
const res = await fetch(url, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
system_instruction: { parts: [{ text: systemPrompt }] },
|
|
25
|
+
contents: [{ role: 'user', parts: [{ text: userPrompt }] }],
|
|
26
|
+
generationConfig: { maxOutputTokens: 500 },
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
const err = await res.json().catch(() => ({}));
|
|
32
|
+
throw new Error(`Gemini error ${res.status}: ${err?.error?.message || res.statusText}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const data = await res.json();
|
|
36
|
+
return data.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || '';
|
|
37
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Provider router — picks provider from config, builds system prompt
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILE = path.join(os.homedir(), '.devjar', 'config.json');
|
|
8
|
+
|
|
9
|
+
export function loadConfig() {
|
|
10
|
+
try {
|
|
11
|
+
if (fs.existsSync(CONFIG_FILE)) return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
12
|
+
} catch {}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function saveConfig(cfg) {
|
|
17
|
+
const dir = path.dirname(CONFIG_FILE);
|
|
18
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), 'utf8');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildSystemPrompt(claudeMdContent, projectName) {
|
|
23
|
+
const context = claudeMdContent
|
|
24
|
+
? claudeMdContent.slice(0, 2000)
|
|
25
|
+
: `Project: ${projectName}\nNo CLAUDE.md — run devjar init for better results.`;
|
|
26
|
+
|
|
27
|
+
return `You are a senior developer's prompt engineer.
|
|
28
|
+
Given a vague request and project context below, restructure into STAR-C format:
|
|
29
|
+
|
|
30
|
+
S — Situation: what exists right now (1 line)
|
|
31
|
+
T — Task: exactly what to build/fix (1 line)
|
|
32
|
+
A — Action: how to do it, which files (2-3 lines)
|
|
33
|
+
R — Result: what done looks like (1 line)
|
|
34
|
+
C — Constraints: what NOT to do (2-3 lines)
|
|
35
|
+
|
|
36
|
+
Project context:
|
|
37
|
+
${context}
|
|
38
|
+
|
|
39
|
+
Be concise. Output only the STAR-C prompt. No preamble. No explanation.`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function getProvider(config) {
|
|
43
|
+
const providerName = config?.provider || 'ollama';
|
|
44
|
+
const providers = {
|
|
45
|
+
ollama: () => import('./ollama.js'),
|
|
46
|
+
anthropic: () => import('./anthropic.js'),
|
|
47
|
+
gemini: () => import('./gemini.js'),
|
|
48
|
+
openai: () => import('./openai.js'),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const loader = providers[providerName];
|
|
52
|
+
if (!loader) throw new Error(`Unknown provider "${providerName}". Choose: ${Object.keys(providers).join(', ')}`);
|
|
53
|
+
return loader();
|
|
54
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Provider: Ollama — free, local, no API key needed
|
|
2
|
+
// Requires: ollama running at localhost:11434
|
|
3
|
+
// Install: https://ollama.com → then: ollama pull llama3.2
|
|
4
|
+
|
|
5
|
+
export const name = 'ollama';
|
|
6
|
+
export const defaultModel = 'llama3.2';
|
|
7
|
+
|
|
8
|
+
export async function normalize(userPrompt, systemPrompt, config = {}) {
|
|
9
|
+
const base = config.ollamaUrl || 'http://localhost:11434';
|
|
10
|
+
const model = config.model || defaultModel;
|
|
11
|
+
|
|
12
|
+
let res;
|
|
13
|
+
try {
|
|
14
|
+
res = await fetch(`${base}/api/generate`, {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
body: JSON.stringify({ model, system: systemPrompt, prompt: userPrompt, stream: false }),
|
|
18
|
+
signal: AbortSignal.timeout(30000),
|
|
19
|
+
});
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (e.cause?.code === 'ECONNREFUSED' || e.name === 'TimeoutError') {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'Ollama is not running.\n' +
|
|
24
|
+
' Start it: ollama serve\n' +
|
|
25
|
+
' Install: https://ollama.com\n' +
|
|
26
|
+
` Then pull: ollama pull ${model}`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
const body = await res.text();
|
|
34
|
+
if (res.status === 404) throw new Error(`Model "${model}" not found.\n Run: ollama pull ${model}`);
|
|
35
|
+
throw new Error(`Ollama error ${res.status}: ${body}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const data = await res.json();
|
|
39
|
+
return data.response.trim();
|
|
40
|
+
}
|