@blockrun/franklin 3.3.0 → 3.3.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.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * franklin migrate — one-click import from other AI coding agents.
3
+ *
4
+ * Detects installed tools (Claude Code, Cline, Cursor, etc.),
5
+ * shows what can be migrated, and imports with user confirmation.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import readline from 'node:readline';
11
+ import chalk from 'chalk';
12
+ import { BLOCKRUN_DIR } from '../config.js';
13
+ function detectSources() {
14
+ const sources = [];
15
+ const home = os.homedir();
16
+ // ── Claude Code ──
17
+ const claudeDir = path.join(home, '.claude');
18
+ if (fs.existsSync(claudeDir)) {
19
+ const items = [];
20
+ // MCP servers
21
+ const claudeMcp = path.join(claudeDir, 'mcp.json');
22
+ if (fs.existsSync(claudeMcp)) {
23
+ items.push({
24
+ label: 'MCP servers',
25
+ source: claudeMcp,
26
+ target: path.join(BLOCKRUN_DIR, 'mcp.json'),
27
+ size: fileSize(claudeMcp),
28
+ transform: () => migrateMcp(claudeMcp),
29
+ });
30
+ }
31
+ // Global instructions → learnings
32
+ const claudeMd = path.join(claudeDir, 'CLAUDE.md');
33
+ if (fs.existsSync(claudeMd)) {
34
+ items.push({
35
+ label: 'Global instructions (CLAUDE.md)',
36
+ source: claudeMd,
37
+ target: path.join(BLOCKRUN_DIR, 'learnings.jsonl'),
38
+ size: fileSize(claudeMd),
39
+ transform: () => migrateInstructions(claudeMd),
40
+ });
41
+ }
42
+ // Session history
43
+ const claudeHistory = path.join(claudeDir, 'history.jsonl');
44
+ if (fs.existsSync(claudeHistory)) {
45
+ const lines = countLines(claudeHistory);
46
+ items.push({
47
+ label: `Session history (${lines.toLocaleString()} messages)`,
48
+ source: claudeHistory,
49
+ target: path.join(BLOCKRUN_DIR, 'sessions'),
50
+ size: fileSize(claudeHistory),
51
+ transform: () => migrateSessions(claudeHistory),
52
+ });
53
+ }
54
+ // Project memory files
55
+ const projectsDir = path.join(claudeDir, 'projects');
56
+ if (fs.existsSync(projectsDir)) {
57
+ const memoryFiles = findMemoryFiles(projectsDir);
58
+ if (memoryFiles.length > 0) {
59
+ items.push({
60
+ label: `Project memories (${memoryFiles.length} files)`,
61
+ source: projectsDir,
62
+ target: path.join(BLOCKRUN_DIR, 'learnings.jsonl'),
63
+ size: `${memoryFiles.length} files`,
64
+ transform: () => migrateMemories(memoryFiles),
65
+ });
66
+ }
67
+ }
68
+ if (items.length > 0) {
69
+ sources.push({ name: 'Claude Code', dir: claudeDir, items });
70
+ }
71
+ }
72
+ // ── Cline / OpenClaw ──
73
+ const clineDir = path.join(home, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev');
74
+ if (fs.existsSync(clineDir)) {
75
+ const items = [];
76
+ // TODO: detect Cline data
77
+ if (items.length > 0) {
78
+ sources.push({ name: 'Cline', dir: clineDir, items });
79
+ }
80
+ }
81
+ // ── Cursor ──
82
+ const cursorDir = path.join(home, 'Library', 'Application Support', 'Cursor');
83
+ if (fs.existsSync(cursorDir)) {
84
+ const items = [];
85
+ // TODO: detect Cursor data
86
+ if (items.length > 0) {
87
+ sources.push({ name: 'Cursor', dir: cursorDir, items });
88
+ }
89
+ }
90
+ return sources;
91
+ }
92
+ // ─── Transforms ───────────────────────────────────────────────────────────
93
+ function migrateMcp(source) {
94
+ const target = path.join(BLOCKRUN_DIR, 'mcp.json');
95
+ const raw = JSON.parse(fs.readFileSync(source, 'utf-8'));
96
+ // Claude Code format: { mcpServers: { name: { command, args, env } } }
97
+ // Franklin format: { mcpServers: { name: { transport, command, args, label } } }
98
+ const servers = {};
99
+ if (raw.mcpServers) {
100
+ for (const [name, config] of Object.entries(raw.mcpServers)) {
101
+ servers[name] = {
102
+ transport: config.transport || 'stdio',
103
+ command: config.command,
104
+ args: config.args || [],
105
+ label: name,
106
+ ...(config.env ? { env: config.env } : {}),
107
+ };
108
+ }
109
+ }
110
+ // Merge with existing Franklin MCP config
111
+ let existing = {};
112
+ try {
113
+ if (fs.existsSync(target)) {
114
+ existing = JSON.parse(fs.readFileSync(target, 'utf-8'));
115
+ }
116
+ }
117
+ catch { /* start fresh */ }
118
+ const merged = {
119
+ mcpServers: {
120
+ ...(existing.mcpServers || {}),
121
+ ...servers,
122
+ },
123
+ };
124
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
125
+ fs.writeFileSync(target, JSON.stringify(merged, null, 2));
126
+ console.log(chalk.green(` ✓ ${Object.keys(servers).length} MCP server(s) imported`));
127
+ }
128
+ function migrateInstructions(source) {
129
+ // Read CLAUDE.md and convert key preferences to learnings
130
+ const content = fs.readFileSync(source, 'utf-8');
131
+ const learningsPath = path.join(BLOCKRUN_DIR, 'learnings.jsonl');
132
+ // Extract simple preference lines as learnings
133
+ const lines = content.split('\n');
134
+ const learnings = [];
135
+ const now = Date.now();
136
+ let count = 0;
137
+ for (const line of lines) {
138
+ const trimmed = line.trim();
139
+ // Skip empty lines, headers, and code blocks
140
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('```') || trimmed.startsWith('|'))
141
+ continue;
142
+ // Skip very short or very long lines
143
+ if (trimmed.length < 15 || trimmed.length > 200)
144
+ continue;
145
+ // Skip lines that are just paths or URLs
146
+ if (trimmed.startsWith('/') || trimmed.startsWith('http'))
147
+ continue;
148
+ // Lines starting with - or * are likely preference rules
149
+ if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
150
+ const text = trimmed.slice(2).trim();
151
+ if (text.length > 15) {
152
+ const entry = {
153
+ id: `migrate-${count++}`,
154
+ learning: text.slice(0, 200),
155
+ category: 'other',
156
+ confidence: 0.8,
157
+ source_session: 'migrate:claude-code',
158
+ created_at: now,
159
+ last_confirmed: now,
160
+ times_confirmed: 1,
161
+ };
162
+ learnings.push(JSON.stringify(entry));
163
+ }
164
+ }
165
+ }
166
+ if (learnings.length > 0) {
167
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
168
+ // Append to existing learnings
169
+ fs.appendFileSync(learningsPath, learnings.join('\n') + '\n');
170
+ console.log(chalk.green(` ✓ ${learnings.length} preferences imported`));
171
+ }
172
+ else {
173
+ console.log(chalk.dim(' ○ No extractable preferences found'));
174
+ }
175
+ }
176
+ function migrateSessions(source) {
177
+ const sessionsDir = path.join(BLOCKRUN_DIR, 'sessions');
178
+ fs.mkdirSync(sessionsDir, { recursive: true });
179
+ const raw = fs.readFileSync(source, 'utf-8');
180
+ const lines = raw.split('\n').filter(l => l.trim());
181
+ // Group by conversation turns — each user+assistant pair is a chunk
182
+ // We'll create session files grouped by day
183
+ const sessions = new Map();
184
+ for (const line of lines) {
185
+ try {
186
+ const msg = JSON.parse(line);
187
+ // Use date from the line or current date as session key
188
+ const dateKey = new Date().toISOString().split('T')[0];
189
+ // Try to extract timestamp if present
190
+ const ts = msg.timestamp || msg.created_at || msg.ts;
191
+ const key = ts ? new Date(ts).toISOString().split('T')[0] : dateKey;
192
+ if (!sessions.has(key))
193
+ sessions.set(key, []);
194
+ sessions.get(key).push(line);
195
+ }
196
+ catch {
197
+ // Skip unparseable lines
198
+ }
199
+ }
200
+ let imported = 0;
201
+ for (const [dateKey, msgs] of sessions) {
202
+ const sessionId = `imported-${dateKey}`;
203
+ const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`);
204
+ // Don't overwrite existing imported sessions
205
+ if (fs.existsSync(sessionFile))
206
+ continue;
207
+ fs.writeFileSync(sessionFile, msgs.join('\n') + '\n');
208
+ // Create metadata
209
+ const meta = {
210
+ id: sessionId,
211
+ model: 'imported',
212
+ workDir: os.homedir(),
213
+ createdAt: new Date(dateKey).getTime(),
214
+ updatedAt: Date.now(),
215
+ turnCount: Math.floor(msgs.length / 2),
216
+ messageCount: msgs.length,
217
+ };
218
+ fs.writeFileSync(path.join(sessionsDir, `${sessionId}.meta.json`), JSON.stringify(meta, null, 2));
219
+ imported++;
220
+ }
221
+ console.log(chalk.green(` ✓ ${lines.length.toLocaleString()} messages → ${imported} session(s)`));
222
+ }
223
+ function migrateMemories(files) {
224
+ const learningsPath = path.join(BLOCKRUN_DIR, 'learnings.jsonl');
225
+ const now = Date.now();
226
+ let count = 0;
227
+ const entries = [];
228
+ for (const file of files) {
229
+ try {
230
+ const content = fs.readFileSync(file, 'utf-8');
231
+ const lines = content.split('\n');
232
+ for (const line of lines) {
233
+ const trimmed = line.trim();
234
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('```'))
235
+ continue;
236
+ if (trimmed.startsWith('- ') && trimmed.length > 20 && trimmed.length < 200) {
237
+ const text = trimmed.slice(2).trim();
238
+ // Skip index entries (links to other files)
239
+ if (text.startsWith('[') && text.includes(']('))
240
+ continue;
241
+ entries.push(JSON.stringify({
242
+ id: `memory-${count++}`,
243
+ learning: text.slice(0, 200),
244
+ category: 'other',
245
+ confidence: 0.7,
246
+ source_session: 'migrate:project-memory',
247
+ created_at: now,
248
+ last_confirmed: now,
249
+ times_confirmed: 1,
250
+ }));
251
+ }
252
+ }
253
+ }
254
+ catch { /* skip unreadable files */ }
255
+ }
256
+ if (entries.length > 0) {
257
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
258
+ fs.appendFileSync(learningsPath, entries.join('\n') + '\n');
259
+ console.log(chalk.green(` ✓ ${entries.length} memories imported`));
260
+ }
261
+ else {
262
+ console.log(chalk.dim(' ○ No extractable memories found'));
263
+ }
264
+ }
265
+ // ─── Helpers ──────────────────────────────────────────────────────────────
266
+ function fileSize(p) {
267
+ try {
268
+ const bytes = fs.statSync(p).size;
269
+ if (bytes < 1024)
270
+ return `${bytes} B`;
271
+ if (bytes < 1024 * 1024)
272
+ return `${(bytes / 1024).toFixed(1)} KB`;
273
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
274
+ }
275
+ catch {
276
+ return '?';
277
+ }
278
+ }
279
+ function countLines(p) {
280
+ try {
281
+ return fs.readFileSync(p, 'utf-8').split('\n').filter(l => l.trim()).length;
282
+ }
283
+ catch {
284
+ return 0;
285
+ }
286
+ }
287
+ function findMemoryFiles(projectsDir) {
288
+ const files = [];
289
+ try {
290
+ for (const project of fs.readdirSync(projectsDir)) {
291
+ const memoryDir = path.join(projectsDir, project, 'memory');
292
+ if (!fs.existsSync(memoryDir))
293
+ continue;
294
+ for (const file of fs.readdirSync(memoryDir)) {
295
+ if (file.endsWith('.md') && file !== 'MEMORY.md') {
296
+ files.push(path.join(memoryDir, file));
297
+ }
298
+ }
299
+ }
300
+ }
301
+ catch { /* ignore */ }
302
+ return files;
303
+ }
304
+ // ─── Interactive prompt ───────────────────────────────────────────────────
305
+ function ask(question) {
306
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
307
+ return new Promise(resolve => {
308
+ rl.question(question, answer => { rl.close(); resolve(answer.trim().toLowerCase()); });
309
+ });
310
+ }
311
+ // ─── Main command ─────────────────────────────────────────────────────────
312
+ export async function migrateCommand() {
313
+ console.log(chalk.bold('\n franklin migrate\n'));
314
+ const sources = detectSources();
315
+ if (sources.length === 0) {
316
+ console.log(chalk.dim(' No other AI tools detected. Nothing to migrate.\n'));
317
+ console.log(chalk.dim(' Supported: Claude Code, Cline, Cursor\n'));
318
+ return;
319
+ }
320
+ // Show what was found
321
+ for (const source of sources) {
322
+ console.log(chalk.bold(` ${chalk.green('●')} ${source.name}`) + chalk.dim(` (${source.dir})`));
323
+ for (const item of source.items) {
324
+ console.log(chalk.dim(` ├─ ${item.label}`) + (item.size ? chalk.dim(` [${item.size}]`) : ''));
325
+ }
326
+ console.log('');
327
+ }
328
+ const total = sources.reduce((n, s) => n + s.items.length, 0);
329
+ const answer = await ask(chalk.yellow(` Import ${total} item(s) into Franklin? [Y/n] `));
330
+ if (answer && answer !== 'y' && answer !== 'yes') {
331
+ console.log(chalk.dim('\n Cancelled.\n'));
332
+ return;
333
+ }
334
+ console.log('');
335
+ // Run migrations
336
+ for (const source of sources) {
337
+ console.log(chalk.bold(` Migrating from ${source.name}...`));
338
+ for (const item of source.items) {
339
+ try {
340
+ item.transform();
341
+ }
342
+ catch (err) {
343
+ console.log(chalk.red(` ✗ ${item.label}: ${err.message}`));
344
+ }
345
+ }
346
+ console.log('');
347
+ }
348
+ console.log(chalk.green(' Done.') + chalk.dim(' Run `franklin --trust` to start.\n'));
349
+ }
350
+ // ─── First-run detection (called from start.ts) ──────────────────────────
351
+ const MIGRATED_MARKER = path.join(BLOCKRUN_DIR, '.migrated');
352
+ /**
353
+ * Check if other AI tools are installed and suggest migration.
354
+ * Only runs once — writes a marker file after first check.
355
+ * Returns true if the user chose to migrate (caller should re-run start after).
356
+ */
357
+ export async function checkAndSuggestMigration() {
358
+ // Only suggest once
359
+ if (fs.existsSync(MIGRATED_MARKER))
360
+ return false;
361
+ // Write marker immediately so we never ask again
362
+ fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
363
+ fs.writeFileSync(MIGRATED_MARKER, new Date().toISOString());
364
+ const sources = detectSources();
365
+ if (sources.length === 0)
366
+ return false;
367
+ const names = sources.map(s => s.name).join(', ');
368
+ const total = sources.reduce((n, s) => n + s.items.length, 0);
369
+ console.log(chalk.bold(`\n ${chalk.green('●')} Found ${names} — ${total} items available to import.`));
370
+ const answer = await ask(chalk.yellow(` Import into Franklin? [Y/n] `));
371
+ if (answer && answer !== 'y' && answer !== 'yes') {
372
+ console.log(chalk.dim(' Skipped. Run `franklin migrate` anytime.\n'));
373
+ return false;
374
+ }
375
+ console.log('');
376
+ for (const source of sources) {
377
+ console.log(chalk.bold(` Migrating from ${source.name}...`));
378
+ for (const item of source.items) {
379
+ try {
380
+ item.transform();
381
+ }
382
+ catch (err) {
383
+ console.log(chalk.red(` ✗ ${item.label}: ${err.message}`));
384
+ }
385
+ }
386
+ }
387
+ console.log(chalk.green('\n Done.') + ' Starting Franklin...\n');
388
+ return true;
389
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * franklin panel — launch the local web dashboard.
3
+ */
4
+ export declare function panelCommand(options: {
5
+ port?: string;
6
+ }): Promise<void>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * franklin panel — launch the local web dashboard.
3
+ */
4
+ import chalk from 'chalk';
5
+ import { createPanelServer } from '../panel/server.js';
6
+ export async function panelCommand(options) {
7
+ const port = parseInt(options.port || '3100', 10);
8
+ const server = createPanelServer(port);
9
+ server.listen(port, () => {
10
+ console.log('');
11
+ console.log(chalk.bold(' Franklin Panel'));
12
+ console.log(chalk.dim(` http://localhost:${port}`));
13
+ console.log('');
14
+ console.log(chalk.dim(' Press Ctrl+C to stop.'));
15
+ console.log('');
16
+ // Try to open browser
17
+ const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
18
+ import('node:child_process').then(({ exec }) => {
19
+ exec(`${open} http://localhost:${port}`);
20
+ }).catch(() => { });
21
+ });
22
+ // Graceful shutdown
23
+ const shutdown = () => {
24
+ server.close();
25
+ process.exit(0);
26
+ };
27
+ process.on('SIGINT', shutdown);
28
+ process.on('SIGTERM', shutdown);
29
+ }
@@ -50,6 +50,14 @@ export async function startCommand(options) {
50
50
  console.log(chalk.dim(' Free models work now. Fund with USDC for paid models.\n'));
51
51
  }
52
52
  }
