Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 23 additions & 13 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,28 @@ async function refreshToken(
};
}

async function validAccessToken(
creds: NonNullable<Awaited<ReturnType<typeof readCredentials>>>,
): Promise<string> {
if (isAgentCredential(creds)) return creds.accessToken;
const exp = decodeJwtExp(creds.accessToken);
if (exp === 0 || Date.now() / 1000 <= exp - 60) return creds.accessToken;
try {
const refreshed = await refreshToken(creds.refreshToken);
await saveCredentials(refreshed);
return refreshed.accessToken;
} catch {
throw new AuthError('Session expired. Run: hookmyapp login');
}
}

/** Return the same fresh Bearer token used by normal CLI API requests. */
export async function getValidAccessToken(): Promise<string> {
const creds = await readCredentials();
if (!creds) throw new AuthError('Not logged in. Run: hookmyapp login');
return validAccessToken(creds);
}

export async function forceTokenRefresh(): Promise<void> {
const creds = await readCredentials();
if (!creds) {
Expand Down Expand Up @@ -288,19 +310,7 @@ export async function apiClient(
}
})();

let { accessToken } = creds;

// Check if token is expired (with 60-second buffer)
const exp = decodeJwtExp(accessToken);
if (exp > 0 && Date.now() / 1000 > exp - 60) {
try {
const refreshed = await refreshToken(creds.refreshToken);
await saveCredentials(refreshed);
accessToken = refreshed.accessToken;
} catch {
throw new AuthError('Session expired. Run: hookmyapp login');
}
}
const accessToken = await validAccessToken(creds);

const baseUrl = getEffectiveApiUrl();

Expand Down
4 changes: 4 additions & 0 deletions src/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '../config/env-profiles.js';
import { posthogAliasAndIdentify } from '../observability/posthog.js';
import { parseSandboxSessions, type WhatsAppSandboxSession } from '../api/sandbox-session.js';
import { maybeInstallClaudeMcp } from '../commands/mcp.js';

// --- Phase 122 bootstrap-code exchange DTO ---
// Mirrors backend/src/auth/bootstrap/dto/exchange-bootstrap.dto.ts (Wave 1
Expand Down Expand Up @@ -90,6 +91,7 @@ async function pollForTokens(opts: {
email: u.email,
name: fullName.length > 0 ? fullName : undefined,
});
maybeInstallClaudeMcp();
Comment thread
ord669 marked this conversation as resolved.
console.log(`\n${c.success(icon.success)} Logged in successfully\n`);
return;
}
Expand Down Expand Up @@ -443,6 +445,7 @@ export async function runBootstrapCodeExchange(
activeWorkspaceId: data.workspace.id,
activeWorkspaceSlug: data.workspace.name,
});
maybeInstallClaudeMcp();

