roguelike-cli 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.
@@ -0,0 +1,625 @@
1
+ import * as path from 'path';
2
+ import * as fs from 'fs';
3
+ import { execSync } from 'child_process';
4
+ import { Config } from '../config/config';
5
+ import { listSchemas, navigateToNode, getTree } from '../storage/storage';
6
+ import { saveSchemaFile } from '../storage/nodeConfig';
7
+ import { generateSchemaWithAI } from '../ai/claude';
8
+
9
+ export interface CommandResult {
10
+ output?: string;
11
+ newPath?: string;
12
+ reloadConfig?: boolean;
13
+ }
14
+
15
+ // Pending schema waiting to be saved
16
+ export interface PendingSchema {
17
+ title: string;
18
+ content: string;
19
+ tree?: any[];
20
+ }
21
+
22
+ // Conversation history for AI context
23
+ export interface ConversationMessage {
24
+ role: 'user' | 'assistant';
25
+ content: string;
26
+ }
27
+
28
+ // Session state for dialog mode
29
+ export interface SessionState {
30
+ pending: PendingSchema | null;
31
+ history: ConversationMessage[];
32
+ }
33
+
34
+ // Global session state
35
+ export const sessionState: SessionState = {
36
+ pending: null,
37
+ history: []
38
+ };
39
+
40
+ // Format items in columns like native ls
41
+ function formatColumns(items: string[], termWidth: number = 80): string {
42
+ if (items.length === 0) return '';
43
+
44
+ const maxLen = Math.max(...items.map(s => s.length)) + 2;
45
+ const cols = Math.max(1, Math.floor(termWidth / maxLen));
46
+
47
+ const rows: string[] = [];
48
+ for (let i = 0; i < items.length; i += cols) {
49
+ const row = items.slice(i, i + cols);
50
+ rows.push(row.map(item => item.padEnd(maxLen)).join('').trimEnd());
51
+ }
52
+
53
+ return rows.join('\n');
54
+ }
55
+
56
+ // Copy to clipboard (cross-platform)
57
+ function copyToClipboard(text: string): void {
58
+ const platform = process.platform;
59
+ try {
60
+ if (platform === 'darwin') {
61
+ execSync('pbcopy', { input: text });
62
+ } else if (platform === 'win32') {
63
+ execSync('clip', { input: text });
64
+ } else {
65
+ // Linux - try xclip or xsel
66
+ try {
67
+ execSync('xclip -selection clipboard', { input: text });
68
+ } catch {
69
+ execSync('xsel --clipboard --input', { input: text });
70
+ }
71
+ }
72
+ } catch (e) {
73
+ // Silently fail if clipboard not available
74
+ }
75
+ }
76
+
77
+ // Helper function for recursive copy
78
+ function copyRecursive(src: string, dest: string): void {
79
+ const stat = fs.statSync(src);
80
+
81
+ if (stat.isDirectory()) {
82
+ if (!fs.existsSync(dest)) {
83
+ fs.mkdirSync(dest, { recursive: true });
84
+ }
85
+
86
+ const entries = fs.readdirSync(src);
87
+ for (const entry of entries) {
88
+ const srcPath = path.join(src, entry);
89
+ const destPath = path.join(dest, entry);
90
+ copyRecursive(srcPath, destPath);
91
+ }
92
+ } else {
93
+ fs.copyFileSync(src, dest);
94
+ }
95
+ }
96
+
97
+ export async function processCommand(
98
+ input: string,
99
+ currentPath: string,
100
+ config: Config,
101
+ signal?: AbortSignal
102
+ ): Promise<CommandResult> {
103
+ // Check for clipboard pipe
104
+ const clipboardPipe = /\s*\|\s*(pbcopy|copy|clip)\s*$/i;
105
+ const shouldCopy = clipboardPipe.test(input);
106
+ const cleanInput = input.replace(clipboardPipe, '').trim();
107
+
108
+ const parts = cleanInput.split(' ').filter(p => p.length > 0);
109
+ const command = parts[0].toLowerCase();
110
+
111
+ // Helper to wrap result with clipboard copy
112
+ const wrapResult = (result: CommandResult): CommandResult => {
113
+ if (shouldCopy && result.output) {
114
+ copyToClipboard(result.output);
115
+ result.output += '\n\n[copied to clipboard]';
116
+ }
117
+ return result;
118
+ };
119
+
120
+ if (command === 'ls') {
121
+ if (!fs.existsSync(currentPath)) {
122
+ return wrapResult({ output: 'Directory does not exist.' });
123
+ }
124
+
125
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true });
126
+ const items: string[] = [];
127
+
128
+ for (const entry of entries) {
129
+ if (entry.name.startsWith('.')) continue;
130
+
131
+ if (entry.isDirectory()) {
132
+ items.push(entry.name + '/');
133
+ } else {
134
+ items.push(entry.name);
135
+ }
136
+ }
137
+
138
+ if (items.length === 0) {
139
+ return wrapResult({ output: '' });
140
+ }
141
+
142
+ const termWidth = process.stdout.columns || 80;
143
+ return wrapResult({ output: formatColumns(items, termWidth) });
144
+ }
145
+
146
+ if (command === 'tree') {
147
+ const showFiles = parts.includes('-A') || parts.includes('--all');
148
+
149
+ // Parse depth: --depth=N or -d N
150
+ let maxDepth = 10;
151
+ const depthFlag = parts.find(p => p.startsWith('--depth='));
152
+ if (depthFlag) {
153
+ maxDepth = parseInt(depthFlag.split('=')[1]) || 10;
154
+ } else {
155
+ const dIndex = parts.indexOf('-d');
156
+ if (dIndex !== -1 && parts[dIndex + 1]) {
157
+ maxDepth = parseInt(parts[dIndex + 1]) || 10;
158
+ }
159
+ }
160
+
161
+ const treeLines = getTree(currentPath, '', true, maxDepth, 0, showFiles);
162
+ if (treeLines.length === 0) {
163
+ return wrapResult({ output: 'No items found.' });
164
+ }
165
+ return wrapResult({ output: treeLines.join('\n') });
166
+ }
167
+
168
+ // Handle navigation without 'cd' command (.., ...)
169
+ if (command === '..' || command === '...') {
170
+ let levels = command === '...' ? 2 : 1;
171
+ let targetPath = currentPath;
172
+
173
+ for (let i = 0; i < levels; i++) {
174
+ const parentPath = path.dirname(targetPath);
175
+ if (parentPath === config.storagePath || parentPath.length < config.storagePath.length) {
176
+ return { output: 'Already at root.' };
177
+ }
178
+ targetPath = parentPath;
179
+ }
180
+
181
+ return { newPath: targetPath, output: '' };
182
+ }
183
+
184
+ if (command === 'mkdir') {
185
+ if (parts.length < 2) {
186
+ return { output: 'Usage: mkdir <name>' };
187
+ }
188
+
189
+ const name = parts.slice(1).join(' ');
190
+ const { createNode } = require('../storage/nodeConfig');
191
+
192
+ try {
193
+ const nodePath = createNode(currentPath, name);
194
+ return { output: `Created: ${name}` };
195
+ } catch (error: any) {
196
+ return { output: `Error: ${error.message}` };
197
+ }
198
+ }
199
+
200
+ if (command === 'cp') {
201
+ if (parts.length < 3) {
202
+ return { output: 'Usage: cp <source> <destination>' };
203
+ }
204
+
205
+ const source = parts[1];
206
+ const dest = parts[2];
207
+
208
+ const sourcePath = path.isAbsolute(source) ? source : path.join(currentPath, source);
209
+ const destPath = path.isAbsolute(dest) ? dest : path.join(currentPath, dest);
210
+
211
+ if (!fs.existsSync(sourcePath)) {
212
+ return { output: `Source not found: ${source}` };
213
+ }
214
+
215
+ try {
216
+ copyRecursive(sourcePath, destPath);
217
+ return { output: `Copied: ${source} -> ${dest}` };
218
+ } catch (error: any) {
219
+ return { output: `Error: ${error.message}` };
220
+ }
221
+ }
222
+
223
+ if (command === 'mv' || command === 'move') {
224
+ if (parts.length < 3) {
225
+ return { output: 'Usage: mv <source> <destination>' };
226
+ }
227
+
228
+ const source = parts[1];
229
+ const dest = parts[2];
230
+
231
+ const sourcePath = path.isAbsolute(source) ? source : path.join(currentPath, source);
232
+ const destPath = path.isAbsolute(dest) ? dest : path.join(currentPath, dest);
233
+
234
+ if (!fs.existsSync(sourcePath)) {
235
+ return { output: `Source not found: ${source}` };
236
+ }
237
+
238
+ try {
239
+ fs.renameSync(sourcePath, destPath);
240
+ return { output: `Moved: ${source} -> ${dest}` };
241
+ } catch (error: any) {
242
+ // If rename fails (cross-device), copy then delete
243
+ try {
244
+ copyRecursive(sourcePath, destPath);
245
+ fs.rmSync(sourcePath, { recursive: true, force: true });
246
+ return { output: `Moved: ${source} -> ${dest}` };
247
+ } catch (e: any) {
248
+ return { output: `Error: ${e.message}` };
249
+ }
250
+ }
251
+ }
252
+
253
+ if (command === 'open') {
254
+ const { exec } = require('child_process');
255
+
256
+ // open or open . - open current folder in system file manager
257
+ if (parts.length < 2 || parts[1] === '.') {
258
+ exec(`open "${currentPath}"`);
259
+ return { output: `Opening: ${currentPath}` };
260
+ }
261
+
262
+ const name = parts.slice(1).join(' ');
263
+ const targetPath = path.join(currentPath, name);
264
+
265
+ // Check if target exists
266
+ if (fs.existsSync(targetPath)) {
267
+ const stat = fs.statSync(targetPath);
268
+
269
+ if (stat.isDirectory()) {
270
+ // It's a folder, open in file manager
271
+ exec(`open "${targetPath}"`);
272
+ return { output: `Opening: ${targetPath}` };
273
+ }
274
+
275
+ if (stat.isFile()) {
276
+ // It's a file, show its content (supports | pbcopy)
277
+ const content = fs.readFileSync(targetPath, 'utf-8');
278
+ return wrapResult({ output: content });
279
+ }
280
+ }
281
+
282
+ return wrapResult({ output: `Not found: ${name}` });
283
+ }
284
+
285
+ if (command === 'rm') {
286
+ if (parts.length < 2) {
287
+ return { output: 'Usage: rm <name> or rm -rf <name>' };
288
+ }
289
+
290
+ const isRecursive = parts[1] === '-rf' || parts[1] === '-r';
291
+ const targetName = isRecursive ? parts.slice(2).join(' ') : parts.slice(1).join(' ');
292
+
293
+ if (!targetName) {
294
+ return { output: 'Usage: rm <name> or rm -rf <name>' };
295
+ }
296
+
297
+ const targetPath = path.isAbsolute(targetName) ? targetName : path.join(currentPath, targetName);
298
+
299
+ if (!fs.existsSync(targetPath)) {
300
+ return { output: `Not found: ${targetName}` };
301
+ }
302
+
303
+ try {
304
+ if (isRecursive) {
305
+ fs.rmSync(targetPath, { recursive: true, force: true });
306
+ } else {
307
+ const stat = fs.statSync(targetPath);
308
+ if (stat.isDirectory()) {
309
+ return { output: `Error: ${targetName} is a directory. Use rm -rf to remove directories.` };
310
+ }
311
+ fs.unlinkSync(targetPath);
312
+ }
313
+ return { output: `Removed: ${targetName}` };
314
+ } catch (error: any) {
315
+ return { output: `Error: ${error.message}` };
316
+ }
317
+ }
318
+
319
+ if (command === 'init') {
320
+ const { initCommand } = await import('../commands/init');
321
+ await initCommand();
322
+ return { output: 'Initialization complete. You can now use rlc.\n', reloadConfig: true };
323
+ }
324
+
325
+ if (command === 'cd') {
326
+ if (parts.length < 2) {
327
+ return { output: 'Usage: cd <node> or cd .. or cd <path>' };
328
+ }
329
+
330
+ const target = parts.slice(1).join(' ');
331
+
332
+ if (target === '..') {
333
+ const parentPath = path.dirname(currentPath);
334
+ if (parentPath === config.storagePath || parentPath.length < config.storagePath.length) {
335
+ return { output: 'Already at root.' };
336
+ }
337
+ return { newPath: parentPath, output: '' };
338
+ }
339
+
340
+ if (target === '...') {
341
+ let targetPath = path.dirname(currentPath);
342
+ targetPath = path.dirname(targetPath);
343
+ if (targetPath.length < config.storagePath.length) {
344
+ return { output: 'Already at root.' };
345
+ }
346
+ return { newPath: targetPath, output: '' };
347
+ }
348
+
349
+ // Handle paths like "cd bank/account" or "cd ../other"
350
+ if (target.includes('/')) {
351
+ let targetPath = currentPath;
352
+ const pathParts = target.split('/');
353
+
354
+ for (const part of pathParts) {
355
+ if (part === '..') {
356
+ targetPath = path.dirname(targetPath);
357
+ } else if (part === '.') {
358
+ continue;
359
+ } else {
360
+ const newPath = navigateToNode(targetPath, part);
361
+ if (!newPath) {
362
+ return { output: `Path "${target}" not found.` };
363
+ }
364
+ targetPath = newPath;
365
+ }
366
+ }
367
+
368
+ return { newPath: targetPath, output: '' };
369
+ }
370
+
371
+ const newPath = navigateToNode(currentPath, target);
372
+ if (!newPath) {
373
+ return { output: `Node "${target}" not found.` };
374
+ }
375
+
376
+ return { newPath, output: '' };
377
+ }
378
+
379
+ if (command === 'pwd') {
380
+ return wrapResult({ output: currentPath });
381
+ }
382
+
383
+ if (command === 'config') {
384
+ const maskedKey = config.apiKey
385
+ ? config.apiKey.slice(0, 8) + '...' + config.apiKey.slice(-4)
386
+ : '(not set)';
387
+
388
+ const output = `
389
+ Provider: ${config.aiProvider}
390
+ Model: ${config.model || '(default)'}
391
+ API Key: ${maskedKey}
392
+ Storage: ${config.storagePath}
393
+ `.trim();
394
+
395
+ return wrapResult({ output });
396
+ }
397
+
398
+ if (command.startsWith('config:')) {
399
+ const configParts = input.split(':').slice(1).join(':').trim().split('=');
400
+ if (configParts.length !== 2) {
401
+ return { output: 'Usage: config:key=value' };
402
+ }
403
+
404
+ const key = configParts[0].trim();
405
+ const value = configParts[1].trim();
406
+
407
+ if (key === 'apiKey') {
408
+ const { updateConfig } = await import('../config/config');
409
+ updateConfig({ apiKey: value });
410
+ return { output: 'API key updated.' };
411
+ }
412
+
413
+ if (key === 'storagePath') {
414
+ const { updateConfig } = await import('../config/config');
415
+ updateConfig({ storagePath: value, currentPath: value });
416
+ return { output: `Storage path updated to: ${value}` };
417
+ }
418
+
419
+ return { output: `Unknown config key: ${key}` };
420
+ }
421
+
422
+ if (command === 'help') {
423
+ return wrapResult({
424
+ output: `Commands:
425
+ init - Initialize rlc (first time setup)
426
+ ls - List all schemas, todos, and notes
427
+ tree - Show directory tree structure
428
+ tree -A - Show tree with files
429
+ tree --depth=N - Limit tree depth (e.g., --depth=2)
430
+ cd <node> - Navigate into a node
431
+ cd .. - Go back to parent
432
+ pwd - Show current path
433
+ open - Open current folder in Finder
434
+ open <folder> - Open specific folder in Finder
435
+ mkdir <name> - Create new folder
436
+ cp <src> <dest> - Copy file or folder
437
+ mv <src> <dest> - Move/rename file or folder
438
+ rm <name> - Delete file
439
+ rm -rf <name> - Delete folder recursively
440
+ config - Show configuration
441
+ config:apiKey=<key> - Set API key
442
+ <description> - Create schema/todo (AI generates preview)
443
+ save - Save pending schema to disk
444
+ cancel - Discard pending schema
445
+ clean - Show items to delete in current folder
446
+ clean --yes - Delete all items in current folder
447
+ exit/quit - Exit the program
448
+
449
+ Clipboard:
450
+ ls | pbcopy - Copy output to clipboard (macOS)
451
+ tree | pbcopy - Works with any command
452
+ config | copy - Alternative for Windows
453
+
454
+ Workflow:
455
+ 1. Type description (e.g., "todo: deploy app")
456
+ 2. AI generates schema preview
457
+ 3. Refine with more instructions if needed
458
+ 4. Type "save" to save or "cancel" to discard
459
+
460
+ Examples:
461
+
462
+ > todo opening company in delaware
463
+
464
+ ┌─ TODO opening company in delaware ───────────────────────────┐
465
+ │ │
466
+ ├── register business name │
467
+ ├── file incorporation papers │
468
+ ├── get EIN number │
469
+ └── Branch: legal │
470
+ └── open business bank account │
471
+ │ │
472
+ └───────────────────────────────────────────────────────────────┘
473
+
474
+ > yandex cloud production infrastructure
475
+
476
+ ┌─────────────────────────────────────────────────────────────┐
477
+ │ Yandex Cloud │
478
+ │ │
479
+ │ ┌──────────────────┐ ┌──────────────────┐ │
480
+ │ │ back-fastapi │ │ admin-next │ │
481
+ │ │ (VM) │ │ (VM) │ │
482
+ │ └────────┬─────────┘ └──────────────────┘ │
483
+ │ │ │
484
+ │ ├──────────────────┬─────────────────┐ │
485
+ │ │ │ │ │
486
+ │ ┌────────▼────────┐ ┌─────▼──────┐ ┌──────▼────────┐ │
487
+ │ │ PostgreSQL │ │ Redis │ │ Cloudflare │ │
488
+ │ │ (Existing DB) │ │ Cluster │ │ R2 Storage │ │
489
+ │ └─────────────────┘ └────────────┘ └───────────────┘ │
490
+ └─────────────────────────────────────────────────────────────┘
491
+
492
+ > architecture production redis web application
493
+
494
+ ┌─ Architecture production redis web application ────────────┐
495
+ │ │
496
+ ├── load-balancer │
497
+ ├── web-servers │
498
+ │ ├── app-server-1 │
499
+ │ ├── app-server-2 │
500
+ │ └── app-server-3 │
501
+ ├── redis │
502
+ │ ├── cache-cluster │
503
+ │ └── session-store │
504
+ └── database │
505
+ ├── postgres-primary │
506
+ └── postgres-replica │
507
+ │ │
508
+ └───────────────────────────────────────────────────────────────┘
509
+
510
+ > kubernetes cluster with clusters postgres and redis
511
+
512
+ ┌─────────────────────────────────────────────────────────────┐
513
+ │ Kubernetes cluster with clusters postgres │
514
+ │ │
515
+ │ ┌──────────────┐ ┌──────────────┐ │
516
+ │ │ postgres │ │ redis │ │
517
+ │ │ │ │ │ │
518
+ │ │ primary-pod │ │ cache-pod-1 │ │
519
+ │ │ replica-pod-1│ │ cache-pod-2 │ │
520
+ │ │ replica-pod-2│ │ │ │
521
+ │ └──────┬───────┘ └──────┬───────┘ │
522
+ │ │ │ │
523
+ │ └──────────┬───────────┘ │
524
+ │ │ │
525
+ │ ┌───────▼────────┐ │
526
+ │ │ worker-zones │ │
527
+ │ │ zone-1 │ │
528
+ │ │ zone-2 │ │
529
+ │ └────────────────┘ │
530
+ └─────────────────────────────────────────────────────────────┘
531
+
532
+ www.rlc.rocks`
533
+ });
534
+ }
535
+
536
+ // Save command - save pending schema to .rlc.schema file
537
+ if (command === 'save') {
538
+ if (!sessionState.pending) {
539
+ return wrapResult({ output: 'Nothing to save. Create a schema first.' });
540
+ }
541
+
542
+ const schemaPath = saveSchemaFile(
543
+ currentPath,
544
+ sessionState.pending.title,
545
+ sessionState.pending.content
546
+ );
547
+ const relativePath = path.relative(config.storagePath, schemaPath);
548
+ const filename = path.basename(schemaPath);
549
+
550
+ // Clear session
551
+ sessionState.pending = null;
552
+ sessionState.history = [];
553
+
554
+ return wrapResult({ output: `Saved: ${filename}` });
555
+ }
556
+
557
+ // Cancel command - discard pending schema
558
+ if (command === 'cancel') {
559
+ if (!sessionState.pending) {
560
+ return wrapResult({ output: 'Nothing to cancel.' });
561
+ }
562
+
563
+ sessionState.pending = null;
564
+ sessionState.history = [];
565
+
566
+ return wrapResult({ output: 'Discarded pending schema.' });
567
+ }
568
+
569
+ // Clean command - clear current directory
570
+ if (command === 'clean') {
571
+ const entries = fs.readdirSync(currentPath);
572
+ const toDelete = entries.filter(e => !e.startsWith('.'));
573
+
574
+ if (toDelete.length === 0) {
575
+ return wrapResult({ output: 'Directory is already empty.' });
576
+ }
577
+
578
+ // Check for --yes flag to skip confirmation
579
+ if (!parts.includes('--yes') && !parts.includes('-y')) {
580
+ return wrapResult({
581
+ output: `Will delete ${toDelete.length} items:\n${toDelete.join('\n')}\n\nRun "clean --yes" to confirm.`
582
+ });
583
+ }
584
+
585
+ for (const entry of toDelete) {
586
+ const entryPath = path.join(currentPath, entry);
587
+ fs.rmSync(entryPath, { recursive: true, force: true });
588
+ }
589
+
590
+ return wrapResult({ output: `Deleted ${toDelete.length} items.` });
591
+ }
592
+
593
+ // AI generation - store in pending, don't save immediately
594
+ const fullInput = cleanInput;
595
+
596
+ // Add user message to history
597
+ sessionState.history.push({ role: 'user', content: fullInput });
598
+
599
+ const schema = await generateSchemaWithAI(fullInput, config, signal, sessionState.history);
600
+
601
+ if (signal?.aborted) {
602
+ return { output: 'Command cancelled.' };
603
+ }
604
+
605
+ if (schema) {
606
+ // Store in pending
607
+ sessionState.pending = {
608
+ title: schema.title,
609
+ content: schema.content,
610
+ tree: schema.tree
611
+ };
612
+
613
+ // Add assistant response to history
614
+ sessionState.history.push({ role: 'assistant', content: schema.content });
615
+
616
+ const filename = schema.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
617
+
618
+ return wrapResult({
619
+ output: `\n${schema.content}\n\n[Type "save" to save as ${filename}.rlc.schema, or refine with more instructions]`
620
+ });
621
+ }
622
+
623
+ return wrapResult({ output: 'Could not generate schema. Make sure API key is set.' });
624
+ }
625
+