53
+ // First-run: detect other AI tools and offer migration
54
+ if (process.stdin.isTTY) {
55
+ try {
56
+ const { checkAndSuggestMigration } = await import('./migrate.js');
57
+ await checkAndSuggestMigration();
58
+ }
59
+ catch { /* migration is optional */ }
60
+ }
53
61
  printBanner(version);
54
62
  const workDir = process.cwd();
55
63
  // Show session info immediately, fetch balance in background
@@ -65,10 +73,10 @@ export async function startCommand(options) {
65
73
  // After the user's first session, the tip fades and they go straight to the prompt.
66
74
  console.log('');
67
75
  console.log(chalk.dim(' Try something only Franklin can do:'));
68
- console.log(chalk.dim(' ') + chalk.hex('#FFD700')('"what\'s BTC looking like today?"') + chalk.dim(' live market signal'));
69
- console.log(chalk.dim(' ') + chalk.hex('#10B981')('"find X posts about ai agent"') + chalk.dim(' ← social growth'));
70
- console.log(chalk.dim(' ') + chalk.hex('#60A5FA')('"generate a hero image for my app"') + chalk.dim(' AI image gen'));
71
- console.log(chalk.dim(' Or just code — 55+ models ready, no API keys needed.'));
76
+ console.log(chalk.dim(' ') + chalk.hex('#FFD700')('"what\'s BTC looking like today?"') + chalk.dim(' ← market signal'));
77
+ console.log(chalk.dim(' ') + chalk.hex('#10B981')('"find X posts about ai agent"') + chalk.dim(' ← social growth'));
78
+ console.log(chalk.dim(' ') + chalk.hex('#60A5FA')('"generate a hero image for my app"') + chalk.dim(' ← image gen'));
79
+ console.log(chalk.dim(' Or just code — 55+ models, no API keys.'));
72
80
  console.log('');
73
81
  // Balance fetcher — used at startup and after each turn
74
82
  const fetchBalance = async () => {
package/dist/index.js CHANGED
@@ -64,6 +64,14 @@ program
64
64
  .description('Manage runcode background proxy (start|stop|status)')
65
65
  .option('-p, --port <port>', 'Proxy port', '8402')
66
66
  .action((action, options) => daemonCommand(action, options));
67
+ program
68
+ .command('panel')
69
+ .description('Open the Franklin dashboard (localhost:3100)')
70
+ .option('-p, --port <port>', 'Dashboard port', '3100')
71
+ .action(async (options) => {
72
+ const { panelCommand } = await import('./commands/panel.js');
73
+ await panelCommand(options);
74
+ });
67
75
  program
68
76
  .command('models')
69
77
  .description('List available models and pricing')
@@ -149,6 +157,13 @@ program
149
157
  });
