feat: add minimal companion client CLI command
This commit is contained in:
@@ -77,6 +77,7 @@ Flynn provides a full CLI via the `flynn` binary (or `npx tsx src/cli/index.ts`
|
|||||||
| `flynn gmail-auth` | Authenticate with Gmail via OAuth2 |
|
| `flynn gmail-auth` | Authenticate with Gmail via OAuth2 |
|
||||||
| `flynn gcal-auth` | Authenticate with Google Calendar via OAuth2 |
|
| `flynn gcal-auth` | Authenticate with Google Calendar via OAuth2 |
|
||||||
| `flynn skills` | List/install/manage skills |
|
| `flynn skills` | List/install/manage skills |
|
||||||
|
| `flynn companion` | Run a minimal companion node client against the gateway |
|
||||||
|
|
||||||
`flynn setup` / `flynn onboard` now print a post-save channel verification checklist (start command, WebChat URL, `/status` smoke test, and channel-specific validation hints).
|
`flynn setup` / `flynn onboard` now print a post-save channel verification checklist (start command, WebChat URL, `/status` smoke test, and channel-specific validation hints).
|
||||||
|
|
||||||
@@ -1330,6 +1331,10 @@ Companion runtime helper:
|
|||||||
- runtime observability/control passthroughs (`pendingRequestCount`, `pendingEventWaitCount`, `hasPendingWork`, `idle`, `lastDisconnectCode`, `lastDisconnectReason`, `getPendingWorkSnapshot()`, `getEventSurfaceSnapshot()`, `getConnectionSnapshot()`, `connected`, `waitForIdle()`)
|
- runtime observability/control passthroughs (`pendingRequestCount`, `pendingEventWaitCount`, `hasPendingWork`, `idle`, `lastDisconnectCode`, `lastDisconnectReason`, `getPendingWorkSnapshot()`, `getEventSurfaceSnapshot()`, `getConnectionSnapshot()`, `connected`, `waitForIdle()`)
|
||||||
- `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety, optional interval jitter (`jitterRatio`) to spread load (with safe normalization for invalid random samples), `tickNow()` for manual sends, success/error hooks, loop observability (`successCount`, `lastSuccessAt`, `failureCount`, `lastFailure`, `getState()`), and optional auto-stop after repeated failures.
|
- `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety, optional interval jitter (`jitterRatio`) to spread load (with safe normalization for invalid random samples), `tickNow()` for manual sends, success/error hooks, loop observability (`successCount`, `lastSuccessAt`, `failureCount`, `lastFailure`, `getState()`), and optional auto-stop after repeated failures.
|
||||||
|
|
||||||
|
Minimal companion CLI:
|
||||||
|
- `flynn companion --once` connects to the gateway, registers a node, publishes one heartbeat, then exits.
|
||||||
|
- `flynn companion --platform macos --heartbeat 30` runs a long-lived node with periodic heartbeats and logs `agent.stream`/`agent.typing` events.
|
||||||
|
|
||||||
## WebChat PWA Push Subscriptions
|
## WebChat PWA Push Subscriptions
|
||||||
|
|
||||||
Enable installable WebChat PWA metadata and browser push-subscription storage on the gateway:
|
Enable installable WebChat PWA metadata and browser push-subscription storage on the gateway:
|
||||||
|
|||||||
+17
-2
@@ -5375,10 +5375,25 @@
|
|||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/channels/utils.test.ts src/channels/telegram/adapter.test.ts src/channels/discord/adapter.test.ts src/channels/slack/adapter.test.ts src/channels/whatsapp/adapter.test.ts + pnpm typecheck passing"
|
"test_status": "pnpm test:run src/channels/utils.test.ts src/channels/telegram/adapter.test.ts src/channels/discord/adapter.test.ts src/channels/slack/adapter.test.ts src/channels/whatsapp/adapter.test.ts + pnpm typecheck passing"
|
||||||
|
},
|
||||||
|
"minimal-companion-client-tier-b2": {
|
||||||
|
"status": "completed",
|
||||||
|
"date": "2026-02-18",
|
||||||
|
"updated": "2026-02-18",
|
||||||
|
"summary": "Implemented Tier B2 minimal companion client via new `flynn companion` CLI command. The command connects to gateway node RPC, registers a companion node, publishes heartbeats, streams agent typing/content events, supports config-derived URL/token defaults, and includes one-shot mode for smoke testing.",
|
||||||
|
"files_modified": [
|
||||||
|
"src/cli/companion.ts",
|
||||||
|
"src/cli/companion.test.ts",
|
||||||
|
"src/cli/index.ts",
|
||||||
|
"src/cli/index.test.ts",
|
||||||
|
"README.md",
|
||||||
|
"docs/plans/state.json"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm test:run src/cli/companion.test.ts src/cli/index.test.ts + pnpm typecheck passing"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1924,
|
"total_test_count": 1927,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
@@ -5398,7 +5413,7 @@
|
|||||||
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
||||||
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
||||||
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
||||||
"next_up": "Implement Tier B2 minimal companion app client"
|
"next_up": "Track OpenClaw evolution regularly for inspiration and feature ideas"
|
||||||
},
|
},
|
||||||
"soul_md_and_cron_create": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockLoadConfigSafe,
|
||||||
|
mockGetConfigPath,
|
||||||
|
mockRuntimeCtorArgs,
|
||||||
|
mockRuntimeInstances,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
const runtimeCtorArgs: Array<{ url: string; token?: string }> = [];
|
||||||
|
const runtimeInstances: Array<{
|
||||||
|
connect: ReturnType<typeof vi.fn>;
|
||||||
|
registerNode: ReturnType<typeof vi.fn>;
|
||||||
|
setNodeStatus: ReturnType<typeof vi.fn>;
|
||||||
|
subscribeAgentStream: ReturnType<typeof vi.fn>;
|
||||||
|
subscribeAgentTyping: ReturnType<typeof vi.fn>;
|
||||||
|
disconnect: ReturnType<typeof vi.fn>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const loadConfigSafe = vi.fn(() => ({
|
||||||
|
config: {
|
||||||
|
server: {
|
||||||
|
port: 18888,
|
||||||
|
token: 'config-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getConfigPath = vi.fn(() => '/tmp/flynn-config.yaml');
|
||||||
|
|
||||||
|
return {
|
||||||
|
mockLoadConfigSafe: loadConfigSafe,
|
||||||
|
mockGetConfigPath: getConfigPath,
|
||||||
|
mockRuntimeCtorArgs: runtimeCtorArgs,
|
||||||
|
mockRuntimeInstances: runtimeInstances,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('./shared.js', () => ({
|
||||||
|
loadConfigSafe: mockLoadConfigSafe,
|
||||||
|
getConfigPath: mockGetConfigPath,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../companion/index.js', () => ({
|
||||||
|
CompanionRuntimeClient: class {
|
||||||
|
connect = vi.fn(async () => undefined);
|
||||||
|
registerNode = vi.fn(async ({ nodeId, role, capabilities }: { nodeId: string; role: string; capabilities: string[] }) => ({
|
||||||
|
registered: true,
|
||||||
|
node: { id: nodeId, role },
|
||||||
|
protocol: { serverVersion: 1, clientVersion: 1, negotiatedVersion: 1 },
|
||||||
|
capabilities: { declared: capabilities, enabled: capabilities },
|
||||||
|
}));
|
||||||
|
setNodeStatus = vi.fn(async () => ({ updated: true, node: { id: 'n', role: 'companion' } }));
|
||||||
|
subscribeAgentStream = vi.fn(() => () => undefined);
|
||||||
|
subscribeAgentTyping = vi.fn(() => () => undefined);
|
||||||
|
disconnect = vi.fn(() => undefined);
|
||||||
|
|
||||||
|
constructor(opts: { url: string; token?: string }) {
|
||||||
|
mockRuntimeCtorArgs.push(opts);
|
||||||
|
mockRuntimeInstances.push(this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('companion command', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockRuntimeCtorArgs.length = 0;
|
||||||
|
mockRuntimeInstances.length = 0;
|
||||||
|
mockLoadConfigSafe.mockReturnValue({
|
||||||
|
config: {
|
||||||
|
server: {
|
||||||
|
port: 18888,
|
||||||
|
token: 'config-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockGetConfigPath.mockReturnValue('/tmp/flynn-config.yaml');
|
||||||
|
process.exitCode = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses config-derived gateway url/token by default', async () => {
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
const program = new Command();
|
||||||
|
const { registerCompanionCommand } = await import('./companion.js');
|
||||||
|
registerCompanionCommand(program);
|
||||||
|
|
||||||
|
await program.parseAsync(['node', 'test', 'companion', '--once']);
|
||||||
|
|
||||||
|
expect(mockGetConfigPath).toHaveBeenCalledOnce();
|
||||||
|
expect(mockRuntimeCtorArgs).toEqual([{ url: 'ws://127.0.0.1:18888', token: 'config-token' }]);
|
||||||
|
expect(mockRuntimeInstances[0]?.connect).toHaveBeenCalledOnce();
|
||||||
|
expect(mockRuntimeInstances[0]?.registerNode).toHaveBeenCalledOnce();
|
||||||
|
expect(mockRuntimeInstances[0]?.setNodeStatus).toHaveBeenCalledOnce();
|
||||||
|
expect(mockRuntimeInstances[0]?.disconnect).toHaveBeenCalled();
|
||||||
|
expect(errSpy).not.toHaveBeenCalled();
|
||||||
|
expect(process.exitCode).toBeUndefined();
|
||||||
|
|
||||||
|
logSpy.mockRestore();
|
||||||
|
errSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers explicit url/token and capability overrides', async () => {
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
const program = new Command();
|
||||||
|
const { registerCompanionCommand } = await import('./companion.js');
|
||||||
|
registerCompanionCommand(program);
|
||||||
|
|
||||||
|
await program.parseAsync([
|
||||||
|
'node',
|
||||||
|
'test',
|
||||||
|
'companion',
|
||||||
|
'--once',
|
||||||
|
'--url',
|
||||||
|
'ws://10.0.0.5:19000',
|
||||||
|
'--token',
|
||||||
|
'override-token',
|
||||||
|
'--node-id',
|
||||||
|
'test-node',
|
||||||
|
'--capability',
|
||||||
|
'ui.canvas',
|
||||||
|
'node.push.register',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mockRuntimeCtorArgs).toEqual([{ url: 'ws://10.0.0.5:19000', token: 'override-token' }]);
|
||||||
|
expect(mockRuntimeInstances[0]?.registerNode).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
nodeId: 'test-node',
|
||||||
|
capabilities: ['ui.canvas', 'node.push.register'],
|
||||||
|
}));
|
||||||
|
expect(errSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
logSpy.mockRestore();
|
||||||
|
errSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets process exit code when options are invalid', async () => {
|
||||||
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
const program = new Command();
|
||||||
|
const { registerCompanionCommand } = await import('./companion.js');
|
||||||
|
registerCompanionCommand(program);
|
||||||
|
|
||||||
|
await program.parseAsync(['node', 'test', 'companion', '--once', '--heartbeat', '0']);
|
||||||
|
|
||||||
|
expect(errSpy).toHaveBeenCalled();
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
|
|
||||||
|
errSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import type { Command } from 'commander';
|
||||||
|
import { hostname } from 'node:os';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { CompanionRuntimeClient } from '../companion/index.js';
|
||||||
|
import type { SetNodeStatusInput } from '../companion/index.js';
|
||||||
|
import { getConfigPath, loadConfigSafe } from './shared.js';
|
||||||
|
|
||||||
|
type CompanionPlatform = SetNodeStatusInput['platform'];
|
||||||
|
|
||||||
|
interface CompanionCommandOptions {
|
||||||
|
config?: string;
|
||||||
|
url?: string;
|
||||||
|
token?: string;
|
||||||
|
nodeId?: string;
|
||||||
|
role?: string;
|
||||||
|
platform?: CompanionPlatform;
|
||||||
|
capability?: string[];
|
||||||
|
heartbeat?: string;
|
||||||
|
once?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGatewayUrl(options: CompanionCommandOptions, configPath: string): string {
|
||||||
|
if (options.url && options.url.trim().length > 0) {
|
||||||
|
return options.url.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = loadConfigSafe(configPath);
|
||||||
|
if (loaded.config) {
|
||||||
|
const port = loaded.config.server.port;
|
||||||
|
return `ws://127.0.0.1:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ws://127.0.0.1:18800';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGatewayToken(options: CompanionCommandOptions, configPath: string): string | undefined {
|
||||||
|
if (options.token && options.token.trim().length > 0) {
|
||||||
|
return options.token.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = loadConfigSafe(configPath);
|
||||||
|
return loaded.config?.server.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCapabilities(platform: CompanionPlatform, provided?: string[]): string[] {
|
||||||
|
if (provided && provided.length > 0) {
|
||||||
|
return provided.map((v) => v.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'ios' || platform === 'macos') {
|
||||||
|
return ['ui.canvas', 'node.status.write', 'node.location.write', 'node.push.register'];
|
||||||
|
}
|
||||||
|
if (platform === 'android') {
|
||||||
|
return ['ui.canvas', 'node.status.write', 'node.location.write', 'node.push.register'];
|
||||||
|
}
|
||||||
|
return ['ui.canvas', 'node.status.write'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNodeId(options: CompanionCommandOptions, platform: CompanionPlatform): string {
|
||||||
|
if (options.nodeId && options.nodeId.trim().length > 0) {
|
||||||
|
return options.nodeId.trim();
|
||||||
|
}
|
||||||
|
return `${platform}-${hostname()}-${randomUUID().slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeartbeatSeconds(value: string | undefined): number {
|
||||||
|
const raw = value ?? '30';
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 86_400) {
|
||||||
|
throw new Error('heartbeat must be an integer between 1 and 86400 seconds');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishHeartbeat(
|
||||||
|
runtime: CompanionRuntimeClient,
|
||||||
|
platform: CompanionPlatform,
|
||||||
|
): Promise<void> {
|
||||||
|
await runtime.setNodeStatus({
|
||||||
|
platform,
|
||||||
|
statusText: 'heartbeat',
|
||||||
|
powerSource: 'unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCompanionSession(options: CompanionCommandOptions): Promise<void> {
|
||||||
|
const configPath = options.config ?? getConfigPath();
|
||||||
|
const platform: CompanionPlatform = options.platform ?? 'macos';
|
||||||
|
const gatewayUrl = resolveGatewayUrl(options, configPath);
|
||||||
|
const gatewayToken = resolveGatewayToken(options, configPath);
|
||||||
|
const role = options.role?.trim() || 'companion';
|
||||||
|
const nodeId = resolveNodeId(options, platform);
|
||||||
|
const capabilities = resolveCapabilities(platform, options.capability);
|
||||||
|
const heartbeatSeconds = parseHeartbeatSeconds(options.heartbeat);
|
||||||
|
|
||||||
|
const runtime = new CompanionRuntimeClient({
|
||||||
|
url: gatewayUrl,
|
||||||
|
token: gatewayToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopSignals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
|
||||||
|
let heartbeatTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
if (heartbeatTimer) {
|
||||||
|
clearInterval(heartbeatTimer);
|
||||||
|
heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
runtime.disconnect(1000, 'Companion shutting down');
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const signal of stopSignals) {
|
||||||
|
process.once(signal, cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.subscribeAgentStream((data) => {
|
||||||
|
const payload = data as { sessionId?: string; content?: string };
|
||||||
|
const session = payload.sessionId ? ` (${payload.sessionId})` : '';
|
||||||
|
if (payload.content) {
|
||||||
|
console.log(`[agent.stream${session}] ${payload.content}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.subscribeAgentTyping((data) => {
|
||||||
|
const payload = data as { sessionId?: string; phase?: string };
|
||||||
|
const session = payload.sessionId ? ` (${payload.sessionId})` : '';
|
||||||
|
const phase = payload.phase ?? 'typing';
|
||||||
|
console.log(`[agent.typing${session}] ${phase}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runtime.connect();
|
||||||
|
const register = await runtime.registerNode({
|
||||||
|
nodeId,
|
||||||
|
role,
|
||||||
|
capabilities,
|
||||||
|
});
|
||||||
|
|
||||||
|
await publishHeartbeat(runtime, platform);
|
||||||
|
|
||||||
|
console.log(`Connected companion node ${register.node.id} (${platform}, role=${role})`);
|
||||||
|
console.log(`Gateway: ${gatewayUrl}`);
|
||||||
|
console.log(`Capabilities: ${capabilities.join(', ') || '(none)'}`);
|
||||||
|
|
||||||
|
if (options.once) {
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
heartbeatTimer = setInterval(() => {
|
||||||
|
void publishHeartbeat(runtime, platform).catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Heartbeat failed: ${message}`);
|
||||||
|
});
|
||||||
|
}, heartbeatSeconds * 1000);
|
||||||
|
|
||||||
|
await new Promise<void>(() => {
|
||||||
|
// Keep process alive until interrupted.
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
cleanup();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCompanionCommand(program: Command): void {
|
||||||
|
program
|
||||||
|
.command('companion')
|
||||||
|
.description('Run a minimal gateway companion node client')
|
||||||
|
.option('-c, --config <path>', 'Config file path')
|
||||||
|
.option('--url <url>', 'Gateway WebSocket URL (default from config server.port)')
|
||||||
|
.option('--token <token>', 'Gateway auth token (default from config server.token)')
|
||||||
|
.option('--node-id <id>', 'Node ID to register (default: generated)')
|
||||||
|
.option('--role <role>', 'Node role', 'companion')
|
||||||
|
.option('--platform <platform>', 'Node platform (macos|ios|android|linux|windows|unknown)', 'macos')
|
||||||
|
.option('--capability <name...>', 'Capability list override')
|
||||||
|
.option('--heartbeat <seconds>', 'Heartbeat interval in seconds', '30')
|
||||||
|
.option('--once', 'Connect, register, publish one heartbeat, then exit', false)
|
||||||
|
.action(async (opts: CompanionCommandOptions) => {
|
||||||
|
try {
|
||||||
|
await runCompanionSession(opts);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Companion command failed: ${message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ describe('CLI program', () => {
|
|||||||
expect(commandNames).toContain('backup');
|
expect(commandNames).toContain('backup');
|
||||||
expect(commandNames).toContain('setup');
|
expect(commandNames).toContain('setup');
|
||||||
expect(commandNames).toContain('onboard');
|
expect(commandNames).toContain('onboard');
|
||||||
|
expect(commandNames).toContain('companion');
|
||||||
|
|
||||||
expect(commandNames).toContain('openai-auth');
|
expect(commandNames).toContain('openai-auth');
|
||||||
expect(commandNames).toContain('openai-key');
|
expect(commandNames).toContain('openai-key');
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { registerZaiAuthCommand } from './zai-auth.js';
|
|||||||
import { registerAnthropicAuthCommand } from './anthropic-auth.js';
|
import { registerAnthropicAuthCommand } from './anthropic-auth.js';
|
||||||
import { registerSkillsCommand } from './skills.js';
|
import { registerSkillsCommand } from './skills.js';
|
||||||
import { registerBackupCommand } from './backup.js';
|
import { registerBackupCommand } from './backup.js';
|
||||||
|
import { registerCompanionCommand } from './companion.js';
|
||||||
|
|
||||||
export function createProgram(): Command {
|
export function createProgram(): Command {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -58,6 +59,7 @@ export function createProgram(): Command {
|
|||||||
registerAnthropicAuthCommand(program);
|
registerAnthropicAuthCommand(program);
|
||||||
registerSkillsCommand(program);
|
registerSkillsCommand(program);
|
||||||
registerBackupCommand(program);
|
registerBackupCommand(program);
|
||||||
|
registerCompanionCommand(program);
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user