shennian 0.2.30 → 0.2.33

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.
File without changes
@@ -0,0 +1,7 @@
1
+ export declare function readLatestUserEnv(): NodeJS.ProcessEnv;
2
+ export declare function mergeAgentProcessEnv(input: {
3
+ daemonEnv: NodeJS.ProcessEnv;
4
+ userEnv: NodeJS.ProcessEnv;
5
+ extra?: NodeJS.ProcessEnv;
6
+ }): NodeJS.ProcessEnv;
7
+ export declare function buildAgentProcessEnv(extra?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
@@ -0,0 +1,82 @@
1
+ // @arch docs/architecture/cli/agent-adapters.md
2
+ // @test src/__tests__/agent-env.test.ts
3
+ import os from 'node:os';
4
+ import { spawnSync } from 'node:child_process';
5
+ const SHELL_ENV_START = '__SHENNIAN_AGENT_ENV_START__';
6
+ const SHELL_ENV_END = '__SHENNIAN_AGENT_ENV_END__';
7
+ function quotePosix(value) {
8
+ return `'${value.replace(/'/g, `'\\''`)}'`;
9
+ }
10
+ function parseEnvJson(stdout) {
11
+ const start = stdout.indexOf(SHELL_ENV_START);
12
+ const end = stdout.indexOf(SHELL_ENV_END, start + SHELL_ENV_START.length);
13
+ if (start === -1 || end === -1)
14
+ return null;
15
+ const json = stdout.slice(start + SHELL_ENV_START.length, end);
16
+ try {
17
+ const parsed = JSON.parse(json);
18
+ const env = {};
19
+ for (const [key, value] of Object.entries(parsed)) {
20
+ if (typeof value === 'string')
21
+ env[key] = value;
22
+ }
23
+ return env;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ function readPosixShellEnv() {
30
+ const shell = process.env.SHELL?.trim() || '/bin/sh';
31
+ const script = [
32
+ `printf ${quotePosix(SHELL_ENV_START)}`,
33
+ `${quotePosix(process.execPath)} -e ${quotePosix('process.stdout.write(JSON.stringify(process.env))')}`,
34
+ `printf ${quotePosix(SHELL_ENV_END)}`,
35
+ ].join('; ');
36
+ for (const args of [['-ilc', script], ['-lc', script]]) {
37
+ const result = spawnSync(shell, args, {
38
+ env: process.env,
39
+ encoding: 'utf-8',
40
+ stdio: ['ignore', 'pipe', 'ignore'],
41
+ timeout: 1500,
42
+ });
43
+ const parsed = typeof result.stdout === 'string' ? parseEnvJson(result.stdout) : null;
44
+ if (parsed)
45
+ return parsed;
46
+ }
47
+ return null;
48
+ }
49
+ function readWindowsGlobalEnv() {
50
+ const script = `
51
+ $envs = @{}
52
+ [Environment]::GetEnvironmentVariables('Machine').GetEnumerator() | ForEach-Object { $envs[$_.Key] = [string]$_.Value }
53
+ [Environment]::GetEnvironmentVariables('User').GetEnumerator() | ForEach-Object { $envs[$_.Key] = [string]$_.Value }
54
+ Write-Output '${SHELL_ENV_START}'
55
+ $envs | ConvertTo-Json -Compress
56
+ Write-Output '${SHELL_ENV_END}'
57
+ `;
58
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
59
+ encoding: 'utf-8',
60
+ stdio: ['ignore', 'pipe', 'ignore'],
61
+ timeout: 1500,
62
+ });
63
+ return typeof result.stdout === 'string' ? parseEnvJson(result.stdout) : null;
64
+ }
65
+ export function readLatestUserEnv() {
66
+ const env = os.platform() === 'win32' ? readWindowsGlobalEnv() : readPosixShellEnv();
67
+ return env ?? {};
68
+ }
69
+ export function mergeAgentProcessEnv(input) {
70
+ return {
71
+ ...input.daemonEnv,
72
+ ...input.userEnv,
73
+ ...input.extra,
74
+ };
75
+ }
76
+ export function buildAgentProcessEnv(extra = {}) {
77
+ return mergeAgentProcessEnv({
78
+ daemonEnv: process.env,
79
+ userEnv: readLatestUserEnv(),
80
+ extra,
81
+ });
82
+ }
@@ -2,6 +2,7 @@ import { createInterface } from 'node:readline';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { AgentAdapter, registerAgent } from './adapter.js';
4
4
  import { resolveBuiltinCommand, spawnResolvedCommand } from './command-spec.js';
5
+ import { buildAgentProcessEnv } from '../agent-env.js';
5
6
  export function normalizeClaudeModelId(modelId) {
6
7
  const trimmed = modelId?.trim();
7
8
  return trimmed || 'default';
@@ -60,7 +61,7 @@ export class ClaudeAdapter extends AgentAdapter {
60
61
  const proc = spawnResolvedCommand(spec, args, {
61
62
  cwd: this.workDir ?? undefined,
62
63
  stdio: ['ignore', 'pipe', 'pipe'],
63
- env: { ...process.env },
64
+ env: buildAgentProcessEnv(),
64
65
  });
65
66
  this.process = proc;
66
67
  const rl = createInterface({ input: proc.stdout });
@@ -4,6 +4,7 @@ import { createInterface } from 'node:readline';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { AgentAdapter, registerAgent } from './adapter.js';
6
6
  import { resolveBuiltinCommand, spawnResolvedCommand } from './command-spec.js';
7
+ import { buildAgentProcessEnv } from '../agent-env.js';
7
8
  export class CodexAdapter extends AgentAdapter {
8
9
  type = 'codex';
9
10
  process = null;
@@ -78,7 +79,7 @@ export class CodexAdapter extends AgentAdapter {
78
79
  const proc = spawnResolvedCommand(spec, args, {
79
80
  cwd: this.workDir ?? undefined,
80
81
  stdio: ['ignore', 'pipe', 'pipe'],
81
- env: { ...process.env },
82
+ env: buildAgentProcessEnv(),
82
83
  });
83
84
  this.process = proc;
84
85
  const rl = createInterface({ input: proc.stdout });
@@ -125,7 +126,7 @@ export class CodexAdapter extends AgentAdapter {
125
126
  const proc = spawnResolvedCommand(spec, ['app-server', '--listen', 'stdio://'], {
126
127
  cwd: this.workDir ?? undefined,
127
128
  stdio: ['pipe', 'pipe', 'pipe'],
128
- env: { ...process.env, NO_COLOR: '1' },
129
+ env: buildAgentProcessEnv({ NO_COLOR: '1' }),
129
130
  });
130
131
  this.process = proc;
131
132
  this.stderrBuf = '';
@@ -4,6 +4,7 @@ import { createInterface } from 'node:readline';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { AgentAdapter, registerAgent } from './adapter.js';
6
6
  import { resolveBuiltinCommand, spawnResolvedCommand } from './command-spec.js';
7
+ import { buildAgentProcessEnv } from '../agent-env.js';
7
8
  export class CursorAdapter extends AgentAdapter {
8
9
  type = 'cursor';
9
10
  process = null;
@@ -60,7 +61,7 @@ export class CursorAdapter extends AgentAdapter {
60
61
  const proc = spawnResolvedCommand(spec, args, {
61
62
  cwd: this.workDir ?? undefined,
62
63
  stdio: ['ignore', 'pipe', 'pipe'],
63
- env: { ...process.env },
64
+ env: buildAgentProcessEnv(),
64
65
  });
65
66
  this.process = proc;
66
67
  const rl = createInterface({ input: proc.stdout });
@@ -3,6 +3,7 @@ import { createInterface } from 'node:readline';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { AgentAdapter, registerAgent } from './adapter.js';
5
5
  import { spawnCommandString } from './command-spec.js';
6
+ import { buildAgentProcessEnv } from '../agent-env.js';
6
7
  export class CustomAgentAdapter extends AgentAdapter {
7
8
  type;
8
9
  command;
@@ -75,7 +76,7 @@ export class CustomAgentAdapter extends AgentAdapter {
75
76
  const proc = spawnCommandString(this.command, args, {
76
77
  cwd: this.workDir ?? undefined,
77
78
  stdio: ['pipe', 'pipe', 'pipe'],
78
- env: { ...process.env },
79
+ env: buildAgentProcessEnv(),
79
80
  });
80
81
  this.spawnProcess = proc;
81
82
  this.attachOutputHandlers(proc, () => {
@@ -101,7 +102,7 @@ export class CustomAgentAdapter extends AgentAdapter {
101
102
  const proc = spawnCommandString(this.command, ['/start', '--workdir', this.workDir ?? process.cwd()], {
102
103
  cwd: this.workDir ?? undefined,
103
104
  stdio: ['pipe', 'pipe', 'pipe'],
104
- env: { ...process.env },
105
+ env: buildAgentProcessEnv(),
105
106
  });
106
107
  this.stdioProcess = proc;
107
108
  this.attachOutputHandlers(proc, () => {
@@ -2,6 +2,7 @@ import { createInterface } from 'node:readline';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { AgentAdapter, registerAgent } from './adapter.js';
4
4
  import { resolveBuiltinCommand, spawnResolvedCommand } from './command-spec.js';
5
+ import { buildAgentProcessEnv } from '../agent-env.js';
5
6
  function num(v) {
6
7
  return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
7
8
  }
@@ -72,7 +73,7 @@ export class GeminiAdapter extends AgentAdapter {
72
73
  const proc = spawnResolvedCommand(spec, args, {
73
74
  cwd: this.workDir ?? undefined,
74
75
  stdio: ['ignore', 'pipe', 'pipe'],
75
- env: { ...process.env },
76
+ env: buildAgentProcessEnv(),
76
77
  });
77
78
  this.process = proc;
78
79
  const rl = createInterface({ input: proc.stdout });
@@ -5,6 +5,7 @@ import { resolveBuiltinCommand, spawnResolvedCommand } from '../command-spec.js'
5
5
  import { fallbackClaudeAliasModels, fallbackGeminiModels, parseCodexAppServerModels, parseCursorModels, parseOpenCodeModels, } from './parsers.js';
6
6
  import { runResolvedCommand } from './runner.js';
7
7
  import { DISCOVERY_WORKDIR } from './types.js';
8
+ import { buildAgentProcessEnv } from '../../agent-env.js';
8
9
  function sendAppServerRpc(proc, pending, id, method, params, timeoutMs) {
9
10
  if (!proc.stdin)
10
11
  return Promise.reject(new Error('codex app-server stdin unavailable'));
@@ -27,7 +28,7 @@ async function discoverCodexModelsViaAppServer(spec) {
27
28
  const proc = spawnResolvedCommand(spec, ['app-server', '--listen', 'stdio://'], {
28
29
  cwd: DISCOVERY_WORKDIR,
29
30
  stdio: ['pipe', 'pipe', 'pipe'],
30
- env: { ...process.env, NO_COLOR: '1' },
31
+ env: buildAgentProcessEnv({ NO_COLOR: '1' }),
31
32
  });
32
33
  const pending = new Map();
33
34
  let seq = 1;
@@ -2,12 +2,13 @@
2
2
  // @test src/__tests__/model-switching.test.ts
3
3
  import { spawnResolvedCommand } from '../command-spec.js';
4
4
  import { DISCOVERY_WORKDIR } from './types.js';
5
+ import { buildAgentProcessEnv } from '../../agent-env.js';
5
6
  export function runResolvedCommand(spec, args, timeoutMs = 15_000) {
6
7
  return new Promise((resolve, reject) => {
7
8
  const proc = spawnResolvedCommand(spec, args, {
8
9
  cwd: DISCOVERY_WORKDIR,
9
10
  stdio: ['ignore', 'pipe', 'pipe'],
10
- env: { ...process.env, NO_COLOR: '1' },
11
+ env: buildAgentProcessEnv({ NO_COLOR: '1' }),
11
12
  });
12
13
  let stdout = '';
13
14
  let stderr = '';
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { AgentAdapter } from './adapter.js';
3
3
  import { resolveBuiltinCommand, spawnResolvedCommand } from './command-spec.js';
4
+ import { buildAgentProcessEnv } from '../agent-env.js';
4
5
  /** Best-effort parse of `openclaw agent --json` stdout; shape may vary by version. */
5
6
  function extractResponseText(parsed) {
6
7
  if (parsed == null)
@@ -125,7 +126,7 @@ export class OpenClawAdapter extends AgentAdapter {
125
126
  const proc = spawnResolvedCommand(spec, args, {
126
127
  cwd: this.workDir ?? undefined,
127
128
  stdio: ['ignore', 'pipe', 'pipe'],
128
- env: { ...process.env },
129
+ env: buildAgentProcessEnv(),
129
130
  });
130
131
  this.process = proc;
131
132
  let stdoutBuf = '';
@@ -204,7 +205,7 @@ export class OpenClawAdapter extends AgentAdapter {
204
205
  const proc = spawnResolvedCommand(spec, args, {
205
206
  cwd: this.workDir ?? undefined,
206
207
  stdio: ['ignore', 'pipe', 'pipe'],
207
- env: { ...process.env },
208
+ env: buildAgentProcessEnv(),
208
209
  });
209
210
  let stdoutBuf = '';
210
211
  let stderrBuf = '';
@@ -4,6 +4,7 @@ import { createInterface } from 'node:readline';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { AgentAdapter, registerAgent } from './adapter.js';
6
6
  import { resolveBuiltinCommand, spawnResolvedCommand } from './command-spec.js';
7
+ import { buildAgentProcessEnv } from '../agent-env.js';
7
8
  function usageFromTokens(tokens) {
8
9
  if (!tokens)
9
10
  return undefined;
@@ -103,7 +104,7 @@ export class OpenCodeAdapter extends AgentAdapter {
103
104
  const proc = spawnResolvedCommand(spec, args, {
104
105
  cwd: this.workDir ?? undefined,
105
106
  stdio: ['ignore', 'pipe', 'pipe'],
106
- env: { ...process.env, NO_COLOR: '1' },
107
+ env: buildAgentProcessEnv({ NO_COLOR: '1' }),
107
108
  });
108
109
  this.process = proc;
109
110
  const rl = createInterface({ input: proc.stdout });
@@ -1,4 +1,5 @@
1
1
  import type { Command } from 'commander';
2
+ export declare function isSafeSnapshotEnvKey(key: string): boolean;
2
3
  type Platform = 'darwin' | 'linux' | 'win32';
3
4
  type ServiceLaunchMode = 'direct' | 'global-shim' | 'npx';
4
5
  type DaemonLauncher = 'desktop-managed' | 'global-cli' | 'unknown';
@@ -13,6 +14,7 @@ export type DaemonStatus = {
13
14
  pidFile: string;
14
15
  logFile: string;
15
16
  machineId?: string;
17
+ paired?: boolean;
16
18
  serverUrl?: string;
17
19
  };
18
20
  export type ServiceLaunchSpec = {
@@ -7,6 +7,7 @@ import os from 'node:os';
7
7
  import { execSync, spawn } from 'node:child_process';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { getShennianDir, loadConfig, resolveShennianPath, saveConfig } from '../config/index.js';
10
+ import { buildAugmentedPath } from '../env-path.js';
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
12
  const SHENNIAN_DIR = getShennianDir();
12
13
  const PID_FILE = resolveShennianPath('daemon.pid');
@@ -15,6 +16,32 @@ const REMOTE_ACCESS_DISABLED_FILE = resolveShennianPath('remote-access.disabled'
15
16
  const LAUNCHER_FILE = resolveShennianPath('daemon-launcher.json');
16
17
  const SHENNIAN_SCRIPT = path.resolve(__dirname, '../../bin/shennian.js');
17
18
  const NODE_EXEC = process.execPath;
19
+ const SAFE_SNAPSHOT_ENV_KEYS = new Set([
20
+ 'PATH',
21
+ 'HOME',
22
+ 'USER',
23
+ 'LOGNAME',
24
+ 'SHELL',
25
+ 'TMPDIR',
26
+ 'LANG',
27
+ 'LC_ALL',
28
+ 'LC_CTYPE',
29
+ 'SSH_AUTH_SOCK',
30
+ 'XDG_CONFIG_HOME',
31
+ 'XDG_DATA_HOME',
32
+ 'TEMP',
33
+ 'TMP',
34
+ 'APPDATA',
35
+ 'LOCALAPPDATA',
36
+ 'ELECTRON_RUN_AS_NODE',
37
+ 'SHENNIAN_DESKTOP_CLI_SCRIPT',
38
+ 'SHENNIAN_DESKTOP_CLI_BRIDGE',
39
+ 'SHENNIAN_DESKTOP_SERVER_URL',
40
+ 'SHENNIAN_HOME',
41
+ ]);
42
+ export function isSafeSnapshotEnvKey(key) {
43
+ return SAFE_SNAPSHOT_ENV_KEYS.has(key);
44
+ }
18
45
  function getPlatform() {
19
46
  const p = os.platform();
20
47
  if (p === 'darwin' || p === 'linux' || p === 'win32')
@@ -176,6 +203,7 @@ export function getDaemonStatus(opts = {}) {
176
203
  pidFile: PID_FILE,
177
204
  logFile: LOG_FILE,
178
205
  ...(config.machineId ? { machineId: config.machineId } : {}),
206
+ paired: Boolean(config.machineToken && config.machineId),
179
207
  ...(config.serverUrl ? { serverUrl: config.serverUrl } : {}),
180
208
  };
181
209
  }
@@ -190,6 +218,9 @@ function persistServerUrlOverride(serverUrl) {
190
218
  config.serverUrl = normalized;
191
219
  saveConfig(config);
192
220
  }
221
+ function resolveServerUrlOverride(api) {
222
+ return api?.trim() || process.env.SHENNIAN_DESKTOP_SERVER_URL?.trim() || undefined;
223
+ }
193
224
  // ─── Service definitions ─────────────────────────────────────────────────────
194
225
  const LAUNCHD_LABEL = 'com.shennian.agent';
195
226
  const LAUNCHD_PLIST = path.join(os.homedir(), 'Library/LaunchAgents', `${LAUNCHD_LABEL}.plist`);
@@ -391,40 +422,13 @@ function installWindowsScheduledTask() {
391
422
  }
392
423
  }
393
424
  export function captureEnvForService() {
394
- const keep = [
395
- 'PATH',
396
- 'HOME',
397
- 'USER',
398
- 'LOGNAME',
399
- 'SHELL',
400
- 'TMPDIR',
401
- 'LANG',
402
- 'LC_ALL',
403
- 'LC_CTYPE',
404
- 'SSH_AUTH_SOCK',
405
- 'XDG_CONFIG_HOME',
406
- 'XDG_DATA_HOME',
407
- 'TEMP',
408
- 'TMP',
409
- 'ANTHROPIC_API_KEY',
410
- 'ANTHROPIC_AUTH_TOKEN',
411
- 'ANTHROPIC_BASE_URL',
412
- 'OPENAI_API_KEY',
413
- 'OPENAI_BASE_URL',
414
- 'GEMINI_API_KEY',
415
- 'GOOGLE_API_KEY',
416
- 'ELECTRON_RUN_AS_NODE',
417
- 'SHENNIAN_DESKTOP_CLI_SCRIPT',
418
- 'SHENNIAN_DESKTOP_CLI_BRIDGE',
419
- 'SHENNIAN_HOME',
420
- ];
421
425
  const env = {};
422
- for (const k of keep) {
426
+ for (const k of SAFE_SNAPSHOT_ENV_KEYS) {
423
427
  if (process.env[k])
424
428
  env[k] = process.env[k];
425
429
  }
426
430
  env.HOME ??= os.homedir();
427
- env.PATH ??= '/usr/local/bin:/usr/bin:/bin';
431
+ env.PATH = buildAugmentedPath({ pathValue: env.PATH, env });
428
432
  env.USER ??= os.userInfo().username;
429
433
  if (getPlatform() === 'win32') {
430
434
  const tempDir = process.env.TEMP || process.env.TMP || os.tmpdir();
@@ -505,7 +509,7 @@ export function saveEnvSnapshot() {
505
509
  fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
506
510
  const snapshot = {};
507
511
  for (const [k, v] of Object.entries(process.env)) {
508
- if (v !== undefined)
512
+ if (v !== undefined && isSafeSnapshotEnvKey(k))
509
513
  snapshot[k] = v;
510
514
  }
511
515
  fs.writeFileSync(resolveShennianPath('env.json'), JSON.stringify(snapshot, null, 2));
@@ -650,7 +654,7 @@ async function stopDaemonProcessAndWait(timeoutMs = 5000) {
650
654
  return result;
651
655
  }
652
656
  function enableRemoteAccess(opts = {}) {
653
- persistServerUrlOverride(opts.api);
657
+ persistServerUrlOverride(resolveServerUrlOverride(opts.api));
654
658
  try {
655
659
  fs.unlinkSync(REMOTE_ACCESS_DISABLED_FILE);
656
660
  }
@@ -723,6 +727,7 @@ async function disableRemoteAccess(opts = {}) {
723
727
  }
724
728
  }
725
729
  function daemonStart(opts) {
730
+ saveEnvSnapshot();
726
731
  enableRemoteAccess(opts);
727
732
  }
728
733
  async function daemonStop(opts = {}) {
@@ -738,7 +743,7 @@ async function waitForPidExit(pid, timeoutMs = 5000) {
738
743
  return !isRunning(pid);
739
744
  }
740
745
  async function daemonRestart(opts = {}) {
741
- persistServerUrlOverride(opts.api);
746
+ persistServerUrlOverride(resolveServerUrlOverride(opts.api));
742
747
  const pid = readPid();
743
748
  if (pid !== null && isRunning(pid)) {
744
749
  try {
@@ -3,6 +3,7 @@ export declare function runPairFlow(opts: {
3
3
  serverUrl: string;
4
4
  machineName: string;
5
5
  force?: boolean;
6
+ json?: boolean;
6
7
  }): Promise<void>;
7
8
  export declare function registerDesktopMachine(opts: {
8
9
  serverUrl: string;
@@ -8,6 +8,13 @@ import { detectAndChooseServer } from '../region.js';
8
8
  import { buildPairQrPayload, PAIR_QR_RENDER_OPTIONS } from './pair-qr.js';
9
9
  const POLL_INTERVAL_MS = 3000;
10
10
  const POLL_TIMEOUT_MS = 10 * 60 * 1000;
11
+ function emitPairJson(event) {
12
+ process.stdout.write(`${JSON.stringify(event)}\n`);
13
+ }
14
+ function failPairJson(code, message) {
15
+ emitPairJson({ type: 'error', code, message });
16
+ process.exit(1);
17
+ }
11
18
  function renderTokenBox(token) {
12
19
  const line = '─'.repeat(token.length + 4);
13
20
  console.log(chalk.cyan(`┌${line}┐`));
@@ -23,8 +30,10 @@ async function renderQR(url) {
23
30
  });
24
31
  });
25
32
  }
26
- async function pollStatus(serverUrl, pairToken) {
33
+ async function pollStatus(serverUrl, pairToken, opts = {}) {
27
34
  const deadline = Date.now() + POLL_TIMEOUT_MS;
35
+ if (opts.json)
36
+ emitPairJson({ type: 'pair.waiting' });
28
37
  while (Date.now() < deadline) {
29
38
  await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
30
39
  let res;
@@ -35,13 +44,19 @@ async function pollStatus(serverUrl, pairToken) {
35
44
  continue;
36
45
  }
37
46
  if (res.status === 410) {
38
- console.error(chalk.red('\n✗ Pairing code expired. Please re-run: shennian pair'));
39
- process.exit(1);
47
+ if (opts.json)
48
+ failPairJson('PAIR_EXPIRED', 'Pairing code expired');
49
+ else {
50
+ console.error(chalk.red('\n✗ Pairing code expired. Please re-run: shennian pair'));
51
+ process.exit(1);
52
+ }
40
53
  }
41
54
  if (!res.ok)
42
55
  continue;
43
56
  const data = (await res.json());
44
57
  if (data.status === 'claimed' && data.machineToken && data.machineId) {
58
+ if (opts.json)
59
+ emitPairJson({ type: 'pair.claimed' });
45
60
  return {
46
61
  machineToken: data.machineToken,
47
62
  machineId: data.machineId,
@@ -61,11 +76,15 @@ function askConfirm(question) {
61
76
  });
62
77
  }
63
78
  export async function runPairFlow(opts) {
64
- const { serverUrl, machineName, force } = opts;
79
+ const { serverUrl, machineName, force, json } = opts;
65
80
  const config = loadConfig();
66
81
  if (config.machineToken && config.machineId && !force) {
67
- console.log(chalk.green(`✓ Already paired (machine ID: ${config.machineId})`));
68
- console.log(chalk.gray(' Run "shennian pair" to re-pair to another account'));
82
+ if (json)
83
+ emitPairJson({ type: 'done', status: 'already-paired', machineId: config.machineId });
84
+ else {
85
+ console.log(chalk.green(`✓ Already paired (machine ID: ${config.machineId})`));
86
+ console.log(chalk.gray(' Run "shennian pair" to re-pair to another account'));
87
+ }
69
88
  return;
70
89
  }
71
90
  if (config.machineToken && !force)
@@ -74,9 +93,11 @@ export async function runPairFlow(opts) {
74
93
  const agentList = detectedAgents.map((a) => a.type);
75
94
  const platform = os.platform();
76
95
  const osName = platform === 'win32' ? 'windows' : platform === 'darwin' ? 'macos' : 'linux';
77
- console.log(chalk.bold('\nShennian CLI — Pairing\n'));
78
- console.log(chalk.gray(` Machine: ${machineName} | OS: ${osName} | Agents: ${agentList.join(', ') || 'none'}`));
79
- console.log();
96
+ if (!json) {
97
+ console.log(chalk.bold('\nShennian CLI Pairing\n'));
98
+ console.log(chalk.gray(` Machine: ${machineName} | OS: ${osName} | Agents: ${agentList.join(', ') || 'none'}`));
99
+ console.log();
100
+ }
80
101
  let res;
81
102
  try {
82
103
  res = await fetch(`${serverUrl}/api/machines/pair/create`, {
@@ -86,12 +107,18 @@ export async function runPairFlow(opts) {
86
107
  });
87
108
  }
88
109
  catch {
89
- console.error(chalk.red(`✗ Network error: cannot connect to ${serverUrl}`));
110
+ const message = `Network error: cannot connect to ${serverUrl}`;
111
+ if (json)
112
+ failPairJson('NETWORK_ERROR', message);
113
+ console.error(chalk.red(`✗ ${message}`));
90
114
  process.exit(1);
91
115
  }
92
116
  if (!res.ok) {
93
117
  const body = await res.json().catch(() => ({}));
94
- console.error(chalk.red(`✗ Failed to create machine: ${body.error ?? res.statusText}`));
118
+ const message = `Failed to create machine: ${body.error ?? res.statusText}`;
119
+ if (json)
120
+ failPairJson('PAIR_CREATE_FAILED', message);
121
+ console.error(chalk.red(`✗ ${message}`));
95
122
  process.exit(1);
96
123
  }
97
124
  const data = (await res.json());
@@ -99,21 +126,37 @@ export async function runPairFlow(opts) {
99
126
  config.serverUrl = serverUrl;
100
127
  saveConfig(config);
101
128
  const pairUrl = buildPairQrPayload(data.pairToken);
102
- console.log(chalk.white('Scan QR code or enter the pairing code in the Shennian app:'));
103
- console.log();
104
- await renderQR(pairUrl);
105
- renderTokenBox(data.pairToken);
106
- console.log();
107
- console.log(chalk.gray('Waiting for pairing... (Ctrl+C to cancel)'));
108
- const result = await pollStatus(serverUrl, data.pairToken);
129
+ if (json) {
130
+ emitPairJson({
131
+ type: 'pair.created',
132
+ code: data.pairToken,
133
+ machineId: data.machineId,
134
+ expiresAt: new Date(Date.now() + POLL_TIMEOUT_MS).toISOString(),
135
+ });
136
+ }
137
+ else {
138
+ console.log(chalk.white('Scan QR code or enter the pairing code in the Shennian app:'));
139
+ console.log();
140
+ await renderQR(pairUrl);
141
+ renderTokenBox(data.pairToken);
142
+ console.log();
143
+ console.log(chalk.gray('Waiting for pairing... (Ctrl+C to cancel)'));
144
+ }
145
+ const result = await pollStatus(serverUrl, data.pairToken, { json });
109
146
  if (!result) {
147
+ if (json)
148
+ failPairJson('PAIR_TIMEOUT', 'Timed out waiting for pairing');
110
149
  console.error(chalk.red('\n✗ Timed out. Pairing code expired, please try again.'));
111
150
  process.exit(1);
112
151
  }
113
152
  config.machineToken = result.machineToken;
114
153
  config.machineId = result.machineId;
115
154
  saveConfig(config);
116
- console.log(chalk.green(`\n✓ Paired successfully! Machine "${result.machineName}" is now linked.`));
155
+ if (json)
156
+ emitPairJson({ type: 'pair.saved', machineId: result.machineId });
157
+ if (!json) {
158
+ console.log(chalk.green(`\n✓ Paired successfully! Machine "${result.machineName}" is now linked.`));
159
+ }
117
160
  }
118
161
  export async function registerDesktopMachine(opts) {
119
162
  const config = loadConfig();
@@ -149,6 +192,18 @@ export async function registerDesktopMachine(opts) {
149
192
  saveConfig(config);
150
193
  return { machineId: data.machine.id, machineName: data.machine.name };
151
194
  }
195
+ async function resolveDesktopRegisterServerUrl(api) {
196
+ const explicit = api?.trim();
197
+ if (explicit)
198
+ return explicit;
199
+ const envUrl = process.env.SHENNIAN_DESKTOP_SERVER_URL?.trim();
200
+ if (envUrl)
201
+ return envUrl;
202
+ const configUrl = loadConfig().serverUrl?.trim();
203
+ if (configUrl)
204
+ return configUrl;
205
+ return detectAndChooseServer();
206
+ }
152
207
  export async function runSmartStart(serverUrl, machineName) {
153
208
  const config = loadConfig();
154
209
  if (!config.machineToken) {
@@ -169,15 +224,16 @@ export function registerPairCommand(program) {
169
224
  program
170
225
  .command('desktop-register', { hidden: true })
171
226
  .description('Register this desktop-managed machine to the authenticated account')
172
- .requiredOption('--api <url>', 'Server URL')
227
+ .option('--api <url>', 'Server URL')
173
228
  .option('--name <name>', 'Machine name', os.hostname())
174
229
  .action(async (opts) => {
175
230
  try {
176
231
  const token = process.env.SHENNIAN_DESKTOP_AUTH_TOKEN;
177
232
  if (!token)
178
233
  throw new Error('Missing desktop auth token');
234
+ const serverUrl = await resolveDesktopRegisterServerUrl(opts.api);
179
235
  const result = await registerDesktopMachine({
180
- serverUrl: opts.api,
236
+ serverUrl,
181
237
  authToken: token,
182
238
  machineName: opts.name,
183
239
  });
@@ -192,10 +248,16 @@ export function registerPairCommand(program) {
192
248
  .command('pair')
193
249
  .description('Pair this machine to a Shennian account (re-pair if already paired)')
194
250
  .option('--api <url>', 'Server URL override')
251
+ .option('--server <url>', 'Server URL override')
195
252
  .option('--name <name>', 'Machine name', os.hostname())
253
+ .option('--json', 'Emit machine-readable JSONL events')
196
254
  .action(async (opts) => {
197
255
  const config = loadConfig();
198
256
  if (config.machineToken) {
257
+ if (opts.json) {
258
+ emitPairJson({ type: 'done', status: 'already-paired', machineId: config.machineId });
259
+ return;
260
+ }
199
261
  console.log(chalk.yellow(`Currently paired (machine ID: ${config.machineId})`));
200
262
  const yes = await askConfirm('Re-pairing will unlink the current account. Continue? (y/N) ');
201
263
  if (!yes) {
@@ -206,13 +268,18 @@ export function registerPairCommand(program) {
206
268
  delete config.machineId;
207
269
  saveConfig(config);
208
270
  }
209
- const serverUrl = opts.api ?? (await detectAndChooseServer());
210
- await runPairFlow({ serverUrl, machineName: opts.name, force: true });
271
+ const serverUrl = opts.server ?? opts.api ?? (await detectAndChooseServer());
272
+ await runPairFlow({ serverUrl, machineName: opts.name, force: true, json: Boolean(opts.json) });
211
273
  saveEnvSnapshot();
212
- console.log(chalk.gray('\nStarting background service...'));
274
+ if (opts.json)
275
+ emitPairJson({ type: 'daemon.starting' });
276
+ else
277
+ console.log(chalk.gray('\nStarting background service...'));
213
278
  const startedByService = installService();
214
279
  if (!startedByService) {
215
- startDaemonProcess();
280
+ startDaemonProcess({ quiet: Boolean(opts.json) });
216
281
  }
282
+ if (opts.json)
283
+ emitPairJson({ type: 'done', status: 'paired', machineId: loadConfig().machineId });
217
284
  });
218
285
  }
@@ -0,0 +1,14 @@
1
+ export declare function getUserBinPathCandidates(input?: {
2
+ env?: NodeJS.ProcessEnv;
3
+ homedir?: string;
4
+ exists?: (filePath: string) => boolean;
5
+ readdir?: (filePath: string) => string[];
6
+ }): string[];
7
+ export declare function buildAugmentedPath(input?: {
8
+ pathValue?: string;
9
+ env?: NodeJS.ProcessEnv;
10
+ homedir?: string;
11
+ exists?: (filePath: string) => boolean;
12
+ readdir?: (filePath: string) => string[];
13
+ }): string;
14
+ export declare function augmentProcessPath(): void;
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ const DEFAULT_POSIX_PATH = '/usr/local/bin:/usr/bin:/bin';
5
+ function uniquePush(parts, value, front = false) {
6
+ if (!value || parts.includes(value))
7
+ return;
8
+ if (front)
9
+ parts.unshift(value);
10
+ else
11
+ parts.push(value);
12
+ }
13
+ export function getUserBinPathCandidates(input = {}) {
14
+ const env = input.env ?? process.env;
15
+ const home = input.homedir ?? os.homedir();
16
+ const exists = input.exists ?? fs.existsSync;
17
+ const readdir = input.readdir ?? ((filePath) => fs.readdirSync(filePath));
18
+ const candidates = [];
19
+ if (process.platform === 'win32') {
20
+ const appData = env.APPDATA?.trim() || path.join(home, 'AppData', 'Roaming');
21
+ const localAppData = env.LOCALAPPDATA?.trim() || path.join(home, 'AppData', 'Local');
22
+ uniquePush(candidates, path.join(appData, 'npm'));
23
+ uniquePush(candidates, path.join(localAppData, 'pnpm'));
24
+ uniquePush(candidates, path.join(home, 'scoop', 'shims'));
25
+ uniquePush(candidates, path.join('C:\\', 'Program Files', 'nodejs'));
26
+ return candidates;
27
+ }
28
+ uniquePush(candidates, path.join(home, '.local', 'bin'));
29
+ uniquePush(candidates, path.join(home, '.bun', 'bin'));
30
+ uniquePush(candidates, path.join(home, '.npm-global', 'bin'));
31
+ uniquePush(candidates, path.join(home, '.npm', 'bin'));
32
+ uniquePush(candidates, path.join(home, '.cargo', 'bin'));
33
+ uniquePush(candidates, path.join(home, '.maestro', 'bin'));
34
+ const nvmDir = env.NVM_DIR?.trim() || path.join(home, '.nvm');
35
+ const nvmVersionsDir = path.join(nvmDir, 'versions', 'node');
36
+ try {
37
+ const nvmBins = readdir(nvmVersionsDir)
38
+ .sort((left, right) => right.localeCompare(left, undefined, { numeric: true }))
39
+ .map((version) => path.join(nvmVersionsDir, version, 'bin'))
40
+ .filter((candidate) => exists(candidate));
41
+ for (const candidate of nvmBins)
42
+ uniquePush(candidates, candidate);
43
+ }
44
+ catch {
45
+ // nvm is optional.
46
+ }
47
+ uniquePush(candidates, '/opt/homebrew/bin');
48
+ uniquePush(candidates, '/usr/local/bin');
49
+ uniquePush(candidates, '/usr/bin');
50
+ uniquePush(candidates, '/bin');
51
+ uniquePush(candidates, '/usr/sbin');
52
+ uniquePush(candidates, '/sbin');
53
+ return candidates;
54
+ }
55
+ export function buildAugmentedPath(input = {}) {
56
+ const basePath = input.pathValue ?? input.env?.PATH ?? process.env.PATH ?? DEFAULT_POSIX_PATH;
57
+ const parts = basePath.split(path.delimiter).filter(Boolean);
58
+ for (const candidate of getUserBinPathCandidates(input))
59
+ uniquePush(parts, candidate);
60
+ return parts.join(path.delimiter);
61
+ }
62
+ export function augmentProcessPath() {
63
+ process.env.PATH = buildAugmentedPath();
64
+ }
package/dist/src/index.js CHANGED
@@ -15,6 +15,8 @@ import { SessionManager } from './session/manager.js';
15
15
  import { SERVERS, regionToUrl, urlToRegion } from './region.js';
16
16
  import { getCurrentVersion, handleStartupCrashCheck, checkForUpdate, isUpgradeVersionInCooldown, recordUpgradeFailure, } from './upgrade/engine.js';
17
17
  import { detectAgents } from './agents/detect.js';
18
+ import { augmentProcessPath } from './env-path.js';
19
+ augmentProcessPath();
18
20
  const cliVersion = getCurrentVersion();
19
21
  const AUTO_UPGRADE_INITIAL_DELAY_MS = 30_000;
20
22
  const AUTO_UPGRADE_POLL_INTERVAL_MS = 5 * 60_000;
@@ -66,7 +68,7 @@ program
66
68
  .option('--name <name>', 'Machine name', os.hostname())
67
69
  .action(async (opts) => {
68
70
  const config = loadConfig();
69
- const serverUrl = opts.api ?? config.serverUrl ?? undefined;
71
+ const serverUrl = opts.api ?? process.env.SHENNIAN_DESKTOP_SERVER_URL ?? config.serverUrl ?? undefined;
70
72
  await runSmartStart(serverUrl, opts.name);
71
73
  });
72
74
  // run-service: internal command used by the background service process
@@ -82,6 +84,7 @@ program
82
84
  if (!process.env[k])
83
85
  process.env[k] = v;
84
86
  }
87
+ augmentProcessPath();
85
88
  }
86
89
  catch {
87
90
  // env.json may not exist yet
@@ -148,7 +151,7 @@ program
148
151
  console.error(chalk.red('✗ Not paired yet. Run: shennian'));
149
152
  process.exit(1);
150
153
  }
151
- const serverUrl = opts.api ?? config.serverUrl ?? SERVERS.global.url;
154
+ const serverUrl = opts.api ?? process.env.SHENNIAN_DESKTOP_SERVER_URL ?? config.serverUrl ?? SERVERS.global.url;
152
155
  const wsBase = httpToWs(serverUrl);
153
156
  const wsUrl = `${wsBase}/relay/machine`;
154
157
  const cliVersion = getCurrentVersion();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.30",
3
+ "version": "0.2.33",
4
4
  "description": "Shennian — AI Agent Control Plane CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,14 +32,20 @@
32
32
  "engines": {
33
33
  "node": ">=18"
34
34
  },
35
+ "scripts": {
36
+ "build": "tsc",
37
+ "build:publish": "node -e \"const fs=require('node:fs'); fs.rmSync('dist', { recursive: true, force: true }); fs.rmSync('.tsbuildinfo.publish', { force: true })\" && tsc -p tsconfig.publish.json",
38
+ "dev": "tsc --watch",
39
+ "prepublishOnly": "pnpm build:publish"
40
+ },
35
41
  "dependencies": {
36
42
  "@mariozechner/pi-agent-core": "^0.64.0",
37
43
  "@sinclair/typebox": "^0.34.49",
44
+ "@shennian/wire": "^0.1.2",
38
45
  "chalk": "^5.4.1",
39
46
  "commander": "^13.1.0",
40
47
  "qrcode-terminal": "^0.12.0",
41
- "ws": "^8.18.1",
42
- "@shennian/wire": "0.1.2"
48
+ "ws": "^8.18.1"
43
49
  },
44
50
  "devDependencies": {
45
51
  "@types/node": "^20",
@@ -47,10 +53,5 @@
47
53
  "@types/ws": "^8.18.1",
48
54
  "tsx": "^4.19.4",
49
55
  "typescript": "^5.9.3"
50
- },
51
- "scripts": {
52
- "build": "tsc",
53
- "build:publish": "node -e \"const fs=require('node:fs'); fs.rmSync('dist', { recursive: true, force: true }); fs.rmSync('.tsbuildinfo.publish', { force: true })\" && tsc -p tsconfig.publish.json",
54
- "dev": "tsc --watch"
55
56
  }
56
- }
57
+ }