Skip to content

Commit 6fc0c63

Browse files
authored
Merge pull request #52 from agentage/feature/phase-4-cli-commands
feat: logout, machines, agents --all, run agent@machine
2 parents 80fc409 + 40eb0dc commit 6fc0c63

6 files changed

Lines changed: 324 additions & 24 deletions

File tree

src/commands/agents.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import { type AgentManifest } from '@agentage/core';
44
import { ensureDaemon } from '../utils/ensure-daemon.js';
55
import { get, post } from '../utils/daemon-client.js';
66

7+
interface HubAgent {
8+
name: string;
9+
description?: string;
10+
version?: string;
11+
machines?: { name: string; status: string };
12+
}
13+
714
export const registerAgents = (program: Command): void => {
815
program
916
.command('agents')
@@ -15,8 +22,7 @@ export const registerAgents = (program: Command): void => {
1522
await ensureDaemon();
1623

1724
if (opts.all) {
18-
console.error(chalk.red("Not connected to hub. Run 'agentage login' first."));
19-
process.exitCode = 1;
25+
await listHubAgents(opts.json ?? false);
2026
return;
2127
}
2228

@@ -55,3 +61,49 @@ export const registerAgents = (program: Command): void => {
5561
}
5662
});
5763
};
64+
65+
const listHubAgents = async (jsonMode: boolean): Promise<void> => {
66+
let agents: HubAgent[];
67+
try {
68+
agents = await get<HubAgent[]>('/api/hub/agents');
69+
} catch {
70+
console.error(chalk.red("Not connected to hub. Run 'agentage login' first."));
71+
process.exitCode = 1;
72+
return;
73+
}
74+
75+
if (jsonMode) {
76+
console.log(JSON.stringify(agents, null, 2));
77+
return;
78+
}
79+
80+
if (agents.length === 0) {
81+
console.log(chalk.gray('No agents found across machines.'));
82+
return;
83+
}
84+
85+
const nameWidth = Math.max(12, ...agents.map((a) => a.name.length)) + 2;
86+
const machineWidth = Math.max(10, ...agents.map((a) => (a.machines?.name || '').length)) + 2;
87+
const descWidth =
88+
Math.max(12, ...agents.map((a) => (a.description || '').length).slice(0, 40)) + 2;
89+
90+
console.log(
91+
chalk.bold('NAME'.padEnd(nameWidth)) +
92+
chalk.bold('MACHINE'.padEnd(machineWidth)) +
93+
chalk.bold('DESCRIPTION'.padEnd(descWidth)) +
94+
chalk.bold('STATUS')
95+
);
96+
97+
for (const agent of agents) {
98+
const machineName = agent.machines?.name || '';
99+
const status =
100+
agent.machines?.status === 'online' ? chalk.green('online') : chalk.gray('offline');
101+
102+
console.log(
103+
agent.name.padEnd(nameWidth) +
104+
machineName.padEnd(machineWidth) +
105+
(agent.description || '').substring(0, 38).padEnd(descWidth) +
106+
status
107+
);
108+
}
109+
};

src/commands/logout.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,34 @@
11
import { type Command } from 'commander';
22
import chalk from 'chalk';
3+
import { ensureDaemon } from '../utils/ensure-daemon.js';
4+
import { readAuth, deleteAuth } from '../hub/auth.js';
5+
import { createHubClient } from '../hub/hub-client.js';
36

