nexusforge-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.
package/src/index.ts ADDED
@@ -0,0 +1,427 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * NexusForge CLI
4
+ *
5
+ * AI-powered development companion for your terminal.
6
+ * Like Claude Code, but for NexusForge.
7
+ *
8
+ * Usage:
9
+ * nexusforge - Start interactive chat
10
+ * nexusforge login - Authenticate with browser
11
+ * nexusforge logout - Clear credentials
12
+ * nexusforge status - Check connection status
13
+ * nexusforge --session X - Sync with web session
14
+ * nexusforge --help - Show help
15
+ */
16
+
17
+ import { Command } from 'commander';
18
+ import * as readline from 'readline';
19
+ import * as config from './utils/config.js';
20
+ import { displayWelcome, displayUserMessage, displayAssistantMessage, displaySystemMessage, style } from './utils/theme.js';
21
+ import { startDeviceAuth, logout, checkAuth, getCurrentUser } from './services/auth.js';
22
+ import * as api from './services/api.js';
23
+ import * as executor from './services/executor.js';
24
+ import type { ChatMessage } from './types/index.js';
25
+
26
+ const VERSION = '1.0.0';
27
+
28
+ // Current conversation state
29
+ let conversationId: string | undefined;
30
+ let messages: ChatMessage[] = [];
31
+
32
+ // Create CLI program
33
+ const program = new Command();
34
+
35
+ program
36
+ .name('nexusforge')
37
+ .description('NexusForge CLI - AI-powered development companion')
38
+ .version(VERSION);
39
+
40
+ // Login command
41
+ program
42
+ .command('login')
43
+ .description('Authenticate with NexusForge via browser')
44
+ .action(async () => {
45
+ const success = await startDeviceAuth();
46
+ process.exit(success ? 0 : 1);
47
+ });
48
+
49
+ // Logout command
50
+ program
51
+ .command('logout')
52
+ .description('Clear saved credentials')
53
+ .action(() => {
54
+ logout();
55
+ process.exit(0);
56
+ });
57
+
58
+ // Status command
59
+ program
60
+ .command('status')
61
+ .description('Check connection and authentication status')
62
+ .action(async () => {
63
+ console.log('');
64
+ console.log(style.assistant('NexusForge CLI Status'));
65
+ console.log(style.divider());
66
+ console.log('');
67
+
68
+ // Check auth
69
+ const isAuth = await checkAuth();
70
+ const user = getCurrentUser();
71
+
72
+ if (isAuth && user) {
73
+ displaySystemMessage(`Authenticated as: ${user.username}`, 'success');
74
+ } else if (config.isAuthenticated()) {
75
+ displaySystemMessage('Token may be expired. Run "nexusforge login" to re-authenticate.', 'warning');
76
+ } else {
77
+ displaySystemMessage('Not authenticated. Run "nexusforge login" to authenticate.', 'warning');
78
+ }
79
+
80
+ // Check API health
81
+ const isHealthy = await api.checkHealth();
82
+ if (isHealthy) {
83
+ displaySystemMessage(`API: ${config.getApiUrl()} (healthy)`, 'success');
84
+ } else {
85
+ displaySystemMessage(`API: ${config.getApiUrl()} (unreachable)`, 'error');
86
+ }
87
+
88
+ console.log('');
89
+ console.log(style.muted(`Config file: ${config.getConfigPath()}`));
90
+ console.log('');
91
+ process.exit(0);
92
+ });
93
+
94
+ // Main command (interactive chat)
95
+ program
96
+ .option('-s, --session <token>', 'Sync with a web session token')
97
+ .option('-m, --model <name>', 'Use a specific model')
98
+ .option('--cwd <path>', 'Set working directory')
99
+ .action(async (options) => {
100
+ // Change working directory if specified
101
+ if (options.cwd) {
102
+ if (!executor.changeCwd(options.cwd)) {
103
+ displaySystemMessage(`Cannot change to directory: ${options.cwd}`, 'error');
104
+ process.exit(1);
105
+ }
106
+ }
107
+
108
+ // Check authentication
109
+ if (!config.isAuthenticated()) {
110
+ displaySystemMessage('Not authenticated. Starting login...', 'info');
111
+ const success = await startDeviceAuth();
112
+ if (!success) {
113
+ process.exit(1);
114
+ }
115
+ }
116
+
117
+ // Sync with web session if provided
118
+ if (options.session) {
119
+ try {
120
+ displaySystemMessage('Syncing with web session...', 'info');
121
+ const session = await api.getSessionStatus(options.session);
122
+ conversationId = session.conversation_id;
123
+ displaySystemMessage(`Synced with conversation: ${conversationId}`, 'success');
124
+ } catch (error) {
125
+ displaySystemMessage('Failed to sync with session. Starting fresh.', 'warning');
126
+ }
127
+ }
128
+
129
+ // Display welcome screen
130
+ const user = getCurrentUser();
131
+ displayWelcome(
132
+ VERSION,
133
+ config.getApiUrl(),
134
+ options.model || config.get('defaultModel') || 'NexusForge',
135
+ );
136
+
137
+ if (user?.username) {
138
+ displaySystemMessage(`Logged in as ${user.username}`, 'success');
139
+ }
140
+
141
+ // Start interactive loop
142
+ await startInteractiveChat(options.model);
143
+ });
144
+
145
+ // Parse arguments
146
+ program.parse();
147
+
148
+ /**
149
+ * Start the interactive chat loop
150
+ */
151
+ async function startInteractiveChat(model?: string): Promise<void> {
152
+ const rl = readline.createInterface({
153
+ input: process.stdin,
154
+ output: process.stdout,
155
+ });
156
+
157
+ const promptUser = (): void => {
158
+ rl.question(`${style.userLabel('You')} ${style.prompt('❯')} `, async (input) => {
159
+ const trimmedInput = input.trim();
160
+
161
+ if (!trimmedInput) {
162
+ promptUser();
163
+ return;
164
+ }
165
+
166
+ // Handle slash commands
167
+ if (trimmedInput.startsWith('/')) {
168
+ await handleSlashCommand(trimmedInput, rl);
169
+ promptUser();
170
+ return;
171
+ }
172
+
173
+ // Send message to AI
174
+ try {
175
+ console.log('');
176
+ process.stdout.write(`${style.assistantLabel('NexusForge')} ${style.prompt('❯')} `);
177
+
178
+ // Stream response
179
+ let fullResponse = '';
180
+ for await (const chunk of api.streamMessage(trimmedInput, conversationId, model)) {
181
+ process.stdout.write(style.assistant(chunk));
182
+ fullResponse += chunk;
183
+ }
184
+ console.log('\n');
185
+
186
+ // Store messages
187
+ messages.push({ role: 'user', content: trimmedInput });
188
+ messages.push({ role: 'assistant', content: fullResponse });
189
+
190
+ // Check for executable commands in response
191
+ const commands = executor.parseCommandsFromResponse(fullResponse);
192
+ if (commands.length > 0 && config.get('autoExecute')) {
193
+ await promptToExecuteCommands(commands, rl);
194
+ }
195
+ } catch (error) {
196
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
197
+ displaySystemMessage(`Error: ${errorMessage}`, 'error');
198
+ }
199
+
200
+ promptUser();
201
+ });
202
+ };
203
+
204
+ promptUser();
205
+ }
206
+
207
+ /**
208
+ * Handle slash commands
209
+ */
210
+ async function handleSlashCommand(input: string, rl: readline.Interface): Promise<void> {
211
+ const parts = input.slice(1).split(/\s+/);
212
+ const command = parts[0].toLowerCase();
213
+ const args = parts.slice(1);
214
+
215
+ switch (command) {
216
+ case 'help':
217
+ case 'h':
218
+ displayHelp();
219
+ break;
220
+
221
+ case 'exit':
222
+ case 'quit':
223
+ case 'q':
224
+ console.log('');
225
+ displaySystemMessage('Goodbye!', 'info');
226
+ rl.close();
227
+ process.exit(0);
228
+
229
+ case 'clear':
230
+ case 'c':
231
+ console.clear();
232
+ displayWelcome(VERSION, config.getApiUrl(), config.get('defaultModel') || 'NexusForge');
233
+ break;
234
+
235
+ case 'history':
236
+ displayHistory();
237
+ break;
238
+
239
+ case 'resume':
240
+ await resumeConversation(args[0]);
241
+ break;
242
+
243
+ case 'model':
244
+ if (args[0]) {
245
+ config.set('defaultModel', args[0]);
246
+ displaySystemMessage(`Model set to: ${args[0]}`, 'success');
247
+ } else {
248
+ displaySystemMessage(`Current model: ${config.get('defaultModel')}`, 'info');
249
+ }
250
+ break;
251
+
252
+ case 'status':
253
+ await displayStatus();
254
+ break;
255
+
256
+ case 'read':
257
+ if (args[0]) {
258
+ await readFile(args[0]);
259
+ } else {
260
+ displaySystemMessage('Usage: /read <file_path>', 'warning');
261
+ }
262
+ break;
263
+
264
+ case 'exec':
265
+ case 'run':
266
+ if (args.length > 0) {
267
+ const cmd = args.join(' ');
268
+ const result = await executor.executeCommandStream(cmd, undefined,
269
+ (data) => process.stdout.write(data),
270
+ (data) => process.stderr.write(style.error(data))
271
+ );
272
+ executor.displayCommandResult(result);
273
+ } else {
274
+ displaySystemMessage('Usage: /exec <command>', 'warning');
275
+ }
276
+ break;
277
+
278
+ default:
279
+ displaySystemMessage(`Unknown command: /${command}. Type /help for available commands.`, 'warning');
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Display help
285
+ */
286
+ function displayHelp(): void {
287
+ console.log('');
288
+ console.log(style.assistant('Available Commands:'));
289
+ console.log(style.divider());
290
+ console.log('');
291
+ console.log(` ${style.prompt('/help, /h')} - Show this help`);
292
+ console.log(` ${style.prompt('/exit, /q')} - Exit CLI`);
293
+ console.log(` ${style.prompt('/clear, /c')} - Clear screen`);
294
+ console.log(` ${style.prompt('/history')} - Show conversation history`);
295
+ console.log(` ${style.prompt('/resume [id]')} - Resume a previous conversation`);
296
+ console.log(` ${style.prompt('/model [name]')} - Get or set the model`);
297
+ console.log(` ${style.prompt('/status')} - Show connection status`);
298
+ console.log(` ${style.prompt('/read <path>')} - Read a file into context`);
299
+ console.log(` ${style.prompt('/exec <cmd>')} - Execute a shell command`);
300
+ console.log('');
301
+ console.log(style.muted('Just type your message to chat with NexusForge!'));
302
+ console.log('');
303
+ }
304
+
305
+ /**
306
+ * Display conversation history
307
+ */
308
+ function displayHistory(): void {
309
+ console.log('');
310
+ if (messages.length === 0) {
311
+ displaySystemMessage('No messages in current conversation.', 'info');
312
+ return;
313
+ }
314
+
315
+ console.log(style.assistant('Conversation History:'));
316
+ console.log(style.divider());
317
+ console.log('');
318
+
319
+ for (const msg of messages) {
320
+ if (msg.role === 'user') {
321
+ displayUserMessage(msg.content);
322
+ } else {
323
+ displayAssistantMessage(msg.content.substring(0, 200) + (msg.content.length > 200 ? '...' : ''));
324
+ }
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Resume a previous conversation
330
+ */
331
+ async function resumeConversation(id?: string): Promise<void> {
332
+ displaySystemMessage('Resume functionality coming soon!', 'info');
333
+ }
334
+
335
+ /**
336
+ * Display current status
337
+ */
338
+ async function displayStatus(): Promise<void> {
339
+ console.log('');
340
+ const user = getCurrentUser();
341
+ displaySystemMessage(`User: ${user?.username || 'Unknown'}`, 'info');
342
+ displaySystemMessage(`API: ${config.getApiUrl()}`, 'info');
343
+ displaySystemMessage(`Model: ${config.get('defaultModel')}`, 'info');
344
+ displaySystemMessage(`Conversation: ${conversationId || 'New'}`, 'info');
345
+ displaySystemMessage(`Messages: ${messages.length}`, 'info');
346
+ displaySystemMessage(`CWD: ${executor.getCwd()}`, 'info');
347
+ console.log('');
348
+ }
349
+
350
+ /**
351
+ * Read a file and display it
352
+ */
353
+ async function readFile(path: string): Promise<void> {
354
+ const fs = await import('fs/promises');
355
+ try {
356
+ const content = await fs.readFile(path, 'utf-8');
357
+ console.log('');
358
+ console.log(style.muted(`--- ${path} ---`));
359
+ console.log(style.code(content));
360
+ console.log(style.muted(`--- end ${path} ---`));
361
+ console.log('');
362
+ displaySystemMessage(`File "${path}" added to context.`, 'success');
363
+ } catch (error) {
364
+ displaySystemMessage(`Cannot read file: ${path}`, 'error');
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Prompt user to execute detected commands
370
+ */
371
+ async function promptToExecuteCommands(
372
+ commands: string[],
373
+ rl: readline.Interface,
374
+ ): Promise<void> {
375
+ console.log('');
376
+ displaySystemMessage(`Found ${commands.length} command(s) in response:`, 'info');
377
+
378
+ for (let i = 0; i < commands.length; i++) {
379
+ console.log(style.muted(` ${i + 1}. ${commands[i]}`));
380
+ }
381
+
382
+ console.log('');
383
+
384
+ return new Promise((resolve) => {
385
+ rl.question(
386
+ `${style.prompt('Execute these commands? [y/N/1-n]: ')}`,
387
+ async (answer) => {
388
+ const trimmed = answer.trim().toLowerCase();
389
+
390
+ if (trimmed === 'y' || trimmed === 'yes') {
391
+ // Execute all commands
392
+ for (const cmd of commands) {
393
+ if (executor.isDangerous(cmd)) {
394
+ displaySystemMessage(`Skipping dangerous command: ${cmd}`, 'warning');
395
+ continue;
396
+ }
397
+ const result = await executor.executeCommandStream(cmd, undefined,
398
+ (data) => process.stdout.write(data),
399
+ (data) => process.stderr.write(style.error(data))
400
+ );
401
+ if (result.exitCode !== 0) {
402
+ displaySystemMessage(`Command failed with exit code ${result.exitCode}`, 'error');
403
+ break;
404
+ }
405
+ }
406
+ } else if (/^\d+$/.test(trimmed)) {
407
+ // Execute specific command
408
+ const index = parseInt(trimmed, 10) - 1;
409
+ if (index >= 0 && index < commands.length) {
410
+ const cmd = commands[index];
411
+ if (executor.isDangerous(cmd)) {
412
+ displaySystemMessage(`Cannot execute dangerous command`, 'error');
413
+ } else {
414
+ const result = await executor.executeCommandStream(cmd, undefined,
415
+ (data) => process.stdout.write(data),
416
+ (data) => process.stderr.write(style.error(data))
417
+ );
418
+ executor.displayCommandResult(result);
419
+ }
420
+ }
421
+ }
422
+
423
+ resolve();
424
+ },
425
+ );
426
+ });
427
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * NexusForge CLI API Service
3
+ *
4
+ * Handles all API communication with NexusConnectBridge backend.
5
+ */
6
+
7
+ import * as config from '../utils/config.js';
8
+ import type {
9
+ ChatMessage,
10
+ ChatResponse,
11
+ CLISession,
12
+ FileContext,
13
+ } from '../types/index.js';
14
+
15
+ /**
16
+ * Make an authenticated API request
17
+ */
18
+ async function apiRequest<T>(
19
+ endpoint: string,
20
+ options: RequestInit = {},
21
+ ): Promise<T> {
22
+ const apiUrl = config.getApiUrl();
23
+ const token = config.getAccessToken();
24
+
25
+ const headers: Record<string, string> = {
26
+ 'Content-Type': 'application/json',
27
+ ...((options.headers as Record<string, string>) || {}),
28
+ };
29
+
30
+ if (token) {
31
+ headers['Authorization'] = `Bearer ${token}`;
32
+ }
33
+
34
+ const response = await fetch(`${apiUrl}${endpoint}`, {
35
+ ...options,
36
+ headers,
37
+ });
38
+
39
+ if (!response.ok) {
40
+ const errorText = await response.text();
41
+ throw new Error(`API error (${response.status}): ${errorText}`);
42
+ }
43
+
44
+ return response.json() as Promise<T>;
45
+ }
46
+
47
+ /**
48
+ * Send a chat message and get response
49
+ */
50
+ export async function sendMessage(
51
+ message: string,
52
+ conversationId?: string,
53
+ model?: string,
54
+ fileContext?: FileContext[],
55
+ ): Promise<ChatResponse> {
56
+ return apiRequest<ChatResponse>('/cli/chat', {
57
+ method: 'POST',
58
+ body: JSON.stringify({
59
+ message,
60
+ conversation_id: conversationId,
61
+ model,
62
+ file_context: fileContext,
63
+ }),
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Stream a chat message response
69
+ */
70
+ export async function* streamMessage(
71
+ message: string,
72
+ conversationId?: string,
73
+ model?: string,
74
+ fileContext?: FileContext[],
75
+ ): AsyncGenerator<string, void, unknown> {
76
+ const apiUrl = config.getApiUrl();
77
+ const token = config.getAccessToken();
78
+
79
+ const response = await fetch(`${apiUrl}/ai/chat/stream`, {
80
+ method: 'POST',
81
+ headers: {
82
+ 'Content-Type': 'application/json',
83
+ Authorization: token ? `Bearer ${token}` : '',
84
+ },
85
+ body: JSON.stringify({
86
+ message,
87
+ conversation_id: conversationId,
88
+ model,
89
+ }),
90
+ });
91
+
92
+ if (!response.ok) {
93
+ throw new Error(`API error: ${response.statusText}`);
94
+ }
95
+
96
+ const reader = response.body?.getReader();
97
+ if (!reader) {
98
+ throw new Error('No response body');
99
+ }
100
+
101
+ const decoder = new TextDecoder();
102
+
103
+ while (true) {
104
+ const { done, value } = await reader.read();
105
+ if (done) break;
106
+
107
+ const chunk = decoder.decode(value, { stream: true });
108
+ yield chunk;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Create a new CLI session
114
+ */
115
+ export async function createSession(
116
+ conversationId?: string,
117
+ workingDirectory?: string,
118
+ ): Promise<CLISession> {
119
+ return apiRequest<CLISession>('/cli/sessions', {
120
+ method: 'POST',
121
+ body: JSON.stringify({
122
+ conversation_id: conversationId,
123
+ working_directory: workingDirectory,
124
+ }),
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Get session status
130
+ */
131
+ export async function getSessionStatus(sessionToken: string): Promise<CLISession> {
132
+ return apiRequest<CLISession>(`/cli/sessions/${sessionToken}`);
133
+ }
134
+
135
+ /**
136
+ * Log a command execution
137
+ */
138
+ export async function logCommandExecution(data: {
139
+ command: string;
140
+ workingDirectory: string;
141
+ exitCode?: number;
142
+ output?: string;
143
+ duration?: number;
144
+ }): Promise<void> {
145
+ await apiRequest('/cli/execute', {
146
+ method: 'POST',
147
+ body: JSON.stringify({
148
+ command: data.command,
149
+ working_directory: data.workingDirectory,
150
+ exit_code: data.exitCode,
151
+ output: data.output,
152
+ duration_ms: data.duration,
153
+ }),
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Sync conversation from web
159
+ */
160
+ export async function syncConversation(
161
+ conversationId: string,
162
+ sinceMessageId?: string,
163
+ ): Promise<{
164
+ conversation_id: string;
165
+ title?: string;
166
+ model: string;
167
+ messages: ChatMessage[];
168
+ has_more: boolean;
169
+ }> {
170
+ return apiRequest('/cli/sync', {
171
+ method: 'POST',
172
+ body: JSON.stringify({
173
+ conversation_id: conversationId,
174
+ since_message_id: sinceMessageId,
175
+ }),
176
+ });
177
+ }
178
+
179
+ /**
180
+ * Get available models
181
+ */
182
+ export async function getModels(): Promise<string[]> {
183
+ const response = await apiRequest<{ models: Array<{ name: string }> }>('/ai/models');
184
+ return response.models.map((m) => m.name);
185
+ }
186
+
187
+ /**
188
+ * Check API health
189
+ */
190
+ export async function checkHealth(): Promise<boolean> {
191
+ try {
192
+ const apiUrl = config.getApiUrl();
193
+ const response = await fetch(`${apiUrl.replace('/api/v1/bridge', '')}/health`);
194
+ return response.ok;
195
+ } catch {
196
+ return false;
197
+ }
198
+ }