gbos 1.3.20 → 1.3.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gbos",
3
- "version": "1.3.20",
3
+ "version": "1.3.22",
4
4
  "description": "GBOS - Command line interface for GBOS services",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -6,7 +6,9 @@ const program = new Command();
6
6
  const authCommand = require('./commands/auth');
7
7
  const connectCommand = require('./commands/connect');
8
8
  const logoutCommand = require('./commands/logout');
9
- const { tasksCommand, nextTaskCommand, continueCommand, fallbackCommand, addTaskCommand } = require('./commands/tasks');
9
+ const { tasksCommand, nextTaskCommand, continueCommand, fallbackCommand, addTaskCommand, completedCommand } = require('./commands/tasks');
10
+ const { syncStartCommand, syncStopCommand, syncStatusCommand, syncNowCommand, repoCreateCommand, repoListCommand, repoCloneCommand } = require('./commands/gitlab');
11
+ const { registryLoginCommand, registryImagesCommand, registryPushCommand, registryPullCommand } = require('./commands/registry');
10
12
  const config = require('./lib/config');
11
13
  const { displayStatus, printBanner } = require('./lib/display');
12
14
 
@@ -118,6 +120,13 @@ program
118
120
  .description('Cancel work from the current task and revert to last completed state')
119
121
  .action(fallbackCommand);
120
122
 
123
+ program
124
+ .command('completed')
125
+ .alias('done')
126
+ .description('Complete current task: commit, push to GitLab (creates repo if needed), and mark task done')
127
+ .option('-m, --message <message>', 'Custom commit message')
128
+ .action(completedCommand);
129
+
121
130
  program
122
131
  .command('add_task')
123
132
  .alias('add')
@@ -130,6 +139,101 @@ program
130
139
  .option('-a, --all', 'Clear all stored data including machine ID')
131
140
  .action(logoutCommand);
132
141
 
142
+ // ==================== GitLab Commands ====================
143
+
144
+ const gitlabCmd = program
145
+ .command('gitlab')
146
+ .description('GitLab integration commands');
147
+
148
+ // GitLab Sync subcommands
149
+ const gitlabSync = gitlabCmd
150
+ .command('sync')
151
+ .description('Auto-sync repository with GitLab');
152
+
153
+ gitlabSync
154
+ .command('start')
155
+ .description('Start auto-syncing a repository')
156
+ .option('-p, --path <path>', 'Path to repository (defaults to current directory)')
157
+ .option('-i, --interval <seconds>', 'Sync interval in seconds (default: 60)', parseInt)
158
+ .action(syncStartCommand);
159
+
160
+ gitlabSync
161
+ .command('stop')
162
+ .description('Stop auto-syncing a repository')
163
+ .option('-p, --path <path>', 'Path to repository (defaults to current directory)')
164
+ .option('-a, --all', 'Stop all active syncs')
165
+ .action(syncStopCommand);
166
+
167
+ gitlabSync
168
+ .command('status')
169
+ .description('Show status of all active syncs')
170
+ .action(syncStatusCommand);
171
+
172
+ gitlabSync
173
+ .command('now')
174
+ .description('Force an immediate sync')
175
+ .option('-p, --path <path>', 'Path to repository (defaults to current directory)')
176
+ .action(syncNowCommand);
177
+
178
+ // GitLab Repo subcommands
179
+ const gitlabRepo = gitlabCmd
180
+ .command('repo')
181
+ .description('GitLab repository management');
182
+
183
+ gitlabRepo
184
+ .command('create <name>')
185
+ .description('Create a new GitLab repository')
186
+ .option('--private', 'Create as private repository (default)')
187
+ .option('--public', 'Create as public repository')
188
+ .option('-d, --description <description>', 'Repository description')
189
+ .option('--readme', 'Initialize with README')
190
+ .action(repoCreateCommand);
191
+
192
+ gitlabRepo
193
+ .command('list')
194
+ .description('List GitLab repositories')
195
+ .option('-a, --all', 'Show all accessible repositories (not just owned)')
196
+ .action(repoListCommand);
197
+
198
+ gitlabRepo
199
+ .command('clone <name>')
200
+ .description('Clone a GitLab repository')
201
+ .option('--ssh', 'Use SSH URL instead of HTTPS')
202
+ .option('-d, --dir <directory>', 'Target directory name')
203
+ .action(repoCloneCommand);
204
+
205
+ // ==================== Registry Commands ====================
206
+
207
+ const registryCmd = program
208
+ .command('registry')
209
+ .description('GitLab Container Registry commands');
210
+
211
+ registryCmd
212
+ .command('login')
213
+ .description('Login to GitLab Container Registry')
214
+ .option('-r, --registry <url>', 'Registry URL (defaults to registry.gitlab.com)')
215
+ .action(registryLoginCommand);
216
+
217
+ registryCmd
218
+ .command('images <project>')
219
+ .description('List container images in a project')
220
+ .option('-t, --tags', 'Show tags for each image')
221
+ .action(registryImagesCommand);
222
+
223
+ registryCmd
224
+ .command('push <image>')
225
+ .description('Push an image to GitLab Container Registry')
226
+ .option('-p, --project <project>', 'GitLab project path (e.g., group/project)')
227
+ .option('-r, --registry <url>', 'Registry URL (defaults to registry.gitlab.com)')
228
+ .action(registryPushCommand);
229
+
230
+ registryCmd
231
+ .command('pull <image>')
232
+ .description('Pull an image from GitLab Container Registry')
233
+ .option('-p, --project <project>', 'GitLab project path (e.g., group/project)')
234
+ .option('-r, --registry <url>', 'Registry URL (defaults to registry.gitlab.com)')
235
+ .action(registryPullCommand);
236
+
133
237
  program
134
238
  .command('help [command]')
135
239
  .description('Display help for a specific command')
@@ -140,7 +244,7 @@ program
140
244
  cmd.outputHelp();
141
245
  } else {
142
246
  console.log(`Unknown command: ${command}`);
143
- console.log('Available commands: auth, connect, disconnect, status, tasks, next, continue, fallback, add_task, logout, help');
247
+ console.log('Available commands: auth, connect, disconnect, status, tasks, next, continue, completed, fallback, add_task, logout, gitlab, registry, help');
144
248
  }
