Skip to content

Commit e6abb9d

Browse files
ralyodioclaude
andcommitted
fix: agent commands live in the existing pairux CLI; drop the separate package
`pairux` already exists: the installers put a launcher on PATH (update, uninstall, daemon, --version) that hands everything else to the desktop app. The agent commands now ride that same path instead of a second npm binary that collided with it and carried its own version. - apps/desktop/src/main/agent-cli: login/logout/whoami/join/listen/say/who/ status/leave, run headless by the main process (no window, no tray, no single-instance hand-off) and exit with the command's code. - install.sh (macOS + Linux) and install.ps1: help lists the agent commands; Windows runs them in the console through a pipe instead of `start`. - Removed packages/cli, its lockfile importer and vitest project; docs, README and llms.txt point at `pairux` from the installer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 6666ed1 commit e6abb9d

21 files changed

Lines changed: 203 additions & 218 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,18 @@ pairux --version # Show installed version
202202
pairux --help # Show help
203203
```
204204

205+
The same command brings an AI agent (Claude Code, moshcode, a script) into a live session as a
206+
labelled participant that follows and posts in the chat. See [pairux.com/docs/agents](https://pairux.com/docs/agents).
207+
208+
```bash
209+
pairux login # Optional: label your agents as yours (browser sign-in)
210+
pairux join ABC123 --name Claude # Join with the session's join code
211+
pairux listen --json # Follow chat and who is here; keeps the agent present
212+
pairux say "Tests pass" # Post in the chat
213+
pairux who # Who is in the session
214+
pairux leave
215+
```
216+
205217
### Join as a Viewer (No Install Required!)
206218

207219
Just open the session link in any modern browser. The viewer is a **Progressive Web App (PWA)** — you can install it for quick access without downloading anything.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export class PairuxClient {
6767
private async request<T>(path: string, init: RequestInit = {}, auth = false): Promise<T> {
6868
const headers: Record<string, string> = {
6969
Accept: 'application/json',
70-
'User-Agent': this.opts.userAgent ?? 'pairux-cli',
70+
'User-Agent': this.opts.userAgent ?? 'pairux',
7171
...(init.body ? { 'Content-Type': 'application/json' } : {}),
7272
};
7373
if (auth && this.opts.getAccessToken) {

packages/cli/src/auth.test.ts renamed to apps/desktop/src/main/agent-cli/auth.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { listenForCallback } from './auth.js';
2+
import { listenForCallback } from './auth';
33

44
describe('listenForCallback (real loopback socket)', () => {
55
it('resolves with the code when the state matches', async () => {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import { createHash, randomBytes } from 'node:crypto';
1414
import { createServer, type Server } from 'node:http';
1515
import { spawn } from 'node:child_process';
16-
import type { StoredTokens } from './config.js';
16+
import type { StoredTokens } from './config';
1717

1818
export interface Pkce {
1919
verifier: string;

packages/cli/src/cli.test.ts renamed to apps/desktop/src/main/agent-cli/commands.test.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { mkdtemp, readFile, stat } from 'node:fs/promises';
33
import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
55
import { beforeEach, describe, expect, it } from 'vitest';
6-
import { run, type Deps } from './cli.js';
7-
import { buildAuthorizeUrl, createPkce, needsRefresh } from './auth.js';
6+
import { run, type Deps } from './commands';
7+
import { buildAuthorizeUrl, createPkce, needsRefresh } from './auth';
88

99
interface Call {
1010
method: string;
@@ -25,7 +25,7 @@ function fakeFetch(handler: Handler, calls: Call[]): typeof fetch {
2525
method: init?.method ?? 'GET',
2626
path: url.pathname + url.search,
2727
body: typeof init?.body === 'string' ? init.body : null,
28-
auth: headers.Authorization ?? null,
28+
auth: 'Authorization' in headers ? headers.Authorization : null,
2929
};
3030
calls.push(call);
3131
const { status, body } = handler(call);
@@ -64,7 +64,7 @@ const joined = {
6464
};
6565

6666
beforeEach(async () => {
67-
dir = await mkdtemp(join(tmpdir(), 'pairux-cli-'));
67+
dir = await mkdtemp(join(tmpdir(), 'pairux-agent-'));
6868
out = [];
6969
err = [];
7070
calls = [];
@@ -113,7 +113,7 @@ describe('pairux join / say / leave', () => {
113113
);
114114
expect(code).toBe(0);
115115
expect(calls[0]).toMatchObject({ method: 'POST', path: '/api/v1/agents/join', auth: null });
116-
expect(JSON.parse(calls[0]!.body!)).toEqual({
116+
expect(JSON.parse(calls[0].body!)).toEqual({
117117
joinCode: 'ABC123',
118118
name: 'Claude Code',
119119
client: 'claude-code',
@@ -131,7 +131,7 @@ describe('pairux join / say / leave', () => {
131131
: { status: 404, body: { error: 'nope' } }
132132
);
133133
await (
134-
await import('./config.js')
134+
await import('./config')
135135
).saveConfig(
136136
{
137137
tokens: {
@@ -143,7 +143,7 @@ describe('pairux join / say / leave', () => {
143143
d.configPath
144144
);
145145
expect(await run(['join', 'ABC123', '--name', 'Reviewer'], d)).toBe(0);
146-
expect(calls[0]!.auth).toBe('Bearer pux_at_x');
146+
expect(calls[0].auth).toBe('Bearer pux_at_x');
147147
expect(out[0]).toContain('(your agent)');
148148
});
149149

@@ -176,7 +176,7 @@ describe('pairux join / say / leave', () => {
176176
method: 'POST',
177177
path: `/api/v1/agents/${joined.participantId}/messages`,
178178
});
179-
expect(JSON.parse(calls[1]!.body!)).toEqual({ content: 'hello room' });
179+
expect(JSON.parse(calls[1].body!)).toEqual({ content: 'hello room' });
180180
expect(await run(['say', 'x'.repeat(501)], d)).toBe(2);
181181
expect(calls).toHaveLength(2);
182182
});
@@ -281,7 +281,7 @@ describe('pairux listen', () => {
281281
expect(out[0]).toMatch(/Anthony: can you check the test\?$/);
282282
expect(out[1]).toBe('* Sam joined');
283283
// Second poll asks only for messages after the one already shown.
284-
expect(calls[2]!.path).toContain(encodeURIComponent('2026-09-24T12:00:01.000Z'));
284+
expect(calls[2].path).toContain(encodeURIComponent('2026-09-24T12:00:01.000Z'));
285285
expect((await readConfig()).agent).toMatchObject({ cursor: '2026-09-24T12:00:01.000Z' });
286286
});
287287

@@ -305,7 +305,7 @@ describe('pairux listen', () => {
305305
await run(['join', 'ABC123'], d);
306306
out = [];
307307
await run(['listen', '--json', '--once'], d);
308-
expect(JSON.parse(out[0]!)).toMatchObject({ type: 'message', content: 'hi' });
308+
expect(JSON.parse(out[0])).toMatchObject({ type: 'message', content: 'hi' });
309309
});
310310

311311
it('stops cleanly and forgets the agent when removed (410)', async () => {
@@ -390,7 +390,7 @@ describe('pairux login', () => {
390390
};
391391
});
392392
await (
393-
await import('./config.js')
393+
await import('./config')
394394
).saveConfig(
395395
{
396396
tokens: {
@@ -408,7 +408,7 @@ describe('pairux login', () => {
408408
it('logout revokes the refresh token and drops it', async () => {
409409
const d = deps(() => ({ status: 200, body: {} }));
410410
await (
411-
await import('./config.js')
411+
await import('./config')
412412
).saveConfig(
413413
{
414414
tokens: {
@@ -420,8 +420,8 @@ describe('pairux login', () => {
420420
d.configPath
421421
);
422422
expect(await run(['logout'], d)).toBe(0);
423-
expect(calls[0]!.path).toBe('/api/v1/cli/revoke');
424-
expect(calls[0]!.body).toBe('token=pux_rt_x');
423+
expect(calls[0].path).toBe('/api/v1/cli/revoke');
424+
expect(calls[0].body).toBe('token=pux_rt_x');
425425
expect((await readConfig()).tokens).toBeUndefined();
426426
});
427427
});

packages/cli/src/cli.ts renamed to apps/desktop/src/main/agent-cli/commands.ts

Lines changed: 15 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
#!/usr/bin/env node
21
/**
3-
* pairux — bring an AI agent into a live PairUX session as a participant.
2+
* Agent commands of the `pairux` launcher: bring an AI agent into a live
3+
* PairUX session as a participant. The installed launcher hands these argv to
4+
* the desktop app, whose main process runs them headless and exits (see
5+
* ./index.ts), so they ship and version with the app and need no system Node.
46
*
57
* pairux login sign in (browser, OAuth 2.1 + PKCE)
68
* pairux join ABC123 --name "Claude"
@@ -13,30 +15,20 @@
1315
* against a fake server without touching the network, the terminal or ~/.
1416
*/
1517
import { parseArgs } from 'node:util';
16-
import { realpathSync } from 'node:fs';
17-
import { fileURLToPath } from 'node:url';
18-
import {
19-
apiUrl as resolveApiUrl,
20-
configPath as defaultConfigPath,
21-
loadConfig,
22-
saveConfig,
23-
type CliConfig,
24-
} from './config.js';
18+
import { apiUrl as resolveApiUrl, loadConfig, saveConfig, type CliConfig } from './config';
2519
import {
2620
AuthError,
2721
buildAuthorizeUrl,
2822
createPkce,
2923
exchangeCode,
3024
listenForCallback,
3125
needsRefresh,
32-
openBrowser as defaultOpenBrowser,
3326
randomState,
3427
refresh,
3528
revoke,
36-
} from './auth.js';
37-
import { ApiError, PairuxClient, type ChatMessage, type RosterEntry } from './api.js';
29+
} from './auth';
30+
import { ApiError, PairuxClient, type ChatMessage, type RosterEntry } from './api';
3831

39-
export const VERSION = '0.1.0';
4032
const MAX_MESSAGE = 500;
4133

4234
export interface Deps {
@@ -51,9 +43,11 @@ export interface Deps {
5143
/** Tests stop `listen` after N polls; production runs until 410 or Ctrl-C. */
5244
maxPolls?: number;
5345
login?: typeof listenForCallback;
46+
/** The app version, for the User-Agent and `pairux version`. */
47+
version?: string;
5448
}
5549

56-
const HELP = `pairux ${VERSION}: bring AI agents into PairUX sessions
50+
export const AGENT_HELP = `Agent commands: bring AI agents into PairUX sessions
5751
5852
Usage:
5953
pairux login [--no-browser] Sign in so your agents are labelled as yours
@@ -138,7 +132,7 @@ export async function run(argv: string[], deps: Deps): Promise<number> {
138132
apiUrl: api,
139133
getAccessToken,
140134
fetchImpl: deps.fetchImpl,
141-
userAgent: `pairux-cli/${VERSION}`,
135+
userAgent: `pairux/${deps.version ?? 'unknown'}`,
142136
});
143137

144138
const requireAgent = () => {
@@ -148,17 +142,16 @@ export async function run(argv: string[], deps: Deps): Promise<number> {
148142

149143
try {
150144
switch (command) {
151-
case undefined:
152145
case 'help':
153146
case '--help':
154147
case '-h':
155-
deps.out(HELP);
148+
deps.out(AGENT_HELP);
156149
return 0;
157150

158151
case 'version':
159152
case '--version':
160153
case '-v':
161-
deps.out(VERSION);
154+
deps.out(deps.version ?? 'unknown');
162155
return 0;
163156

164157
case 'login': {
@@ -351,7 +344,7 @@ export async function run(argv: string[], deps: Deps): Promise<number> {
351344
}
352345

353346
for (const m of result.messages) emitMessage(m);
354-
const last = result.messages[result.messages.length - 1];
347+
const last = result.messages.at(-1);
355348
if (last) cursor = last.created_at;
356349
else cursor ??= result.serverTime;
357350

@@ -405,36 +398,9 @@ export async function run(argv: string[], deps: Deps): Promise<number> {
405398
}
406399
}
407400

408-
async function readAllStdin(): Promise<string | null> {
401+
export async function readAllStdin(): Promise<string | null> {
409402
if (process.stdin.isTTY) return null;
410403
const chunks: Buffer[] = [];
411404
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
412405
return Buffer.concat(chunks).toString('utf8');
413406
}
414-
415-
function isMain(): boolean {
416-
if (!process.argv[1]) return false;
417-
try {
418-
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
419-
} catch {
420-
return false;
421-
}
422-
}
423-
424-
if (isMain()) {
425-
const code = await run(process.argv.slice(2), {
426-
env: process.env,
427-
configPath: defaultConfigPath(),
428-
fetchImpl: fetch,
429-
out: (line) => {
430-
process.stdout.write(line + '\n');
431-
},
432-
err: (line) => {
433-
process.stderr.write(line + '\n');
434-
},
435-
openBrowser: defaultOpenBrowser,
436-
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
437-
readStdin: readAllStdin,
438-
});
439-
process.exitCode = code;
440-
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { parseAgentArgv } from './index';
3+
4+
describe('parseAgentArgv', () => {
5+
it('picks up an agent command from the packaged app argv', () => {
6+
expect(
7+
parseAgentArgv(['/opt/PairUX/pairux', 'join', 'ABC123', '--name', 'Claude'], true)
8+
).toEqual(['join', 'ABC123', '--name', 'Claude']);
9+
});
10+
11+
it('skips switches a wrapper prepends (AppImage --no-sandbox)', () => {
12+
expect(parseAgentArgv(['/tmp/.mount/pairux', '--no-sandbox', 'say', 'hi'], true)).toEqual([
13+
'say',
14+
'hi',
15+
]);
16+
});
17+
18+
it('accounts for the app path in dev argv', () => {
19+
expect(parseAgentArgv(['electron', '.', 'listen', '--json'], false)).toEqual([
20+
'listen',
21+
'--json',
22+
]);
23+
});
24+
25+
it('leaves normal launches, the daemon and deep links alone', () => {
26+
expect(parseAgentArgv(['/opt/PairUX/pairux'], true)).toBeNull();
27+
expect(parseAgentArgv(['/opt/PairUX/pairux', 'daemon'], true)).toBeNull();
28+
expect(parseAgentArgv(['/opt/PairUX/pairux', 'pairux://join/ABC123'], true)).toBeNull();
29+
expect(parseAgentArgv(['/opt/PairUX/pairux', '--daemon'], true)).toBeNull();
30+
});
31+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* `pairux login|logout|whoami|join|listen|say|who|status|leave`
3+
*
4+
* The installed `pairux` launcher passes any argument it does not handle
5+
* itself (update, uninstall, --help, --version) straight to the app, the same
6+
* way `pairux daemon` works. When the first real argument is one of these
7+
* agent commands, the main process runs it headless, prints to the terminal
8+
* and exits: no window, no tray, no single-instance hand-off.
9+
*/
10+
import { configPath } from './config';
11+
import { openBrowser } from './auth';
12+
import { readAllStdin, run } from './commands';
13+
14+
export const AGENT_COMMANDS = new Set([
15+
'login',
16+
'logout',
17+
'whoami',
18+
'join',
19+
'listen',
20+
'say',
21+
'who',
22+
'status',
23+
'leave',
24+
]);
25+
26+
/**
27+
* The agent command and its arguments, or null when the app should start
28+
* normally. Packaged argv is [exe, ...args]; in dev it is [electron, '.', ...].
29+
* Chromium/Electron switches (`--no-sandbox`, `--ozone-platform=...`) that a
30+
* wrapper may prepend are skipped.
31+
*/
32+
export function parseAgentArgv(argv: string[], isPackaged: boolean): string[] | null {
33+
const args = argv.slice(isPackaged ? 1 : 2);
34+
const first = args.findIndex((a) => !a.startsWith('--'));
35+
if (first === -1) return null;
36+
const command = args[first];
37+
if (!command || !AGENT_COMMANDS.has(command)) return null;
38+
return args.slice(first);
39+
}
40+
41+
export function runAgentCommand(args: string[], version: string): Promise<number> {
42+
return run(args, {
43+
env: process.env,
44+
configPath: configPath(),
45+
fetchImpl: fetch,
46+
out: (line) => {
47+
process.stdout.write(line + '\n');
48+
},
49+
err: (line) => {
50+
process.stderr.write(line + '\n');
51+
},
52+
openBrowser,
53+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
54+
readStdin: readAllStdin,
55+
version,
56+
});
57+
}

0 commit comments

Comments
 (0)