@abyrd9/harbor-cli 2.3.3 → 2.4.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/README.md +97 -1
- package/dist/index.js +338 -1
- package/harbor.package-json.schema.json +5 -0
- package/harbor.schema.json +5 -0
- package/package.json +1 -1
- package/scripts/dev.sh +61 -3
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ A CLI tool for managing local development services with ease. Harbor helps you o
|
|
|
12
12
|
- 🖥️ **Tmux Integration**: Professional terminal multiplexing for service management
|
|
13
13
|
- 📝 **Service Logging**: Stream service output to log files for monitoring and debugging
|
|
14
14
|
- 🏷️ **Custom Session Names**: Configure unique tmux session names
|
|
15
|
+
- 🤖 **AI Agent Integration**: Inter-pane communication lets AI agents observe and interact with services
|
|
15
16
|
|
|
16
17
|
## Installation
|
|
17
18
|
|
|
@@ -158,10 +159,23 @@ Store configuration directly in your `package.json`:
|
|
|
158
159
|
|---------|-------------|
|
|
159
160
|
| `harbor dock` | Initialize Harbor config by auto-discovering services in your project |
|
|
160
161
|
| `harbor moor` | Scan for and add new services to your existing Harbor configuration |
|
|
161
|
-
| `harbor launch` | Start all services in a tmux session
|
|
162
|
+
| `harbor launch` | Start all services in a tmux session (`-d` for headless) |
|
|
163
|
+
| `harbor anchor` | Attach to a running Harbor session |
|
|
164
|
+
| `harbor scuttle` | Stop all services and kill the session |
|
|
165
|
+
| `harbor bearings` | Show status of running services |
|
|
162
166
|
| `harbor --help` | Show comprehensive help with feature descriptions |
|
|
163
167
|
| `harbor --version` | Show version information |
|
|
164
168
|
|
|
169
|
+
### Inter-Pane Communication
|
|
170
|
+
|
|
171
|
+
| Command | Description |
|
|
172
|
+
|---------|-------------|
|
|
173
|
+
| `harbor hail <service> "<cmd>"` | Send keystrokes to another service pane |
|
|
174
|
+
| `harbor survey <service>` | Capture output from a service pane |
|
|
175
|
+
| `harbor parley <service> "<cmd>"` | Execute command and capture response |
|
|
176
|
+
| `harbor whoami` | Show current pane identity and access |
|
|
177
|
+
| `harbor context` | Output full session context for AI agents |
|
|
178
|
+
|
|
165
179
|
### Command Options
|
|
166
180
|
|
|
167
181
|
- `-p, --path <path>`: Specify project root path (defaults to `./`)
|
|
@@ -261,6 +275,88 @@ Enable logging to stream service output to files in `.harbor/`. This is particul
|
|
|
261
275
|
|
|
262
276
|
**Use case:** Point your AI assistant to the `.harbor/` folder so it can monitor service logs, spot errors, and understand runtime behavior while helping you develop.
|
|
263
277
|
|
|
278
|
+
### Inter-Pane Communication for AI Agents
|
|
279
|
+
|
|
280
|
+
Harbor enables AI agents running in one pane to observe and interact with other services. This is powerful for AI-assisted development workflows where an agent needs to monitor logs, send commands to REPLs, or coordinate between services.
|
|
281
|
+
|
|
282
|
+
#### Configuration
|
|
283
|
+
|
|
284
|
+
Add `canAccess` to specify which services a pane can communicate with:
|
|
285
|
+
|
|
286
|
+
```json
|
|
287
|
+
{
|
|
288
|
+
"services": [
|
|
289
|
+
{
|
|
290
|
+
"name": "repl",
|
|
291
|
+
"path": "./backend",
|
|
292
|
+
"command": "bin/mycli"
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
"name": "agent",
|
|
296
|
+
"path": ".",
|
|
297
|
+
"command": "opencode",
|
|
298
|
+
"canAccess": ["repl", "web"]
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
"name": "web",
|
|
302
|
+
"path": "./frontend",
|
|
303
|
+
"command": "npm run dev"
|
|
304
|
+
}
|
|
305
|
+
]
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
#### Commands
|
|
310
|
+
|
|
311
|
+
**Survey** - Capture output from another pane:
|
|
312
|
+
```bash
|
|
313
|
+
harbor survey repl --lines 50
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Hail** - Send keystrokes to another pane (fire-and-forget):
|
|
317
|
+
```bash
|
|
318
|
+
harbor hail repl "user query --email test@example.com"
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**Parley** - Execute command and capture response (uses markers for clean output):
|
|
322
|
+
```bash
|
|
323
|
+
harbor parley repl "users" --timeout 5000
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**Whoami** - Check current pane identity and access:
|
|
327
|
+
```bash
|
|
328
|
+
harbor whoami
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**Context** - Get full session documentation (markdown, great for AI context):
|
|
332
|
+
```bash
|
|
333
|
+
harbor context
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
#### Access Control
|
|
337
|
+
|
|
338
|
+
- Services can only access panes listed in their `canAccess` array
|
|
339
|
+
- Commands run from outside tmux (external terminal) bypass access control
|
|
340
|
+
- Access is enforced based on the `HARBOR_SERVICE` environment variable
|
|
341
|
+
|
|
342
|
+
#### Environment Variables
|
|
343
|
+
|
|
344
|
+
Each pane automatically receives these environment variables:
|
|
345
|
+
- `HARBOR_SESSION` - Session name
|
|
346
|
+
- `HARBOR_SOCKET` - Tmux socket name
|
|
347
|
+
- `HARBOR_SERVICE` - Current service name
|
|
348
|
+
- `HARBOR_WINDOW` - Window number
|
|
349
|
+
|
|
350
|
+
#### Use Case: AI Agent Integration
|
|
351
|
+
|
|
352
|
+
Add to your agent's instructions:
|
|
353
|
+
|
|
354
|
+
```markdown
|
|
355
|
+
When starting, run `harbor whoami` to check your harbor context.
|
|
356
|
+
Use `harbor survey <service>` to observe other panes.
|
|
357
|
+
Use `harbor parley <service> "<cmd>"` to interact with REPLs/CLIs.
|
|
358
|
+
```
|
|
359
|
+
|
|
264
360
|
### Before/After Scripts
|
|
265
361
|
Run custom scripts before and after your services start:
|
|
266
362
|
|
package/dist/index.js
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
import { Command } from '@commander-js/extra-typings';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { spawn } from 'node:child_process';
|
|
5
|
+
import { spawn, exec } from 'node:child_process';
|
|
6
6
|
import { chmodSync } from 'node:fs';
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
9
11
|
import os from 'node:os';
|
|
10
12
|
import readline from 'node:readline';
|
|
11
13
|
import pc from 'picocolors';
|
|
14
|
+
const execAsync = promisify(exec);
|
|
12
15
|
// Colored output helpers
|
|
13
16
|
const log = {
|
|
14
17
|
error: (msg) => console.log(`${pc.red('✗')} ${msg}`),
|
|
@@ -175,6 +178,123 @@ async function checkDependencies() {
|
|
|
175
178
|
throw new Error('Please install missing dependencies before continuing');
|
|
176
179
|
}
|
|
177
180
|
}
|
|
181
|
+
// ─────────────────────────────────────────────────────────────
|
|
182
|
+
// Inter-Pane Communication Functions
|
|
183
|
+
// ─────────────────────────────────────────────────────────────
|
|
184
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
185
|
+
function getSessionInfo() {
|
|
186
|
+
const sessionFile = path.join(process.cwd(), '.harbor', 'session.json');
|
|
187
|
+
if (!fs.existsSync(sessionFile))
|
|
188
|
+
return null;
|
|
189
|
+
try {
|
|
190
|
+
return JSON.parse(fs.readFileSync(sessionFile, 'utf-8'));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function checkAccess(target) {
|
|
197
|
+
const session = getSessionInfo();
|
|
198
|
+
if (!session) {
|
|
199
|
+
return { allowed: false, error: 'No harbor session running. Run "harbor launch" first.' };
|
|
200
|
+
}
|
|
201
|
+
const targetService = session.services[target];
|
|
202
|
+
if (!targetService) {
|
|
203
|
+
const available = Object.keys(session.services).join(', ');
|
|
204
|
+
return { allowed: false, error: `Unknown service: ${target}. Available services: ${available}` };
|
|
205
|
+
}
|
|
206
|
+
// If called from outside a harbor pane (no HARBOR_SERVICE env), allow access
|
|
207
|
+
const callerService = process.env.HARBOR_SERVICE;
|
|
208
|
+
if (!callerService) {
|
|
209
|
+
return { allowed: true };
|
|
210
|
+
}
|
|
211
|
+
// If called from within a harbor pane, check canAccess
|
|
212
|
+
const callerInfo = session.services[callerService];
|
|
213
|
+
if (!callerInfo) {
|
|
214
|
+
return { allowed: true }; // Caller not in session, allow
|
|
215
|
+
}
|
|
216
|
+
const canAccess = callerInfo.canAccess || [];
|
|
217
|
+
if (!canAccess.includes(target)) {
|
|
218
|
+
return {
|
|
219
|
+
allowed: false,
|
|
220
|
+
error: `Service "${callerService}" does not have access to "${target}". Add "${target}" to canAccess in your harbor config.`
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return { allowed: true };
|
|
224
|
+
}
|
|
225
|
+
async function sendToPane(target, command) {
|
|
226
|
+
const session = getSessionInfo();
|
|
227
|
+
if (!session)
|
|
228
|
+
throw new Error('No harbor session running');
|
|
229
|
+
const service = session.services[target];
|
|
230
|
+
if (!service)
|
|
231
|
+
throw new Error(`Unknown service: ${target}`);
|
|
232
|
+
const tmuxCmd = `tmux -L ${session.socket}`;
|
|
233
|
+
const escaped = command.replace(/"/g, '\\"');
|
|
234
|
+
await execAsync(`${tmuxCmd} send-keys -t "${service.target}" "${escaped}" Enter`);
|
|
235
|
+
}
|
|
236
|
+
async function capturePane(target, lines = 500) {
|
|
237
|
+
const session = getSessionInfo();
|
|
238
|
+
if (!session)
|
|
239
|
+
throw new Error('No harbor session running');
|
|
240
|
+
const service = session.services[target];
|
|
241
|
+
if (!service)
|
|
242
|
+
throw new Error(`Unknown service: ${target}`);
|
|
243
|
+
const tmuxCmd = `tmux -L ${session.socket}`;
|
|
244
|
+
const { stdout } = await execAsync(`${tmuxCmd} capture-pane -t "${service.target}" -p -S -${lines}`);
|
|
245
|
+
return stdout;
|
|
246
|
+
}
|
|
247
|
+
async function execInPane(target, command, timeout = 3000) {
|
|
248
|
+
const session = getSessionInfo();
|
|
249
|
+
if (!session)
|
|
250
|
+
throw new Error('No harbor session running');
|
|
251
|
+
const service = session.services[target];
|
|
252
|
+
if (!service)
|
|
253
|
+
throw new Error(`Unknown service: ${target}`);
|
|
254
|
+
const tmuxCmd = `tmux -L ${session.socket}`;
|
|
255
|
+
const markerId = randomUUID().slice(0, 8);
|
|
256
|
+
const startMarker = `<<<HARBOR_START_${markerId}>>>`;
|
|
257
|
+
const endMarker = `<<<HARBOR_END_${markerId}>>>`;
|
|
258
|
+
// Send start marker
|
|
259
|
+
await execAsync(`${tmuxCmd} send-keys -t "${service.target}" "echo '${startMarker}'" Enter`);
|
|
260
|
+
await sleep(100);
|
|
261
|
+
// Send command
|
|
262
|
+
const escaped = command.replace(/'/g, "'\\''");
|
|
263
|
+
await execAsync(`${tmuxCmd} send-keys -t "${service.target}" '${escaped}' Enter`);
|
|
264
|
+
await sleep(timeout);
|
|
265
|
+
// Send end marker
|
|
266
|
+
await execAsync(`${tmuxCmd} send-keys -t "${service.target}" "echo '${endMarker}'" Enter`);
|
|
267
|
+
await sleep(200);
|
|
268
|
+
// Capture and extract
|
|
269
|
+
const { stdout } = await execAsync(`${tmuxCmd} capture-pane -t "${service.target}" -p -S -500`);
|
|
270
|
+
// Extract content between markers
|
|
271
|
+
const regex = new RegExp(`${escapeRegex(startMarker)}\\n([\\s\\S]*?)${escapeRegex(endMarker)}`);
|
|
272
|
+
const match = stdout.match(regex);
|
|
273
|
+
if (match) {
|
|
274
|
+
// Clean up the output
|
|
275
|
+
const rawOutput = match[1];
|
|
276
|
+
const lines = rawOutput.split('\n');
|
|
277
|
+
// Filter out the echoed command and prompts
|
|
278
|
+
const cleanedLines = lines.filter(line => {
|
|
279
|
+
const trimmed = line.trim();
|
|
280
|
+
if (!trimmed)
|
|
281
|
+
return false;
|
|
282
|
+
if (trimmed.includes(`echo '${startMarker}'`))
|
|
283
|
+
return false;
|
|
284
|
+
if (trimmed.includes(`echo '${endMarker}'`))
|
|
285
|
+
return false;
|
|
286
|
+
return true;
|
|
287
|
+
});
|
|
288
|
+
return cleanedLines.join('\n').trim() || '(no output)';
|
|
289
|
+
}
|
|
290
|
+
return stdout.trim() || '(no output)';
|
|
291
|
+
}
|
|
292
|
+
function escapeRegex(str) {
|
|
293
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
294
|
+
}
|
|
295
|
+
// ─────────────────────────────────────────────────────────────
|
|
296
|
+
// Configuration Prompts
|
|
297
|
+
// ─────────────────────────────────────────────────────────────
|
|
178
298
|
function promptConfigLocation() {
|
|
179
299
|
const rl = readline.createInterface({
|
|
180
300
|
input: process.stdin,
|
|
@@ -237,6 +357,15 @@ ${yellow('Commands:')}
|
|
|
237
357
|
${green('scuttle')} Stop all services
|
|
238
358
|
${green('bearings')} Show status of running services
|
|
239
359
|
|
|
360
|
+
${yellow('Inter-Pane Communication:')}
|
|
361
|
+
${green('hail')} Send a command to another service pane
|
|
362
|
+
${green('survey')} Capture output from a service pane
|
|
363
|
+
${green('parley')} Execute command and capture response
|
|
364
|
+
|
|
365
|
+
${yellow('Agent Awareness:')}
|
|
366
|
+
${green('whoami')} Show current pane identity and access
|
|
367
|
+
${green('context')} Output full session context (markdown)
|
|
368
|
+
|
|
240
369
|
${yellow('Quick Start:')}
|
|
241
370
|
${dim('$')} harbor dock ${dim('# Create config')}
|
|
242
371
|
${dim('$')} harbor launch ${dim('# Start services')}
|
|
@@ -435,6 +564,12 @@ program.command('scuttle')
|
|
|
435
564
|
killSession.on('close', async (code) => {
|
|
436
565
|
if (code === 0) {
|
|
437
566
|
log.success(`Harbor session ${pc.cyan(sessionName)} stopped`);
|
|
567
|
+
// Clean up session.json
|
|
568
|
+
const sessionFile = path.join(process.cwd(), '.harbor', 'session.json');
|
|
569
|
+
if (fs.existsSync(sessionFile)) {
|
|
570
|
+
fs.unlinkSync(sessionFile);
|
|
571
|
+
log.dim(' Cleaned up session metadata');
|
|
572
|
+
}
|
|
438
573
|
// Execute after scripts when session is killed
|
|
439
574
|
if (config.after && config.after.length > 0) {
|
|
440
575
|
try {
|
|
@@ -538,6 +673,196 @@ program.command('bearings')
|
|
|
538
673
|
process.exit(1);
|
|
539
674
|
}
|
|
540
675
|
});
|
|
676
|
+
// ─────────────────────────────────────────────────────────────
|
|
677
|
+
// Inter-Pane Communication Commands
|
|
678
|
+
// ─────────────────────────────────────────────────────────────
|
|
679
|
+
program.command('hail')
|
|
680
|
+
.description('Send a command to another service pane')
|
|
681
|
+
.argument('<service>', 'Target service name')
|
|
682
|
+
.argument('<command>', 'Command to send')
|
|
683
|
+
.action(async (service, command) => {
|
|
684
|
+
try {
|
|
685
|
+
const access = checkAccess(service);
|
|
686
|
+
if (!access.allowed) {
|
|
687
|
+
log.error(access.error || 'Access denied');
|
|
688
|
+
process.exit(1);
|
|
689
|
+
}
|
|
690
|
+
await sendToPane(service, command);
|
|
691
|
+
log.success(`Hailed ${pc.cyan(service)}: ${pc.dim(command)}`);
|
|
692
|
+
}
|
|
693
|
+
catch (err) {
|
|
694
|
+
log.error(err instanceof Error ? err.message : 'Failed to hail service');
|
|
695
|
+
process.exit(1);
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
program.command('survey')
|
|
699
|
+
.description('Capture output from a service pane')
|
|
700
|
+
.argument('<service>', 'Target service name')
|
|
701
|
+
.option('-n, --lines <number>', 'Number of lines to capture', '500')
|
|
702
|
+
.action(async (service, options) => {
|
|
703
|
+
try {
|
|
704
|
+
const access = checkAccess(service);
|
|
705
|
+
if (!access.allowed) {
|
|
706
|
+
log.error(access.error || 'Access denied');
|
|
707
|
+
process.exit(1);
|
|
708
|
+
}
|
|
709
|
+
const output = await capturePane(service, parseInt(options.lines));
|
|
710
|
+
console.log(output);
|
|
711
|
+
}
|
|
712
|
+
catch (err) {
|
|
713
|
+
log.error(err instanceof Error ? err.message : 'Failed to survey service');
|
|
714
|
+
process.exit(1);
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
program.command('parley')
|
|
718
|
+
.description('Execute a command in a pane and capture the response')
|
|
719
|
+
.argument('<service>', 'Target service name')
|
|
720
|
+
.argument('<command>', 'Command to execute')
|
|
721
|
+
.option('-t, --timeout <ms>', 'Timeout in milliseconds', '3000')
|
|
722
|
+
.action(async (service, command, options) => {
|
|
723
|
+
try {
|
|
724
|
+
const access = checkAccess(service);
|
|
725
|
+
if (!access.allowed) {
|
|
726
|
+
log.error(access.error || 'Access denied');
|
|
727
|
+
process.exit(1);
|
|
728
|
+
}
|
|
729
|
+
const output = await execInPane(service, command, parseInt(options.timeout));
|
|
730
|
+
console.log(output);
|
|
731
|
+
}
|
|
732
|
+
catch (err) {
|
|
733
|
+
log.error(err instanceof Error ? err.message : 'Failed to parley with service');
|
|
734
|
+
process.exit(1);
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
program.command('whoami')
|
|
738
|
+
.description('Show current pane identity and session info')
|
|
739
|
+
.action(async () => {
|
|
740
|
+
const session = getSessionInfo();
|
|
741
|
+
const currentService = process.env.HARBOR_SERVICE;
|
|
742
|
+
if (!session) {
|
|
743
|
+
log.warn('Not in a harbor session');
|
|
744
|
+
log.dim(' No .harbor/session.json found');
|
|
745
|
+
process.exit(0);
|
|
746
|
+
}
|
|
747
|
+
const currentServiceInfo = currentService ? session.services[currentService] : null;
|
|
748
|
+
const canAccessList = currentServiceInfo?.canAccess || [];
|
|
749
|
+
log.header(`${pc.cyan('⚓')} Harbor Identity`);
|
|
750
|
+
log.plain('');
|
|
751
|
+
log.label('Session:', session.session);
|
|
752
|
+
log.label('Socket:', session.socket);
|
|
753
|
+
if (currentService && currentServiceInfo) {
|
|
754
|
+
log.label('You are:', `${pc.green(currentService)} (window ${currentServiceInfo.window})`);
|
|
755
|
+
if (canAccessList.length > 0) {
|
|
756
|
+
log.label('Can access:', canAccessList.map(s => pc.cyan(s)).join(', '));
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
log.label('Can access:', pc.dim('(none configured)'));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
log.label('You are:', pc.dim('external (not in a harbor pane)'));
|
|
764
|
+
log.label('Can access:', pc.green('all services'));
|
|
765
|
+
}
|
|
766
|
+
log.plain('');
|
|
767
|
+
log.dim(' Run "harbor context" for full documentation');
|
|
768
|
+
log.dim(' Run "harbor bearings" to see all services');
|
|
769
|
+
});
|
|
770
|
+
program.command('context')
|
|
771
|
+
.description('Output session context for AI agents (markdown format)')
|
|
772
|
+
.action(async () => {
|
|
773
|
+
const session = getSessionInfo();
|
|
774
|
+
const currentService = process.env.HARBOR_SERVICE;
|
|
775
|
+
if (!session) {
|
|
776
|
+
console.log(`# Harbor Session
|
|
777
|
+
|
|
778
|
+
No active harbor session found. Run \`harbor launch\` to start one.
|
|
779
|
+
`);
|
|
780
|
+
process.exit(0);
|
|
781
|
+
}
|
|
782
|
+
const currentServiceInfo = currentService ? session.services[currentService] : null;
|
|
783
|
+
const canAccessList = currentServiceInfo?.canAccess || [];
|
|
784
|
+
let output = `# Harbor Session Context
|
|
785
|
+
|
|
786
|
+
You are running inside a **harbor** tmux session, which orchestrates multiple development services.
|
|
787
|
+
|
|
788
|
+
## Current Session
|
|
789
|
+
- **Session**: ${session.session}
|
|
790
|
+
- **Socket**: ${session.socket}
|
|
791
|
+
- **Started**: ${session.startedAt}
|
|
792
|
+
`;
|
|
793
|
+
if (currentService) {
|
|
794
|
+
output += `- **Your Pane**: ${currentService} (window ${currentServiceInfo?.window})
|
|
795
|
+
`;
|
|
796
|
+
}
|
|
797
|
+
output += `
|
|
798
|
+
## Available Services
|
|
799
|
+
| Service | Window | You Can Access |
|
|
800
|
+
|---------|--------|----------------|
|
|
801
|
+
`;
|
|
802
|
+
for (const [name, info] of Object.entries(session.services)) {
|
|
803
|
+
const isCurrent = name === currentService;
|
|
804
|
+
const hasAccess = !currentService || canAccessList.includes(name) || name === currentService;
|
|
805
|
+
const accessIcon = isCurrent ? '(you)' : hasAccess ? '✓' : '✗';
|
|
806
|
+
output += `| ${name} | ${info.window} | ${accessIcon} |\n`;
|
|
807
|
+
}
|
|
808
|
+
output += `
|
|
809
|
+
## Inter-Pane Communication Commands
|
|
810
|
+
|
|
811
|
+
You can interact with other service panes using these commands:
|
|
812
|
+
|
|
813
|
+
### \`harbor hail <service> "<command>"\`
|
|
814
|
+
Send keystrokes to another pane (fire-and-forget).
|
|
815
|
+
\`\`\`bash
|
|
816
|
+
harbor hail repl "echo hello"
|
|
817
|
+
\`\`\`
|
|
818
|
+
|
|
819
|
+
### \`harbor survey <service> [--lines N]\`
|
|
820
|
+
Capture the current output/scrollback from another pane.
|
|
821
|
+
\`\`\`bash
|
|
822
|
+
harbor survey web --lines 50
|
|
823
|
+
\`\`\`
|
|
824
|
+
|
|
825
|
+
### \`harbor parley <service> "<command>" [--timeout ms]\`
|
|
826
|
+
Execute a command in another pane and capture the response.
|
|
827
|
+
Uses markers to delimit output. Good for REPLs and CLIs.
|
|
828
|
+
\`\`\`bash
|
|
829
|
+
harbor parley repl "users" --timeout 3000
|
|
830
|
+
\`\`\`
|
|
831
|
+
|
|
832
|
+
## Access Control
|
|
833
|
+
`;
|
|
834
|
+
if (currentService) {
|
|
835
|
+
if (canAccessList.length > 0) {
|
|
836
|
+
output += `Your service (${currentService}) can access: **${canAccessList.join(', ')}**
|
|
837
|
+
|
|
838
|
+
To access other services, add them to \`canAccess\` in harbor.json and restart the session.
|
|
839
|
+
`;
|
|
840
|
+
}
|
|
841
|
+
else {
|
|
842
|
+
output += `Your service (${currentService}) has no \`canAccess\` configured.
|
|
843
|
+
|
|
844
|
+
Add services to \`canAccess\` in harbor.json to enable inter-pane communication:
|
|
845
|
+
\`\`\`json
|
|
846
|
+
{
|
|
847
|
+
"name": "${currentService}",
|
|
848
|
+
"canAccess": ["repl", "web"]
|
|
849
|
+
}
|
|
850
|
+
\`\`\`
|
|
851
|
+
`;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
output += `You are running from outside the harbor session, so you have access to all services.
|
|
856
|
+
`;
|
|
857
|
+
}
|
|
858
|
+
output += `
|
|
859
|
+
## Other Useful Commands
|
|
860
|
+
- \`harbor bearings\` - Show session status and running services
|
|
861
|
+
- \`harbor anchor\` - Attach to the tmux session interactively
|
|
862
|
+
- \`harbor scuttle\` - Stop all services
|
|
863
|
+
`;
|
|
864
|
+
console.log(output);
|
|
865
|
+
});
|
|
541
866
|
program.parse();
|
|
542
867
|
function fileExists(path) {
|
|
543
868
|
return fs.existsSync(`${process.cwd()}/${path}`);
|
|
@@ -556,6 +881,7 @@ export function validateConfig(config) {
|
|
|
556
881
|
if (!Array.isArray(config.services)) {
|
|
557
882
|
return 'Services must be an array';
|
|
558
883
|
}
|
|
884
|
+
const serviceNames = new Set(config.services.map(s => s.name));
|
|
559
885
|
for (const service of config.services) {
|
|
560
886
|
if (!service.name) {
|
|
561
887
|
return 'Service name is required';
|
|
@@ -563,6 +889,17 @@ export function validateConfig(config) {
|
|
|
563
889
|
if (!service.path) {
|
|
564
890
|
return 'Service path is required';
|
|
565
891
|
}
|
|
892
|
+
// Validate canAccess references
|
|
893
|
+
if (service.canAccess) {
|
|
894
|
+
for (const targetName of service.canAccess) {
|
|
895
|
+
if (!serviceNames.has(targetName)) {
|
|
896
|
+
return `Service "${service.name}" has canAccess reference to unknown service "${targetName}"`;
|
|
897
|
+
}
|
|
898
|
+
if (targetName === service.name) {
|
|
899
|
+
return `Service "${service.name}" cannot have canAccess reference to itself`;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
566
903
|
}
|
|
567
904
|
// Validate before scripts
|
|
568
905
|
if (config.before && !Array.isArray(config.before)) {
|
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
"type": "integer",
|
|
39
39
|
"description": "Maximum number of lines to keep in the log file",
|
|
40
40
|
"minimum": 1
|
|
41
|
+
},
|
|
42
|
+
"canAccess": {
|
|
43
|
+
"type": "array",
|
|
44
|
+
"items": { "type": "string" },
|
|
45
|
+
"description": "Names of other services this pane can send commands to via hail/survey/parley"
|
|
41
46
|
}
|
|
42
47
|
},
|
|
43
48
|
"additionalProperties": false
|
package/harbor.schema.json
CHANGED
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
"type": "integer",
|
|
39
39
|
"description": "Maximum number of lines to keep in the log file",
|
|
40
40
|
"minimum": 1
|
|
41
|
+
},
|
|
42
|
+
"canAccess": {
|
|
43
|
+
"type": "array",
|
|
44
|
+
"items": { "type": "string" },
|
|
45
|
+
"description": "Names of other services this pane can send commands to via hail/survey/parley"
|
|
41
46
|
}
|
|
42
47
|
},
|
|
43
48
|
"additionalProperties": false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abyrd9/harbor-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "A CLI tool for orchestrating local development services in a tmux session. Perfect for microservices and polyglot projects with automatic service discovery and before/after script support.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/scripts/dev.sh
CHANGED
|
@@ -98,11 +98,17 @@ $tmux_cmd bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-
|
|
|
98
98
|
$tmux_cmd bind-key -n S-Left select-window -t :-
|
|
99
99
|
$tmux_cmd bind-key -n S-Right select-window -t :+
|
|
100
100
|
|
|
101
|
+
# Add Ctrl+t to create new terminal window (marked with + prefix)
|
|
102
|
+
$tmux_cmd bind-key -n C-t new-window -n "+Terminal"
|
|
103
|
+
|
|
104
|
+
# Add Ctrl+w to close current window ONLY if it's user-created (starts with +)
|
|
105
|
+
$tmux_cmd bind-key -n C-w if-shell 'tmux display-message -p "#{window_name}" | grep -q "^+"' 'kill-window' 'display-message "Cannot close service windows (only +Terminal tabs)"'
|
|
106
|
+
|
|
101
107
|
# Configure status bar
|
|
102
108
|
$tmux_cmd set-option -g status-position top
|
|
103
109
|
$tmux_cmd set-option -g status-style bg="#1c1917",fg="#a8a29e"
|
|
104
110
|
$tmux_cmd set-option -g status-left ""
|
|
105
|
-
$tmux_cmd set-option -g status-right "#[fg=#
|
|
111
|
+
$tmux_cmd set-option -g status-right "#[fg=#57534e]ctrl+t new · ctrl+w close · shift+←/→ switch · ctrl+q quit#[fg=#78716c] · %H:%M#[default]"
|
|
106
112
|
$tmux_cmd set-window-option -g window-status-current-format "\
|
|
107
113
|
#[fg=#6366f1, bg=#1c1917] →\
|
|
108
114
|
#[fg=#6366f1, bg=#1c1917, bold] #W\
|
|
@@ -143,23 +149,75 @@ while read service; do
|
|
|
143
149
|
echo "Path: $path"
|
|
144
150
|
echo "Command: $command"
|
|
145
151
|
|
|
152
|
+
# Build the environment export command for inter-pane communication
|
|
153
|
+
env_export="export HARBOR_SESSION='$session_name' HARBOR_SOCKET='$socket_name' HARBOR_SERVICE='$name' HARBOR_WINDOW=$window_index"
|
|
154
|
+
|
|
146
155
|
if [ "$log" = "true" ]; then
|
|
147
156
|
log_file="$repo_root/.harbor/${session_name}-${name}.log"
|
|
148
157
|
: > "$log_file"
|
|
149
158
|
# Use pipe-pane to capture ALL terminal output (works with any program, no buffering issues)
|
|
150
159
|
$tmux_cmd new-window -t "$session_name":$window_index -n "$name"
|
|
151
160
|
$tmux_cmd pipe-pane -t "$session_name":$window_index "cat >> \"$log_file\""
|
|
152
|
-
|
|
161
|
+
# Inject environment variables then run command
|
|
162
|
+
$tmux_cmd send-keys -t "$session_name":$window_index "$env_export && cd \"$path\" && $command" C-m
|
|
153
163
|
# Start background process to trim logs if they get too large
|
|
154
164
|
start_log_trim "$log_file" "$effective_max_lines"
|
|
155
165
|
else
|
|
156
166
|
$tmux_cmd new-window -t "$session_name":$window_index -n "$name"
|
|
157
|
-
|
|
167
|
+
# Inject environment variables then run command
|
|
168
|
+
$tmux_cmd send-keys -t "$session_name":$window_index "$env_export && cd \"$path\" && $command" C-m
|
|
158
169
|
fi
|
|
159
170
|
|
|
160
171
|
((window_index++))
|
|
161
172
|
done < <(get_harbor_config | jq -c '.services[]')
|
|
162
173
|
|
|
174
|
+
# Generate session.json for inter-pane communication
|
|
175
|
+
echo "Generating session metadata..."
|
|
176
|
+
mkdir -p "$repo_root/.harbor"
|
|
177
|
+
|
|
178
|
+
# Build the session JSON
|
|
179
|
+
session_json=$(cat <<EOF
|
|
180
|
+
{
|
|
181
|
+
"session": "$session_name",
|
|
182
|
+
"socket": "$socket_name",
|
|
183
|
+
"startedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
184
|
+
"services": {
|
|
185
|
+
EOF
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Add each service to the JSON
|
|
189
|
+
first_service=true
|
|
190
|
+
svc_window_index=1
|
|
191
|
+
while read service; do
|
|
192
|
+
name=$(echo $service | jq -r '.name')
|
|
193
|
+
can_access=$(echo $service | jq -c '.canAccess // []')
|
|
194
|
+
|
|
195
|
+
if [ "$first_service" = true ]; then
|
|
196
|
+
first_service=false
|
|
197
|
+
else
|
|
198
|
+
session_json+=","
|
|
199
|
+
fi
|
|
200
|
+
|
|
201
|
+
session_json+=$(cat <<EOF
|
|
202
|
+
|
|
203
|
+
"$name": {
|
|
204
|
+
"window": $svc_window_index,
|
|
205
|
+
"target": "$session_name:$svc_window_index",
|
|
206
|
+
"canAccess": $can_access
|
|
207
|
+
}
|
|
208
|
+
EOF
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
((svc_window_index++))
|
|
212
|
+
done < <(get_harbor_config | jq -c '.services[]')
|
|
213
|
+
|
|
214
|
+
session_json+="
|
|
215
|
+
}
|
|
216
|
+
}"
|
|
217
|
+
|
|
218
|
+
echo "$session_json" > "$repo_root/.harbor/session.json"
|
|
219
|
+
echo "Session metadata written to .harbor/session.json"
|
|
220
|
+
|
|
163
221
|
# Bind 'Home' key to switch to the terminal window
|
|
164
222
|
$tmux_cmd bind-key -n Home select-window -t :0
|
|
165
223
|
|