150
158
  }
151
159
  }
160
+ program
161
+ .command('migrate')
162
+ .description('Import data from other AI tools (Claude Code, Cline, Cursor)')
163
+ .action(async () => {
164
+ const { migrateCommand } = await import('./commands/migrate.js');
165
+ await migrateCommand();
166
+ });
152
167
  program
153
168
  .command('plugins')
154
169
  .description('List installed plugins')
@@ -19,6 +19,11 @@ async function connectStdio(name, config) {
19
19
  command: config.command,
20
20
  args: config.args || [],
21
21
  env: { ...process.env, ...(config.env || {}) },
22
+ // 'ignore' discards subprocess stderr completely so a misconfigured MCP
23
+ // server (e.g. missing OAuth keys) can't dump multi-line stack traces
24
+ // into the user's terminal. 'pipe' didn't fully work because some SDK
25
+ // versions read piped stderr and re-emit it.
26
+ stderr: 'ignore',
22
27
  });
23
28
  const client = new Client({ name: `runcode-mcp-${name}`, version: '1.0.0' }, { capabilities: {} });
24
29
  try {
@@ -111,8 +116,10 @@ export async function connectMcpServers(config, debug) {
111
116
  }
112
117
  }
113
118
  catch (err) {
114
- // Graceful degradation — log and continue without this server
115
- console.error(`[runcode] MCP ${name} failed: ${err.message}`);
119
+ // Graceful degradation — one-line warning, continue without this server.
120
+ // Always visible (not debug-only) so the user knows why tools are missing.
121
+ const shortMsg = err.message?.split('\n')[0]?.slice(0, 100) || 'unknown error';
122
+ console.error(` ${name}: ${shortMsg} ${debug ? '' : '(--debug for details)'}`);
116
123
  }
117
124
  }
118
125
  return allTools;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Franklin Panel — embedded HTML dashboard.
3
+ * Single page, dark theme, zero dependencies.
4
+ */
5
+ export declare function getHTML(): string;