145
249
  } else {
146
250
  program.outputHelp();
@@ -0,0 +1,470 @@
1
+ const config = require('../lib/config');
2
+ const { displayMessageBox, fg, LOGO_PURPLE, LOGO_LIGHT, RESET, BOLD, DIM, getTerminalWidth } = require('../lib/display');
3
+ const { exec, spawn } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ // Colors
9
+ const CYAN = '\x1b[36m';
10
+ const GREEN = '\x1b[32m';
11
+ const YELLOW = '\x1b[33m';
12
+ const RED = '\x1b[31m';
13
+
14
+ // GitLab configuration
15
+ const GITLAB_CONFIG_FILE = path.join(os.homedir(), '.gbos', 'gitlab.json');
16
+ const SYNC_PID_DIR = path.join(os.homedir(), '.gbos', 'sync');
17
+
18
+ // Load GitLab config
19
+ function loadGitLabConfig() {
20
+ try {
21
+ if (fs.existsSync(GITLAB_CONFIG_FILE)) {
22
+ return JSON.parse(fs.readFileSync(GITLAB_CONFIG_FILE, 'utf8'));
23
+ }
24
+ } catch (e) {
25
+ // Ignore errors
26
+ }
27
+ return { syncs: {}, repos: [] };
28
+ }
29
+
30
+ // Save GitLab config
31
+ function saveGitLabConfig(config) {
32
+ const dir = path.dirname(GITLAB_CONFIG_FILE);
33
+ if (!fs.existsSync(dir)) {
34
+ fs.mkdirSync(dir, { recursive: true });
35
+ }
36
+ fs.writeFileSync(GITLAB_CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
37
+ }
38
+
39
+ // Get GitLab URL from session or config
40
+ function getGitLabUrl() {
41
+ const session = config.loadSession();
42
+ return session?.gitlab_url || process.env.GITLAB_URL || 'https://gitlab.com';
43
+ }
44
+
45
+ // Get GitLab token
46
+ function getGitLabToken() {
47
+ const session = config.loadSession();
48
+ return session?.gitlab_token || process.env.GITLAB_TOKEN || null;
49
+ }
50
+
51
+ // Execute git command
52
+ function execGit(args, cwd = process.cwd()) {
53
+ return new Promise((resolve, reject) => {
54
+ exec(`git ${args}`, { cwd }, (error, stdout, stderr) => {
55
+ if (error) {
56
+ reject(new Error(stderr || error.message));
57
+ } else {
58
+ resolve(stdout.trim());
59
+ }
60
+ });
61
+ });
62
+ }
63
+
64
+ // ==================== SYNC COMMANDS ====================
65
+
66
+ // Start auto-sync for a directory
67
+ async function syncStartCommand(options) {
68
+ const targetPath = options.path || process.cwd();
69
+ const absolutePath = path.resolve(targetPath);
70
+
71
+ // Verify it's a git repository
72
+ try {
73
+ await execGit('rev-parse --is-inside-work-tree', absolutePath);
74
+ } catch (e) {
75
+ displayMessageBox('Not a Git Repository', `${absolutePath} is not a git repository.`, 'error');
76
+ process.exit(1);
77
+ }
78
+
79
+ // Get remote URL
80
+ let remoteUrl;
81
+ try {
82
+ remoteUrl = await execGit('remote get-url origin', absolutePath);
83
+ } catch (e) {
84
+ displayMessageBox('No Remote', 'No git remote "origin" configured.', 'error');
85
+ process.exit(1);
86
+ }
87
+
88
+ const interval = options.interval || 60; // Default 60 seconds
89
+
90
+ // Check if already syncing
91
+ const gitlabConfig = loadGitLabConfig();
92
+ if (gitlabConfig.syncs[absolutePath]) {
93
+ console.log(`\n${YELLOW}!${RESET} Sync already active for ${absolutePath}`);
94
+ console.log(` ${DIM}Use "gbos gitlab sync stop" to stop it first.${RESET}\n`);
95
+ return;
96
+ }
97
+
98
+ // Create sync PID directory
99
+ if (!fs.existsSync(SYNC_PID_DIR)) {
100
+ fs.mkdirSync(SYNC_PID_DIR, { recursive: true });
101
+ }
102
+
103
+ // Start background sync process
104
+ const syncId = Date.now().toString();
105
+ const pidFile = path.join(SYNC_PID_DIR, `${syncId}.pid`);
106
+
107
+ // Create sync script
108
+ const syncScript = `
109
+ while true; do
110
+ cd "${absolutePath}"
111
+ git fetch origin 2>/dev/null
112
+ git add -A 2>/dev/null
113
+ CHANGES=$(git status --porcelain)
114
+ if [ -n "$CHANGES" ]; then
115
+ git commit -m "Auto-sync: $(date '+%Y-%m-%d %H:%M:%S')" 2>/dev/null
116
+ git push origin HEAD 2>/dev/null
117
+ fi
118
+ sleep ${interval}
119
+ done
120
+ `;
121
+
122
+ const child = spawn('bash', ['-c', syncScript], {
123
+ detached: true,
124
+ stdio: 'ignore',
125
+ });
126
+
127
+ child.unref();
128
+
129
+ // Save PID
130
+ fs.writeFileSync(pidFile, child.pid.toString(), 'utf8');
131
+
132
+ // Update config
133
+ gitlabConfig.syncs[absolutePath] = {
134
+ syncId,
135
+ pid: child.pid,
136
+ pidFile,
137
+ remote: remoteUrl,
138
+ interval,
139
+ startedAt: new Date().toISOString(),
140
+ };
141
+ saveGitLabConfig(gitlabConfig);
142
+
143
+ console.log(`\n${GREEN}✓${RESET} ${BOLD}Auto-sync started${RESET}`);
144
+ console.log(` ${DIM}Path:${RESET} ${absolutePath}`);
145
+ console.log(` ${DIM}Remote:${RESET} ${remoteUrl}`);
146
+ console.log(` ${DIM}Interval:${RESET} ${interval} seconds`);
147
+ console.log(` ${DIM}PID:${RESET} ${child.pid}\n`);
148
+ console.log(` ${DIM}Use "gbos gitlab sync stop" to stop syncing.${RESET}\n`);
149
+ }
150
+
151
+ // Stop auto-sync
152
+ async function syncStopCommand(options) {
153
+ const targetPath = options.path ? path.resolve(options.path) : process.cwd();
154
+
155
+ const gitlabConfig = loadGitLabConfig();
156
+
157
+ // Find sync for this path or stop all
158
+ let syncsToStop = [];
159
+
160
+ if (options.all) {
161
+ syncsToStop = Object.entries(gitlabConfig.syncs);
162
+ } else if (gitlabConfig.syncs[targetPath]) {
163
+ syncsToStop = [[targetPath, gitlabConfig.syncs[targetPath]]];
164
+ } else {
165
+ console.log(`\n${DIM}No active sync for ${targetPath}${RESET}\n`);
166
+ return;
167
+ }
168
+
169
+ for (const [syncPath, syncInfo] of syncsToStop) {
170
+ try {
171
+ // Kill the process
172
+ process.kill(syncInfo.pid, 'SIGTERM');
173
+ } catch (e) {
174
+ // Process may already be dead
175
+ }
176
+
177
+ // Remove PID file
178
+ try {
179
+ if (fs.existsSync(syncInfo.pidFile)) {
180
+ fs.unlinkSync(syncInfo.pidFile);
181
+ }
182
+ } catch (e) {
183
+ // Ignore
184
+ }
185
+
186
+ // Remove from config
187
+ delete gitlabConfig.syncs[syncPath];
188
+
189
+ console.log(`${GREEN}✓${RESET} Stopped sync for ${syncPath}`);
190
+ }
191
+
192
+ saveGitLabConfig(gitlabConfig);
193
+ console.log('');
194
+ }
195
+
196
+ // Show sync status
197
+ async function syncStatusCommand() {
198
+ const gitlabConfig = loadGitLabConfig();
199
+ const syncs = Object.entries(gitlabConfig.syncs);
200
+
201
+ const termWidth = getTerminalWidth();
202
+ const tableWidth = Math.min(100, termWidth - 4);
203
+
204
+ console.log(`\n${fg(...LOGO_PURPLE)}${'─'.repeat(tableWidth)}${RESET}`);
205
+ console.log(`${BOLD} Active Syncs${RESET}`);
206
+ console.log(`${fg(...LOGO_PURPLE)}${'─'.repeat(tableWidth)}${RESET}\n`);
207
+
208
+ if (syncs.length === 0) {
209
+ console.log(` ${DIM}No active syncs.${RESET}`);
210
+ console.log(` ${DIM}Use "gbos gitlab sync start" to start syncing a repository.${RESET}\n`);
211
+ } else {
212
+ for (const [syncPath, syncInfo] of syncs) {
213
+ // Check if process is still running
214
+ let isRunning = false;
215
+ try {
216
+ process.kill(syncInfo.pid, 0);
217
+ isRunning = true;
218
+ } catch (e) {
219
+ isRunning = false;
220
+ }
221
+
222
+ const status = isRunning ? `${GREEN}● running${RESET}` : `${RED}● stopped${RESET}`;
223
+ console.log(` ${status} ${BOLD}${syncPath}${RESET}`);
224
+ console.log(` ${DIM}Remote: ${syncInfo.remote}${RESET}`);
225
+ console.log(` ${DIM}Interval: ${syncInfo.interval}s | PID: ${syncInfo.pid}${RESET}`);
226
+ console.log(` ${DIM}Started: ${new Date(syncInfo.startedAt).toLocaleString()}${RESET}`);
227
+ console.log('');
228
+ }
229
+ }
230
+
231
+ console.log(`${fg(...LOGO_PURPLE)}${'─'.repeat(tableWidth)}${RESET}\n`);
232
+ }
233
+
234
+ // Force immediate sync
235
+ async function syncNowCommand(options) {
236
+ const targetPath = options.path ? path.resolve(options.path) : process.cwd();
237
+
238
+ // Verify it's a git repository
239
+ try {
240
+ await execGit('rev-parse --is-inside-work-tree', targetPath);
241
+ } catch (e) {
242
+ displayMessageBox('Not a Git Repository', `${targetPath} is not a git repository.`, 'error');
243
+ process.exit(1);
244
+ }
245
+
246
+ console.log(`\n${DIM}Syncing ${targetPath}...${RESET}\n`);
247
+
248
+ try {
249
+ // Fetch from remote
250
+ console.log(` ${DIM}Fetching from origin...${RESET}`);
251
+ await execGit('fetch origin', targetPath);
252
+
253
+ // Stage all changes
254
+ console.log(` ${DIM}Staging changes...${RESET}`);
255
+ await execGit('add -A', targetPath);
256
+
257
+ // Check for changes
258
+ const status = await execGit('status --porcelain', targetPath);
259
+
260
+ if (status) {
261
+ // Commit changes
262
+ const commitMsg = `Manual sync: ${new Date().toISOString()}`;
263
+ console.log(` ${DIM}Committing changes...${RESET}`);
264
+ await execGit(`commit -m "${commitMsg}"`, targetPath);
265
+
266
+ // Push to remote
267
+ console.log(` ${DIM}Pushing to origin...${RESET}`);
268
+ await execGit('push origin HEAD', targetPath);
269
+
270
+ console.log(`\n${GREEN}✓${RESET} ${BOLD}Sync complete${RESET} - Changes pushed to remote.\n`);
271
+ } else {
272
+ console.log(`\n${GREEN}✓${RESET} ${BOLD}Already in sync${RESET} - No local changes to push.\n`);
273
+ }
274
+
275
+ // Pull any remote changes
276
+ try {
277
+ console.log(` ${DIM}Pulling remote changes...${RESET}`);
278
+ await execGit('pull origin HEAD --rebase', targetPath);
279
+ } catch (e) {
280
+ // May fail if there are conflicts
281
+ console.log(` ${YELLOW}!${RESET} ${DIM}Could not pull remote changes (may have conflicts).${RESET}`);
282
+ }
283
+
284
+ } catch (error) {
285
+ displayMessageBox('Sync Failed', error.message, 'error');
286
+ process.exit(1);
287
+ }
288
+ }
289
+
290
+ // ==================== REPO COMMANDS ====================
291
+
292
+ // Create a new repository
293
+ async function repoCreateCommand(name, options) {
294
+ const token = getGitLabToken();
295
+ const gitlabUrl = getGitLabUrl();
296
+
297
+ if (!token) {
298
+ displayMessageBox('Not Configured', 'GitLab token not configured. Set GITLAB_TOKEN environment variable.', 'error');
299
+ process.exit(1);
300
+ }
301
+
302
+ const visibility = options.private ? 'private' : (options.public ? 'public' : 'private');
303
+ const description = options.description || '';
304
+
305
+ console.log(`\n${DIM}Creating repository "${name}" on GitLab...${RESET}\n`);
306
+
307
+ try {
308
+ const response = await fetch(`${gitlabUrl}/api/v4/projects`, {
309
+ method: 'POST',
310
+ headers: {
311
+ 'PRIVATE-TOKEN': token,
312
+ 'Content-Type': 'application/json',
313
+ },
314
+ body: JSON.stringify({
315
+ name,
316
+ description,
317
+ visibility,
318
+ initialize_with_readme: options.readme || false,
319
+ }),
320
+ });
321
+
322
+ if (!response.ok) {
323
+ const error = await response.json();
324
+ throw new Error(error.message || `Failed to create repository: ${response.status}`);
325
+ }
326
+
327
+ const repo = await response.json();
328
+
329
+ console.log(`${GREEN}✓${RESET} ${BOLD}Repository created${RESET}`);
330
+ console.log(` ${DIM}Name:${RESET} ${repo.name}`);
331
+ console.log(` ${DIM}URL:${RESET} ${repo.web_url}`);
332
+ console.log(` ${DIM}Clone SSH:${RESET} ${repo.ssh_url_to_repo}`);
333
+ console.log(` ${DIM}Clone HTTP:${RESET} ${repo.http_url_to_repo}`);
334
+ console.log(` ${DIM}Visibility:${RESET} ${repo.visibility}\n`);
335
+
336
+ // Save to config
337
+ const gitlabConfig = loadGitLabConfig();
338
+ gitlabConfig.repos.push({
339
+ id: repo.id,
340
+ name: repo.name,
341
+ path: repo.path_with_namespace,
342
+ url: repo.web_url,
343
+ ssh_url: repo.ssh_url_to_repo,
344
+ http_url: repo.http_url_to_repo,
345
+ createdAt: new Date().toISOString(),
346
+ });
347
+ saveGitLabConfig(gitlabConfig);
348
+
349
+ } catch (error) {
350
+ displayMessageBox('Failed', error.message, 'error');
351
+ process.exit(1);
352
+ }
353
+ }
354
+
355
+ // List repositories
356
+ async function repoListCommand(options) {
357
+ const token = getGitLabToken();
358
+ const gitlabUrl = getGitLabUrl();
359
+
360
+ if (!token) {
361
+ displayMessageBox('Not Configured', 'GitLab token not configured. Set GITLAB_TOKEN environment variable.', 'error');
362
+ process.exit(1);
363
+ }
364
+
365
+ console.log(`\n${DIM}Fetching repositories from GitLab...${RESET}\n`);
366
+
367
+ try {
368
+ const owned = options.all ? '' : '&owned=true';
369
+ const response = await fetch(`${gitlabUrl}/api/v4/projects?per_page=50${owned}`, {
370
+ headers: {
371
+ 'PRIVATE-TOKEN': token,
372
+ },
373
+ });
374
+
375
+ if (!response.ok) {
376
+ throw new Error(`Failed to list repositories: ${response.status}`);
377
+ }
378
+
379
+ const repos = await response.json();
380
+
381
+ const termWidth = getTerminalWidth();
382
+ const tableWidth = Math.min(100, termWidth - 4);
383
+
384
+ console.log(`${fg(...LOGO_PURPLE)}${'─'.repeat(tableWidth)}${RESET}`);
385
+ console.log(`${BOLD} GitLab Repositories${RESET}`);
386
+ console.log(`${fg(...LOGO_PURPLE)}${'─'.repeat(tableWidth)}${RESET}\n`);
387
+
388
+ if (repos.length === 0) {
389
+ console.log(` ${DIM}No repositories found.${RESET}\n`);
390
+ } else {
391
+ repos.forEach((repo, i) => {
392
+ const visibility = repo.visibility === 'private' ? `${YELLOW}private${RESET}` : `${GREEN}${repo.visibility}${RESET}`;
393
+ console.log(` ${CYAN}${i + 1}.${RESET} ${BOLD}${repo.path_with_namespace}${RESET} (${visibility})`);
394
+ if (repo.description) {
395
+ console.log(` ${DIM}${repo.description.substring(0, 60)}${repo.description.length > 60 ? '...' : ''}${RESET}`);
396
+ }
397
+ console.log(` ${DIM}${repo.http_url_to_repo}${RESET}`);
398
+ if (i < repos.length - 1) console.log('');
399
+ });
400
+ }
401
+
402
+ console.log(`\n${fg(...LOGO_PURPLE)}${'─'.repeat(tableWidth)}${RESET}`);
403
+ console.log(`${DIM} Total: ${repos.length} repository(ies)${RESET}\n`);
404
+
405
+ } catch (error) {
406
+ displayMessageBox('Failed', error.message, 'error');
407
+ process.exit(1);
408
+ }
409
+ }
410
+
411
+ // Clone a repository
412
+ async function repoCloneCommand(name, options) {
413
+ const token = getGitLabToken();
414
+ const gitlabUrl = getGitLabUrl();
415
+
416
+ if (!token) {
417
+ displayMessageBox('Not Configured', 'GitLab token not configured. Set GITLAB_TOKEN environment variable.', 'error');
418
+ process.exit(1);
419
+ }
420
+
421
+ console.log(`\n${DIM}Searching for repository "${name}"...${RESET}\n`);
422
+
423
+ try {
424
+ // Search for repository
425
+ const response = await fetch(`${gitlabUrl}/api/v4/projects?search=${encodeURIComponent(name)}`, {
426
+ headers: {
427
+ 'PRIVATE-TOKEN': token,
428
+ },
429
+ });
430
+
431
+ if (!response.ok) {
432
+ throw new Error(`Failed to search repositories: ${response.status}`);
433
+ }
434
+
435
+ const repos = await response.json();
436
+
437
+ if (repos.length === 0) {
438
+ displayMessageBox('Not Found', `Repository "${name}" not found.`, 'error');
439
+ process.exit(1);
440
+ }
441
+
442
+ // Find exact match or use first result
443
+ const repo = repos.find(r => r.name === name || r.path_with_namespace === name) || repos[0];
444
+
445
+ const cloneUrl = options.ssh ? repo.ssh_url_to_repo : repo.http_url_to_repo;
446
+ const targetDir = options.dir || repo.name;
447
+
448
+ console.log(` ${DIM}Cloning ${repo.path_with_namespace}...${RESET}`);
449
+ console.log(` ${DIM}URL: ${cloneUrl}${RESET}\n`);
450
+
451
+ await execGit(`clone ${cloneUrl} ${targetDir}`);
452
+
453
+ console.log(`${GREEN}✓${RESET} ${BOLD}Repository cloned${RESET}`);
454
+ console.log(` ${DIM}Location:${RESET} ${path.resolve(targetDir)}\n`);
455
+
456
+ } catch (error) {
457
+ displayMessageBox('Clone Failed', error.message, 'error');
458
+ process.exit(1);
459
+ }
460
+ }
461
+
462
+ module.exports = {
463
+ syncStartCommand,
464
+ syncStopCommand,
465
+ syncStatusCommand,
466
+ syncNowCommand,
467
+ repoCreateCommand,
468
+ repoListCommand,
469
+ repoCloneCommand,
470
+ };
@@ -0,0 +1,314 @@
1
+ const config = require('../lib/config');
2
+ const { displayMessageBox, fg, LOGO_PURPLE, LOGO_LIGHT, RESET, BOLD, DIM, getTerminalWidth } = require('../lib/display');
3
+ const { exec, spawn } = require('child_process');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+
8
+ // Colors
9
+ const CYAN = '\x1b[36m';
10
+ const GREEN = '\x1b[32m';
11
+ const YELLOW = '\x1b[33m';
12
+ const RED = '\x1b[31m';
13
+
14
+ // Get GitLab URL from session or config
15
+ function getGitLabUrl() {
16
+ const session = config.loadSession();
17
+ return session?.gitlab_url || process.env.GITLAB_URL || 'https://gitlab.com';
18
+ }
19
+
20
+ // Get GitLab token
21
+ function getGitLabToken() {
22
+ const session = config.loadSession();
23
+ return session?.gitlab_token || process.env.GITLAB_TOKEN || null;
24
+ }
25
+
26
+ // Get registry URL from GitLab URL
27
+ function getRegistryUrl() {
28
+ const gitlabUrl = getGitLabUrl();
29
+ try {
30
+ const url = new URL(gitlabUrl);
31
+ return `registry.${url.hostname}`;
32
+ } catch (e) {
33
+ return 'registry.gitlab.com';
34
+ }
35
+ }
36
+
37
+ // Execute shell command
38
+ function execCommand(command, options = {}) {
39
+ return new Promise((resolve, reject) => {
40
+ exec(command, options, (error, stdout, stderr) => {
41
+ if (error) {
42
+ reject(new Error(stderr || error.message));
43
+ } else {
44
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
45
+ }
46
+ });
47
+ });
48
+ }
49
+
50
+ // ==================== REGISTRY COMMANDS ====================
51
+
52
+ // Login to GitLab Container Registry
53
+ async function registryLoginCommand(options) {
54
+ const token = getGitLabToken();
55
+ const registryUrl = options.registry || getRegistryUrl();
56
+
57
+ if (!token) {
58
+ displayMessageBox('Not Configured', 'GitLab token not configured. Set GITLAB_TOKEN environment variable or run "gbos auth" first.', 'error');
59
+ process.exit(1);
60
+ }
61
+
62
+ console.log(`\n${DIM}Logging into GitLab Container Registry...${RESET}\n`);
63
+ console.log(` ${DIM}Registry:${RESET} ${registryUrl}`);
64
+
65
+ try {
66
+ // Use docker login with token
67
+ const loginProcess = spawn('docker', ['login', registryUrl, '-u', 'oauth2', '--password-stdin'], {
68
+ stdio: ['pipe', 'pipe', 'pipe'],
69
+ });
70
+
71
+ let stdout = '';
72
+ let stderr = '';
73
+
74
+ loginProcess.stdout.on('data', (data) => {
75
+ stdout += data.toString();
76
+ });
77
+
78
+ loginProcess.stderr.on('data', (data) => {
79
+ stderr += data.toString();
80
+ });
81
+
82
+ // Write token to stdin
83
+ loginProcess.stdin.write(token);
84
+ loginProcess.stdin.end();
85
+
86
+ await new Promise((resolve, reject) => {
87
+ loginProcess.on('close', (code) => {
88
+ if (code === 0) {
89
+ resolve();
90
+ } else {
91
+ reject(new Error(stderr || `Docker login failed with code ${code}`));
92
+ }
93
+ });
94
+ });
95
+
96
+ console.log(`\n${GREEN}✓${RESET} ${BOLD}Login successful${RESET}`);
97
+ console.log(` ${DIM}You can now push and pull images from ${registryUrl}${RESET}\n`);
98
+
99
+ } catch (error) {
100
+ // Check if docker is installed
101
+ try {
102
+ await execCommand('docker --version');
103
+ } catch (e) {
104
+ displayMessageBox('Docker Not Found', 'Docker is not installed or not in PATH. Please install Docker first.', 'error');
105
+ process.exit(1);
106
+ }
107
+
108
+ displayMessageBox('Login Failed', error.message, 'error');
109
+ process.exit(1);
110
+ }
111
+ }
112
+
113
+ // List container images in a project
114
+ async function registryImagesCommand(project, options) {
115
+ const token = getGitLabToken();
116
+ const gitlabUrl = getGitLabUrl();
117
+
118
+ if (!token) {
119
+ displayMessageBox('Not Configured', 'GitLab token not configured. Set GITLAB_TOKEN environment variable.', 'error');
120
+ process.exit(1);
121
+ }
122
+
123
+ // URL encode the project path
124
+ const encodedProject = encodeURIComponent(project);
125
+
126
+ console.log(`\n${DIM}Fetching container images for "${project}"...${RESET}\n`);
127
+
128
+ try {
129
+ // First get the project to verify it exists
130
+ const projectResponse = await fetch(`${gitlabUrl}/api/v4/projects/${encodedProject}`, {
131
+ headers: {
132
+ 'PRIVATE-TOKEN': token,
133
+ },
134
+ });
135
+
136
+ if (!projectResponse.ok) {
137
+ if (projectResponse.status === 404) {
138
+ throw new Error(`Project "${project}" not found. Use the full path like "group/project".`);
139
+ }
140
+ throw new Error(`Failed to fetch project: ${projectResponse.status}`);
141
+ }
142
+
143
+ const projectData = await projectResponse.json();
144
+
145
+ // Get container repositories
146
+ const reposResponse = await fetch(`${gitlabUrl}/api/v4/projects/${projectData.id}/registry/repositories`, {
147
+ headers: {
148
+ 'PRIVATE-TOKEN': token,
149
+ },
150
+ });
151
+
152
+ if (!reposResponse.ok) {
153
+ throw new Error(`Failed to fetch repositories: ${reposResponse.status}`);
154
+ }
155
+
156
+ const repos = await reposResponse.json();
157
+
158
+ const termWidth = getTerminalWidth();
159
+ const tableWidth = Math.min(100, termWidth - 4);
160
+
161
+ console.log(`${fg(...LOGO_PURPLE)}${'─'.repeat(tableWidth)}${RESET}`);
162
+ console.log(`${BOLD} Container Images - ${projectData.path_with_namespace}${RESET}`);
163
+ console.log(`${fg(...LOGO_PURPLE)}${'─'.repeat(tableWidth)}${RESET}\n`);
164
+
165
+ if (repos.length === 0) {
166
+ console.log(` ${DIM}No container images found.${RESET}`);
167
+ console.log(` ${DIM}Push an image with: docker push ${getRegistryUrl()}/${projectData.path_with_namespace}/<image>:<tag>${RESET}\n`);
168
+ } else {
169
+ for (const repo of repos) {
170
+ console.log(` ${CYAN}●${RESET} ${BOLD}${repo.path}${RESET}`);
171
+ console.log(` ${DIM}ID: ${repo.id} | Location: ${repo.location}${RESET}`);
172
+
173
+ // Get tags for this repository
174
+ if (options.tags) {
175
+ try {
176
+ const tagsResponse = await fetch(`${gitlabUrl}/api/v4/projects/${projectData.id}/registry/repositories/${repo.id}/tags`, {
177
+ headers: {
178
+ 'PRIVATE-TOKEN': token,
179
+ },
180
+ });
181
+
182
+ if (tagsResponse.ok) {
183
+ const tags = await tagsResponse.json();
184
+ if (tags.length > 0) {
185
+ const tagList = tags.slice(0, 10).map(t => t.name).join(', ');
186
+ console.log(` ${DIM}Tags: ${tagList}${tags.length > 10 ? ` (+${tags.length - 10} more)` : ''}${RESET}`);
187
+ }
188
+ }
189
+ } catch (e) {
190
+ // Ignore tag fetch errors
191
+ }
192
+ }
193
+
194
+ console.log('');
195
+ }
196
+ }
197
+
198
+ console.log(`${fg(...LOGO_PURPLE)}${'─'.repeat(tableWidth)}${RESET}`);
199
+ console.log(`${DIM} Total: ${repos.length} image(s)${RESET}\n`);
200
+
201
+ } catch (error) {
202
+ displayMessageBox('Failed', error.message, 'error');
203
+ process.exit(1);
204
+ }
205
+ }
206
+
207
+ // Push an image to GitLab Container Registry
208
+ async function registryPushCommand(image, options) {
209
+ const registryUrl = options.registry || getRegistryUrl();
210
+
211
+ console.log(`\n${DIM}Pushing image to GitLab Container Registry...${RESET}\n`);
212
+
213
+ // Check if the image name already includes the registry
214
+ let fullImage = image;
215
+ if (!image.includes('/') || !image.includes('.')) {
216
+ // User provided just an image name, need project path
217
+ if (!options.project) {
218
+ displayMessageBox('Project Required', 'Please specify the project with --project or use full image path.\n\nExample: gbos registry push myimage:latest --project group/project\nOr: gbos registry push registry.gitlab.com/group/project/image:tag', 'error');
219
+ process.exit(1);
220
+ }
221
+ fullImage = `${registryUrl}/${options.project}/${image}`;
222
+ }
223
+
224
+ console.log(` ${DIM}Image:${RESET} ${image}`);
225
+ console.log(` ${DIM}Target:${RESET} ${fullImage}\n`);
226
+
227
+ try {
228
+ // Tag the image if needed
229
+ if (fullImage !== image) {
230
+ console.log(` ${DIM}Tagging image...${RESET}`);
231
+ await execCommand(`docker tag ${image} ${fullImage}`);
232
+ }
233
+
234
+ // Push the image
235
+ console.log(` ${DIM}Pushing to registry...${RESET}\n`);
236
+
237
+ const pushProcess = spawn('docker', ['push', fullImage], {
238
+ stdio: 'inherit',
239
+ });
240
+
241
+ await new Promise((resolve, reject) => {
242
+ pushProcess.on('close', (code) => {
243
+ if (code === 0) {
244
+ resolve();
245
+ } else {
246
+ reject(new Error(`Push failed with exit code ${code}`));
247
+ }
248
+ });
249
+ });
250
+
251
+ console.log(`\n${GREEN}✓${RESET} ${BOLD}Image pushed successfully${RESET}`);
252
+ console.log(` ${DIM}Location:${RESET} ${fullImage}\n`);
253
+
254
+ } catch (error) {
255
+ // Check if docker is installed
256
+ try {
257
+ await execCommand('docker --version');
258
+ } catch (e) {
259
+ displayMessageBox('Docker Not Found', 'Docker is not installed or not in PATH. Please install Docker first.', 'error');
260
+ process.exit(1);
261
+ }
262
+
263
+ displayMessageBox('Push Failed', error.message, 'error');
264
+ process.exit(1);
265
+ }
266
+ }
267
+
268
+ // Pull an image from GitLab Container Registry
269
+ async function registryPullCommand(image, options) {
270
+ const registryUrl = options.registry || getRegistryUrl();
271
+
272
+ console.log(`\n${DIM}Pulling image from GitLab Container Registry...${RESET}\n`);
273
+
274
+ // Check if the image name already includes the registry
275
+ let fullImage = image;
276
+ if (!image.includes('/') || !image.includes('.')) {
277
+ if (!options.project) {
278
+ displayMessageBox('Project Required', 'Please specify the project with --project or use full image path.', 'error');
279
+ process.exit(1);
280
+ }
281
+ fullImage = `${registryUrl}/${options.project}/${image}`;
282
+ }
283
+
284
+ console.log(` ${DIM}Pulling:${RESET} ${fullImage}\n`);
285
+
286
+ try {
287
+ const pullProcess = spawn('docker', ['pull', fullImage], {
288
+ stdio: 'inherit',
289
+ });
290
+
291
+ await new Promise((resolve, reject) => {
292
+ pullProcess.on('close', (code) => {
293
+ if (code === 0) {
294
+ resolve();
295
+ } else {
296
+ reject(new Error(`Pull failed with exit code ${code}`));
297
+ }
298
+ });
299
+ });
300
+
301
+ console.log(`\n${GREEN}✓${RESET} ${BOLD}Image pulled successfully${RESET}\n`);
302
+
303
+ } catch (error) {
304
+ displayMessageBox('Pull Failed', error.message, 'error');
305
+ process.exit(1);
306
+ }
307
+ }
308
+
309
+ module.exports = {
310
+ registryLoginCommand,
311
+ registryImagesCommand,
312
+ registryPushCommand,
313
+ registryPullCommand,
314
+ };
@@ -2,6 +2,9 @@ const api = require('../lib/api');
2
2
  const config = require('../lib/config');
3
3
  const { displayMessageBox, printBanner, printStatusTable, fg, LOGO_LIGHT, LOGO_PURPLE, RESET, BOLD, DIM, getTerminalWidth } = require('../lib/display');
4
4
  const readline = require('readline');
5
+ const { exec, spawn } = require('child_process');
6
+ const path = require('path');
7
+ const fs = require('fs');
5
8
 
6
9
  // Colors for prompts
7
10
  const CYAN = '\x1b[36m';
@@ -697,11 +700,265 @@ async function addTaskCommand() {
697
700
  }
698
701
  }
699
702
 
703
+ // Execute git command
704
+ function execGit(args, cwd = process.cwd()) {
705
+ return new Promise((resolve, reject) => {
706
+ exec(`git ${args}`, { cwd, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
707
+ if (error) {
708
+ reject(new Error(stderr || error.message));
709
+ } else {
710
+ resolve(stdout.trim());
711
+ }
712
+ });
713
+ });
714
+ }
715
+
716
+ // Get GitLab URL and token from session or env
717
+ function getGitLabConfig() {
718
+ const session = config.loadSession();
719
+ return {
720
+ url: session?.gitlab_url || process.env.GITLAB_URL || 'https://gitlab.com',
721
+ token: session?.gitlab_token || process.env.GITLAB_TOKEN || null,
722
+ };
723
+ }
724
+
725
+ // Completed command - commit, push, and complete task
726
+ async function completedCommand(options) {
727
+ const cwd = process.cwd();
728
+ const dirName = path.basename(cwd);
729
+
730
+ console.log(`\n${DIM}Processing completion...${RESET}\n`);
731
+
732
+ // Step 1: Check if current directory is a git repo
733
+ let isGitRepo = false;
734
+ let hasRemote = false;
735
+ let remoteUrl = '';
736
+
737
+ try {
738
+ await execGit('rev-parse --is-inside-work-tree', cwd);
739
+ isGitRepo = true;
740
+ console.log(` ${GREEN}✓${RESET} Git repository detected`);
741
+
742
+ // Check for remote
743
+ try {
744
+ remoteUrl = await execGit('remote get-url origin', cwd);
745
+ hasRemote = true;
746
+ console.log(` ${GREEN}✓${RESET} Remote: ${remoteUrl}`);
747
+ } catch (e) {
748
+ hasRemote = false;
749
+ console.log(` ${YELLOW}!${RESET} No remote configured`);
750
+ }
751
+ } catch (e) {
752
+ isGitRepo = false;
753
+ console.log(` ${YELLOW}!${RESET} Not a git repository`);
754
+ }
755
+
756
+ // Step 2: Initialize git if needed
757
+ if (!isGitRepo) {
758
+ console.log(`\n ${DIM}Initializing git repository...${RESET}`);
759
+ try {
760
+ await execGit('init', cwd);
761
+ isGitRepo = true;
762
+ console.log(` ${GREEN}✓${RESET} Git repository initialized`);
763
+ } catch (e) {
764
+ displayMessageBox('Error', `Failed to initialize git: ${e.message}`, 'error');
765
+ process.exit(1);
766
+ }
767
+ }
768
+
769
+ // Step 3: Create GitLab repo if no remote
770
+ if (!hasRemote) {
771
+ const gitlab = getGitLabConfig();
772
+
773
+ if (!gitlab.token) {
774
+ console.log(`\n ${YELLOW}!${RESET} No GitLab token configured.`);
775
+ console.log(` ${DIM}Set GITLAB_TOKEN environment variable to auto-create repos.${RESET}`);
776
+ console.log(` ${DIM}Skipping remote setup...${RESET}\n`);
777
+ } else {
778
+ console.log(`\n ${DIM}Creating GitLab repository "${dirName}"...${RESET}`);
779
+
780
+ try {
781
+ const response = await fetch(`${gitlab.url}/api/v4/projects`, {
782
+ method: 'POST',
783
+ headers: {
784
+ 'PRIVATE-TOKEN': gitlab.token,
785
+ 'Content-Type': 'application/json',
786
+ },
787
+ body: JSON.stringify({
788
+ name: dirName,
789
+ visibility: 'private',
790
+ initialize_with_readme: false,
791
+ }),
792
+ });
793
+
794
+ if (response.ok) {
795
+ const repo = await response.json();
796
+ remoteUrl = repo.ssh_url_to_repo || repo.http_url_to_repo;
797
+
798
+ // Add remote
799
+ await execGit(`remote add origin ${remoteUrl}`, cwd);
800
+ hasRemote = true;
801
+
802
+ console.log(` ${GREEN}✓${RESET} GitLab repository created: ${repo.web_url}`);
803
+ console.log(` ${GREEN}✓${RESET} Remote added: ${remoteUrl}`);
804
+ } else {
805
+ const error = await response.json();
806
+ if (error.message && error.message.includes('has already been taken')) {
807
+ // Repo exists, try to find and add it
808
+ console.log(` ${YELLOW}!${RESET} Repository "${dirName}" already exists on GitLab`);
809
+
810
+ // Try to get user info and construct URL
811
+ try {
812
+ const userResponse = await fetch(`${gitlab.url}/api/v4/user`, {
813
+ headers: { 'PRIVATE-TOKEN': gitlab.token },
814
+ });
815
+ if (userResponse.ok) {
816
+ const user = await userResponse.json();
817
+ remoteUrl = `git@${new URL(gitlab.url).hostname}:${user.username}/${dirName}.git`;
818
+ await execGit(`remote add origin ${remoteUrl}`, cwd);
819
+ hasRemote = true;
820
+ console.log(` ${GREEN}✓${RESET} Remote added: ${remoteUrl}`);
821
+ }
822
+ } catch (e) {
823
+ console.log(` ${DIM}Could not auto-configure remote. Add manually with:${RESET}`);
824
+ console.log(` ${DIM}git remote add origin <your-repo-url>${RESET}`);
825
+ }
826
+ } else {
827
+ console.log(` ${YELLOW}!${RESET} Failed to create repo: ${error.message || response.status}`);
828
+ }
829
+ }
830
+ } catch (e) {
831
+ console.log(` ${YELLOW}!${RESET} Failed to create GitLab repo: ${e.message}`);
832
+ }
833
+ }
834
+ }
835
+
836
+ // Step 4: Stage all changes
837
+ console.log(`\n ${DIM}Staging changes...${RESET}`);
838
+ try {
839
+ await execGit('add -A', cwd);
840
+ console.log(` ${GREEN}✓${RESET} Changes staged`);
841
+ } catch (e) {
842
+ console.log(` ${YELLOW}!${RESET} Failed to stage: ${e.message}`);
843
+ }
844
+
845
+ // Step 5: Check for changes to commit
846
+ let hasChanges = false;
847
+ try {
848
+ const status = await execGit('status --porcelain', cwd);
849
+ hasChanges = status.length > 0;
850
+ } catch (e) {
851
+ // Assume changes exist
852
+ hasChanges = true;
853
+ }
854
+
855
+ // Step 6: Commit changes
856
+ if (hasChanges) {
857
+ console.log(` ${DIM}Committing changes...${RESET}`);
858
+
859
+ // Get current task for commit message
860
+ let taskInfo = '';
861
+ try {
862
+ if (config.isAuthenticated() && config.getConnection()) {
863
+ const currentResponse = await api.getCurrentTask();
864
+ const task = currentResponse.data?.task || currentResponse.data;
865
+ if (task) {
866
+ taskInfo = task.task_key ? `[${task.task_key}] ` : `[Task #${task.id}] `;
867
+ }
868
+ }
869
+ } catch (e) {
870
+ // No task info available
871
+ }
872
+
873
+ const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
874
+ const commitMessage = options.message || `${taskInfo}Completed: ${timestamp}`;
875
+
876
+ try {
877
+ await execGit(`commit -m "${commitMessage.replace(/"/g, '\\"')}"`, cwd);
878
+ console.log(` ${GREEN}✓${RESET} Changes committed: "${commitMessage}"`);
879
+ } catch (e) {
880
+ if (e.message.includes('nothing to commit')) {
881
+ console.log(` ${DIM}No changes to commit${RESET}`);
882
+ } else {
883
+ console.log(` ${YELLOW}!${RESET} Commit failed: ${e.message}`);
884
+ }
885
+ }
886
+ } else {
887
+ console.log(` ${DIM}No changes to commit${RESET}`);
888
+ }
889
+
890
+ // Step 7: Push to remote
891
+ if (hasRemote) {
892
+ console.log(` ${DIM}Pushing to remote...${RESET}`);
893
+ try {
894
+ // Try to get current branch
895
+ let branch = 'main';
896
+ try {
897
+ branch = await execGit('rev-parse --abbrev-ref HEAD', cwd);
898
+ } catch (e) {
899
+ branch = 'main';
900
+ }
901
+
902
+ // Push with upstream tracking
903
+ try {
904
+ await execGit(`push -u origin ${branch}`, cwd);
905
+ console.log(` ${GREEN}✓${RESET} Pushed to origin/${branch}`);
906
+ } catch (e) {
907
+ // If push fails, try setting upstream
908
+ if (e.message.includes('no upstream branch')) {
909
+ await execGit(`push --set-upstream origin ${branch}`, cwd);
910
+ console.log(` ${GREEN}✓${RESET} Pushed to origin/${branch}`);
911
+ } else {
912
+ throw e;
913
+ }
914
+ }
915
+ } catch (e) {
916
+ console.log(` ${YELLOW}!${RESET} Push failed: ${e.message}`);
917
+ console.log(` ${DIM}You may need to push manually with: git push -u origin main${RESET}`);
918
+ }
919
+ }
920
+
921
+ // Step 8: Mark GBOS task as complete (if authenticated and connected)
922
+ if (config.isAuthenticated() && config.getConnection()) {
923
+ try {
924
+ const currentResponse = await api.getCurrentTask();
925
+ const task = currentResponse.data?.task || currentResponse.data;
926
+
927
+ if (task && task.status === 'in_progress') {
928
+ console.log(`\n ${DIM}Marking GBOS task as complete...${RESET}`);
929
+ await api.completeTask(task.id, {
930
+ completion_notes: options.message || 'Completed via gbos completed command',
931
+ });
932
+ console.log(` ${GREEN}✓${RESET} Task "${task.title || task.id}" marked as complete`);
933
+ }
934
+ } catch (e) {
935
+ // Task completion is optional, don't fail the whole command
936
+ if (e.status !== 404) {
937
+ console.log(` ${DIM}Note: Could not update GBOS task status${RESET}`);
938
+ }
939
+ }
940
+ }
941
+
942
+ // Final summary
943
+ const termWidth = getTerminalWidth();
944
+ const tableWidth = Math.min(60, termWidth - 4);
945
+
946
+ console.log(`\n${fg(...LOGO_PURPLE)}${'─'.repeat(tableWidth)}${RESET}`);
947
+ console.log(`${GREEN}✓${RESET} ${BOLD}Completion finished!${RESET}`);
948
+ console.log(`${fg(...LOGO_PURPLE)}${'─'.repeat(tableWidth)}${RESET}\n`);
949
+
950
+ if (remoteUrl) {
951
+ console.log(` ${DIM}Repository:${RESET} ${remoteUrl}`);
952
+ }
953
+ console.log(` ${DIM}Run "gbos continue" to start the next task.${RESET}\n`);
954
+ }
955
+
700
956
  module.exports = {
701
957
  tasksCommand,
702
958
  nextTaskCommand,
703
959
  continueCommand,
704
960
  fallbackCommand,
705
961
  addTaskCommand,
962
+ completedCommand,
706
963
  generateAgentPrompt,
707
964
  };