// Phase 125 — alias machineId → workosSub once per (machine, user) and
// emit cli_logged_in. workspace publicId is already on disk above so
Expand Down Expand Up @@ -582,6 +585,7 @@ async function persistAgentCredential(
scopes: cred.scopes,
});
await revalidateActiveWorkspace(json);
maybeInstallClaudeMcp();
if (json) {
process.stdout.write(
JSON.stringify({
Expand Down
2 changes: 2 additions & 0 deletions src/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from 'commander';
import { readCredentials, deleteCredentials } from './store.js';
import { isAgentCredential } from '../storage/secrets.js';
import { addExamples } from '../output/help.js';
import { removeClaudeMcp } from '../commands/mcp.js';

export function logoutCommand(program: Command): void {
const logout = program
Expand Down Expand Up @@ -30,6 +31,7 @@ export function logoutCommand(program: Command): void {
}

await deleteCredentials();
removeClaudeMcp();
Comment thread
ord669 marked this conversation as resolved.

if (json) {
process.stdout.write(
Expand Down
79 changes: 79 additions & 0 deletions src/commands/__tests__/mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { spawnSync } from 'node:child_process';
import { getValidAccessToken } from '../../api/client.js';

vi.mock('node:child_process', () => ({ spawnSync: vi.fn() }));
vi.mock('../../api/client.js', () => ({ getValidAccessToken: vi.fn() }));
vi.mock('../../config/env-profiles.js', () => ({
getEffectiveApiUrl: () => 'https://api.hookmyapp.com',
}));

import { installClaudeMcp, maybeInstallClaudeMcp, printMcpHeaders, removeClaudeMcp } from '../mcp.js';

describe('MCP setup', () => {
beforeEach(() => vi.clearAllMocks());

test('prints only the dynamic Authorization header JSON', async () => {
vi.mocked(getValidAccessToken).mockResolvedValue('hmok_test');
const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

await printMcpHeaders();

expect(write).toHaveBeenCalledOnce();
expect(write).toHaveBeenCalledWith('{"Authorization":"Bearer hmok_test"}\n');
});

test('installs a user-scoped Claude headersHelper without storing a token', () => {
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as never);

installClaudeMcp();

const [, args] = vi.mocked(spawnSync).mock.calls[0];
expect(args).toEqual([
'mcp',
'add-json',
'--scope',
'user',
'hookmyapp',
JSON.stringify({
type: 'http',
url: 'https://api.hookmyapp.com/mcp',
headersHelper: 'hookmyapp mcp-headers',
}),
]);
expect(JSON.stringify(args)).not.toContain('Bearer');
});

test('skips automatic setup when Claude Code is absent', () => {
vi.mocked(spawnSync).mockReturnValueOnce({
status: null,
error: Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
} as never);

maybeInstallClaudeMcp(true);

expect(spawnSync).toHaveBeenCalledOnce();
});

test('replaces an existing Claude entry', () => {
vi.mocked(spawnSync)
.mockReturnValueOnce({ status: 1, stderr: 'already exists' } as never)
.mockReturnValueOnce({ status: 0 } as never)
.mockReturnValueOnce({ status: 0 } as never);

installClaudeMcp();

expect(spawnSync).toHaveBeenCalledTimes(3);
expect(vi.mocked(spawnSync).mock.calls[1][1]).toEqual(['mcp', 'remove', '--scope', 'user', 'hookmyapp']);
});

test('removes only the user-scoped HookMyApp entry', () => {
vi.mocked(spawnSync).mockReturnValue({ status: 0 } as never);

removeClaudeMcp(true);

expect(spawnSync).toHaveBeenCalledWith('claude', ['mcp', 'remove', '--scope', 'user', 'hookmyapp'], {
encoding: 'utf8',
});
});
});
3 changes: 3 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { isJsonMode } from '../output/format.js';
import { getEffectiveApiUrl } from '../config/env-profiles.js';
import { readWorkspaceConfig } from './workspace.js';
import { addExamples } from '../output/help.js';
import { getClaudeMcpStatus } from './mcp.js';

// `hard` checks gate the prereq (a FAIL → non-zero exit). Informational checks
// (auth/workspace/default-channel) are reported, never a crash or exit failure.
Expand Down Expand Up @@ -37,6 +38,8 @@ export async function collectDoctorReport(
checks.push({ id: 'npm', label: 'npm', ok: npm !== null, hard: true, detail: npm ?? 'not found on PATH' });
const npx = toolVersion('npx');
checks.push({ id: 'npx', label: 'npx', ok: npx !== null, hard: true, detail: npx ?? 'not found on PATH' });
const mcp = getClaudeMcpStatus();
checks.push({ id: 'mcp', label: 'HookMyApp MCP (Claude)', ok: mcp.ok, hard: false, detail: mcp.detail });
Comment thread
ord669 marked this conversation as resolved.
}

if (opts.checkNetwork !== false) {
Expand Down
97 changes: 97 additions & 0 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { spawnSync } from 'node:child_process';
import type { Command } from 'commander';
import { getEffectiveApiUrl } from '../config/env-profiles.js';
import { ConfigurationError } from '../output/error.js';
import { addExamples } from '../output/help.js';

const MCP_NAME = 'hookmyapp';

function mcpUrl(): string {
return `${getEffectiveApiUrl().replace(/\/$/, '')}/mcp`;
}

export async function printMcpHeaders(): Promise<void> {
const { getValidAccessToken } = await import('../api/client.js');
const token = await getValidAccessToken();
process.stdout.write(JSON.stringify({ Authorization: `Bearer ${token}` }) + '\n');
}

export function installClaudeMcp(): void {
const config = JSON.stringify({
type: 'http',
url: mcpUrl(),
headersHelper: 'hookmyapp mcp-headers',
Comment thread
ord669 marked this conversation as resolved.
});
const args = ['mcp', 'add-json', '--scope', 'user', MCP_NAME, config];
let result = spawnSync('claude', args, { encoding: 'utf8' });
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
if (result.status !== 0 && output.includes('already exists')) {
removeClaudeMcp(true);
result = spawnSync('claude', args, { encoding: 'utf8' });
Comment thread
ord669 marked this conversation as resolved.
}
if (result.error || result.status !== 0) {
throw new ConfigurationError(
result.error?.message || result.stderr.trim() || 'Claude Code MCP setup failed',
'MCP_INSTALL_FAILED',
);
}
}

export function maybeInstallClaudeMcp(force = false): void {
if (!force && process.env.NODE_ENV === 'test') return;
const probe = spawnSync('claude', ['--version'], { encoding: 'utf8' });
if (probe.error || probe.status !== 0) return;
try {
installClaudeMcp();
} catch (err) {
process.stderr.write(
`HookMyApp login succeeded, but Claude MCP setup failed: ${(err as Error).message}\n` +
'Run: hookmyapp mcp install --agent claude\n',
);
}
}

export function removeClaudeMcp(force = false): void {
if (!force && process.env.NODE_ENV === 'test') return;
spawnSync('claude', ['mcp', 'remove', '--scope', 'user', MCP_NAME], {
encoding: 'utf8',
});
}

export function getClaudeMcpStatus(): { ok: boolean; detail: string } {
const result = spawnSync('claude', ['mcp', 'get', MCP_NAME], {
encoding: 'utf8',
});
if (result.error?.message.includes('ENOENT')) {
return { ok: false, detail: 'Claude Code not found' };
}
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
return result.status === 0 && output.includes('Connected')
? { ok: true, detail: 'connected' }
: {
ok: false,
detail: 'not connected — run: hookmyapp mcp install --agent claude',
};
}

export function registerMcpCommand(program: Command): void {
const headers = program.command('mcp-headers', { hidden: true }).action(printMcpHeaders);
addExamples(headers, '\nEXAMPLES:\n $ hookmyapp mcp-headers\n $ hookmyapp --env staging mcp-headers');

const mcp = program.command('mcp').description('Configure HookMyApp MCP access');
addExamples(mcp, '\nEXAMPLES:\n $ hookmyapp mcp install --agent claude\n $ hookmyapp doctor');
const install = mcp
.command('install')
.requiredOption('--agent <agent>', 'Agent to configure (claude)')
.action((opts: { agent: string }) => {
if (opts.agent !== 'claude') {
throw new ConfigurationError(`Unsupported agent "${opts.agent}". Supported: claude`, 'MCP_AGENT_UNSUPPORTED');
}
installClaudeMcp();
process.stdout.write('HookMyApp MCP configured for Claude Code.\n');
Comment thread
ord669 marked this conversation as resolved.
});
addExamples(
install,
'\nEXAMPLES:\n $ hookmyapp mcp install --agent claude\n $ hookmyapp --env staging mcp install --agent claude',
);
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { registerCustomersCommand } from './commands/customers.js';
import { registerSandboxCommand } from './commands/sandbox/index.js';
import { registerListenCommand } from './commands/sandbox-listen/index.js';
import { registerConfigCommand } from './commands/config.js';
import { registerMcpCommand } from './commands/mcp.js';
import {
CliError,
UnexpectedError,
Expand Down Expand Up @@ -162,6 +163,7 @@ program.configureOutput({
loginCommand(program);
logoutCommand(program);
registerCredentialsCommand(program);
registerMcpCommand(program);

// Channel management
registerChannelsCommand(program);
Expand Down
Loading