47
export const registerLogout = (program: Command): void => {
58
program
69
.command('logout')
710
.description('Disconnect from hub')
8-
.action(() => {
9-
console.log(chalk.yellow('Hub sync not yet available.'));
11+
.action(async () => {
12+
await ensureDaemon();
13+
14+
const auth = readAuth();
15+
if (!auth) {
16+
console.log(chalk.yellow('Not logged in.'));
17+
return;
18+
}
19+
20+
// Best-effort deregister from hub
21+
try {
22+
const client = createHubClient(auth.hub.url, auth);
23+
await client.deregister(auth.hub.machineId);
24+
} catch {
25+
// Hub may be unreachable — that's fine
26+
}
27+
28+
deleteAuth();
29+
30+
console.log(chalk.green('Disconnected from hub. Machine deregistered.'));
31+
console.log(chalk.dim('Daemon continues running in standalone mode.'));
32+
console.log(chalk.dim('Run `agentage daemon restart` to apply.'));
1033
});
1134
};

src/commands/machines.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,75 @@
11
import { type Command } from 'commander';
22
import chalk from 'chalk';
33
import { ensureDaemon } from '../utils/ensure-daemon.js';
4+
import { get } from '../utils/daemon-client.js';
5+
6+
interface Machine {
7+
id: string;
8+
name: string;
9+
platform: string;
10+
status: string;
11+
last_seen_at: string;
12+
agents?: unknown[];
13+
}
414

515
export const registerMachines = (program: Command): void => {
616
program
717
.command('machines')
818
.description('List connected machines')
919
.option('--json', 'JSON output')
10-
.action(async () => {
20+
.action(async (opts: { json?: boolean }) => {
1121
await ensureDaemon();
12-
console.error(chalk.red("Not connected to hub. Run 'agentage login' first."));
13-
process.exitCode = 1;
22+
23+
let machines: Machine[];
24+
try {
25+
machines = await get<Machine[]>('/api/hub/machines');
26+
} catch {
27+
console.error(chalk.red("Not connected to hub. Run 'agentage login' first."));
28+
process.exitCode = 1;
29+
return;
30+
}
31+
32+
if (opts.json) {
33+
console.log(JSON.stringify(machines, null, 2));
34+
return;
35+
}
36+
37+
if (machines.length === 0) {
38+
console.log(chalk.gray('No machines registered.'));
39+
return;
40+
}
41+
42+
const nameWidth = Math.max(12, ...machines.map((m) => m.name.length)) + 2;
43+
44+
console.log(
45+
chalk.bold('NAME'.padEnd(nameWidth)) +
46+
chalk.bold('PLATFORM'.padEnd(12)) +
47+
chalk.bold('STATUS'.padEnd(12)) +
48+
chalk.bold('LAST SEEN')
49+
);
50+
51+
for (const machine of machines) {
52+
const status = machine.status === 'online' ? chalk.green('online') : chalk.gray('offline');
53+
54+
const lastSeen = formatLastSeen(machine.last_seen_at);
55+
56+
console.log(
57+
machine.name.padEnd(nameWidth) +
58+
machine.platform.padEnd(12) +
59+
status.padEnd(12 + (status.length - machine.status.length)) +
60+
chalk.gray(lastSeen)
61+
);
62+
}
1463
});
1564
};
65+
66+
const formatLastSeen = (iso: string): string => {
67+
const diff = Date.now() - new Date(iso).getTime();
68+
const seconds = Math.floor(diff / 1000);
69+
70+
if (seconds < 5) return 'just now';
71+
if (seconds < 60) return `${seconds}s ago`;
72+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
73+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
74+
return `${Math.floor(seconds / 86400)}d ago`;
75+
};

src/commands/run.ts

Lines changed: 136 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type Command } from 'commander';
22
import chalk from 'chalk';
33
import { type RunEvent } from '@agentage/core';
44
import { ensureDaemon } from '../utils/ensure-daemon.js';
5-
import { connectWs, post } from '../utils/daemon-client.js';
5+
import { connectWs, get, post } from '../utils/daemon-client.js';
66
import { renderEvent } from '../utils/render.js';
77

88
interface RunResponse {
@@ -24,10 +24,21 @@ type WsMessage = WsRunEventMessage | WsRunStateMessage;
2424

2525
const TERMINAL_STATES = ['completed', 'failed', 'canceled'];
2626

27+
const parseAgentTarget = (input: string): { agentName: string; machineName?: string } => {
28+
const atIndex = input.lastIndexOf('@');
29+
if (atIndex > 0) {
30+
return {
31+
agentName: input.substring(0, atIndex),
32+
machineName: input.substring(atIndex + 1),
33+
};
34+
}
35+
return { agentName: input };
36+
};
37+
2738
export const registerRun = (program: Command): void => {
2839
program
2940
.command('run')
30-
.argument('<agent>', 'Agent name')
41+
.argument('<agent>', 'Agent name (or agent@machine for remote)')
3142
.argument('[prompt]', 'Task/prompt for the agent')
3243
.description('Run an agent')
3344
.option('-d, --detach', 'Run in background, print run ID')
@@ -48,27 +59,96 @@ export const registerRun = (program: Command): void => {
4859
return;
4960
}
5061

51-
const config = opts.config
52-
? (JSON.parse(opts.config) as Record<string, unknown>)
53-
: undefined;
62+
const { agentName, machineName } = parseAgentTarget(agent);
5463

55-
const { runId } = await post<RunResponse>(`/api/agents/${agent}/run`, {
56-
task: prompt,
57-
config,
58-
context: opts.context,
59-
});
60-
61-
if (opts.detach) {
62-
console.log(runId);
63-
return;
64+
if (machineName) {
65+
await runRemote(agentName, machineName, prompt, opts);
66+
} else {
67+
await runLocal(agentName, prompt, opts);
6468
}
65-
66-
// Stream events via WebSocket
67-
await streamRun(runId, opts.json ?? false);
6869
}
6970
);
7071
};
7172

73+
const runLocal = async (
74+
agentName: string,
75+
prompt: string,
76+
opts: { detach?: boolean; json?: boolean; config?: string; context?: string[] }
77+
): Promise<void> => {
78+
const config = opts.config ? (JSON.parse(opts.config) as Record<string, unknown>) : undefined;
79+
80+
const { runId } = await post<RunResponse>(`/api/agents/${agentName}/run`, {
81+
task: prompt,
82+
config,
83+
context: opts.context,
84+
});
85+
86+
if (opts.detach) {
87+
console.log(runId);
88+
return;
89+
}
90+
91+
await streamRun(runId, opts.json ?? false);
92+
};
93+
94+
const runRemote = async (
95+
agentName: string,
96+
machineName: string,
97+
prompt: string,
98+
opts: { detach?: boolean; json?: boolean }
99+
): Promise<void> => {
100+
// Resolve machine name to machine ID
101+
let machines: Array<{ id: string; name: string }>;
102+
try {
103+
machines = await get<Array<{ id: string; name: string }>>('/api/hub/machines');
104+
} catch {
105+
console.error(chalk.red("Not connected to hub. Run 'agentage login' first."));
106+
process.exitCode = 1;
107+
return;
108+
}
109+
110+
const machine = machines.find((m) => m.name === machineName);
111+
if (!machine) {
112+
console.error(chalk.red(`Machine "${machineName}" not found.`));
113+
console.error(chalk.dim(`Available: ${machines.map((m) => m.name).join(', ') || 'none'}`));
114+
process.exitCode = 1;
115+
return;
116+
}
117+
118+
// Create run via hub
119+
let result: { runId?: string };
120+
try {
121+
result = await post<{ runId?: string }>('/api/hub/runs', {
122+
machineId: machine.id,
123+
agentName,
124+
input: prompt,
125+
});
126+
} catch (err) {
127+
console.error(
128+
chalk.red(`Failed to start remote run: ${err instanceof Error ? err.message : String(err)}`)
129+
);
130+
process.exitCode = 1;
131+
return;
132+
}
133+
134+
const runId = result.runId ?? (result as Record<string, unknown>).runId;
135+
if (!runId) {
136+
console.error(chalk.red('Failed to get run ID from hub'));
137+
process.exitCode = 1;
138+
return;
139+
}
140+
141+
if (opts.detach) {
142+
console.log(runId);
143+
return;
144+
}
145+
146+
console.log(chalk.dim(`Running ${agentName} on ${machineName}...`));
147+
148+
// Poll for events from hub (MVP approach — daemon proxies)
149+
await pollRemoteRun(runId as string, opts.json ?? false);
150+
};
151+
72152
const streamRun = (runId: string, jsonMode: boolean): Promise<void> =>
73153
new Promise((resolve) => {
74154
const ws = connectWs((data) => {
@@ -109,3 +189,42 @@ const streamRun = (runId: string, jsonMode: boolean): Promise<void> =>
109189
resolve();
110190
});
111191
});
192+
193+
const pollRemoteRun = async (runId: string, jsonMode: boolean): Promise<void> => {
194+
let lastEventId: string | undefined;
195+
const pollInterval = 1000;
196+
197+
const poll = async (): Promise<boolean> => {
198+
try {
199+
const url = lastEventId
200+
? `/api/hub/runs/${runId}/events?after=${lastEventId}`
201+
: `/api/hub/runs/${runId}/events`;
202+
203+
const events = await get<Array<{ id: string; type: string; data: unknown }>>(url);
204+
205+
for (const event of events) {
206+
if (jsonMode) {
207+
console.log(JSON.stringify(event));
208+
} else {
209+
renderEvent(event as unknown as RunEvent);
210+
}
211+
lastEventId = event.id;
212+
}
213+
214+
// Check run state
215+
const run = await get<{ state: string }>(`/api/hub/runs/${runId}`);
216+
if (TERMINAL_STATES.includes(run.state)) {
217+
return true;
218+
}
219+
} catch {
220+
// Hub may be temporarily unreachable
221+
}
222+
return false;
223+
};
224+
225+
while (true) {
226+
const done = await poll();
227+
if (done) break;
228+
await new Promise((r) => setTimeout(r, pollInterval));
229+
}
230+
};

0 commit comments

Comments
 (0)