Skip to content
Open
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
60 changes: 51 additions & 9 deletions cli/src/claude/utils/claudeSettings.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
/**
* Tests for Claude settings reading functionality
*
* Tests reading Claude's settings.json file and respecting the includeCoAuthoredBy setting
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync, writeFileSync, unlinkSync, mkdirSync, rmSync } from 'node:fs';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { existsSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { readClaudeSettings, shouldIncludeCoAuthoredBy } from './claudeSettings';
import { readClaudeSettings, shouldIncludeCoAuthoredBy, shouldEnableAutoTitle } from './claudeSettings';

describe('Claude Settings', () => {
let testClaudeDir: string;
Expand Down Expand Up @@ -92,4 +86,52 @@ describe('Claude Settings', () => {
expect(result).toBe(true);
});
});
});

describe('shouldEnableAutoTitle', () => {
let testHapiDir: string;
let originalHapiHome: string | undefined;

beforeEach(() => {
testHapiDir = join(tmpdir(), `test-hapi-${Date.now()}`);
mkdirSync(testHapiDir, { recursive: true });
originalHapiHome = process.env.HAPI_HOME;
process.env.HAPI_HOME = testHapiDir;

vi.resetModules();
});

afterEach(() => {
if (originalHapiHome !== undefined) {
process.env.HAPI_HOME = originalHapiHome;
} else {
delete process.env.HAPI_HOME;
}
if (existsSync(testHapiDir)) {
rmSync(testHapiDir, { recursive: true, force: true });
}
});

it('returns true when no settings file exists (default)', async () => {
const { shouldEnableAutoTitle } = await import('./claudeSettings');
expect(shouldEnableAutoTitle()).toBe(true);
});

it('returns true when enableAutoTitle is not set', async () => {
writeFileSync(join(testHapiDir, 'settings.json'), JSON.stringify({ machineId: 'test' }));
const { shouldEnableAutoTitle } = await import('./claudeSettings');
expect(shouldEnableAutoTitle()).toBe(true);
});

it('returns false when enableAutoTitle is explicitly false', async () => {
writeFileSync(join(testHapiDir, 'settings.json'), JSON.stringify({ enableAutoTitle: false }));
const { shouldEnableAutoTitle } = await import('./claudeSettings');
expect(shouldEnableAutoTitle()).toBe(false);
});

it('returns true when enableAutoTitle is explicitly true', async () => {
writeFileSync(join(testHapiDir, 'settings.json'), JSON.stringify({ enableAutoTitle: true }));
const { shouldEnableAutoTitle } = await import('./claudeSettings');
expect(shouldEnableAutoTitle()).toBe(true);
});
});
13 changes: 11 additions & 2 deletions cli/src/claude/utils/claudeSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { logger } from '@/ui/logger';
import { readSettingsSync } from '@/persistence';

export interface ClaudeSettings {
includeCoAuthoredBy?: boolean;
Expand Down Expand Up @@ -58,12 +59,20 @@ export function readClaudeSettings(): ClaudeSettings | null {
*/
export function shouldIncludeCoAuthoredBy(): boolean {
const settings = readClaudeSettings();

// If no settings file or includeCoAuthoredBy is not explicitly set,
// default to true to maintain backward compatibility
if (!settings || settings.includeCoAuthoredBy === undefined) {
return true;
}

return settings.includeCoAuthoredBy;
}

export function shouldEnableAutoTitle(): boolean {
const settings = readSettingsSync();
if (settings.enableAutoTitle === undefined) {
return true;
}
return settings.enableAutoTitle;
}
76 changes: 41 additions & 35 deletions cli/src/claude/utils/startHappyServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { z } from "zod";
import { logger } from "@/ui/logger";
import { ApiSessionClient } from "@/api/apiSession";
import { randomUUID } from "node:crypto";
import { shouldEnableAutoTitle } from "./claudeSettings";

export async function startHappyServer(client: ApiSessionClient) {
// Handler that sends title updates via the client
Expand Down Expand Up @@ -39,41 +40,46 @@ export async function startHappyServer(client: ApiSessionClient) {
version: "1.0.0",
});

// Avoid TS instantiation depth issues by widening the schema type.
const changeTitleInputSchema: z.ZodTypeAny = z.object({
title: z.string().describe('The new title for the chat session'),
});
const toolNames: string[] = [];

mcp.registerTool<any, any>('change_title', {
description: 'Change the title of the current chat session',
title: 'Change Chat Title',
inputSchema: changeTitleInputSchema,
}, async (args: { title: string }) => {
const response = await handler(args.title);
logger.debug('[hapiMCP] Response:', response);

if (response.success) {
return {
content: [
{
type: 'text' as const,
text: `Successfully changed chat title to: "${args.title}"`,
},
],
isError: false,
};
} else {
return {
content: [
{
type: 'text' as const,
text: `Failed to change chat title: ${response.error || 'Unknown error'}`,
},
],
isError: true,
};
}
});
if (shouldEnableAutoTitle()) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] enableAutoTitle=false only removes the tool from the HTTP MCP server here. Codex and OpenCode still go through buildHapiMcpBridge(...), and cli/src/codex/happyMcpStdioBridge.ts:72 still unconditionally re-registers change_title, so those modes can still see and call the title tool.

Suggested fix:

const client = await ensureHttpClient();
const { tools } = await client.listTools();

if (tools.some((tool) => tool.name === 'change_title')) {
    server.registerTool<any, any>('change_title', {
        description: 'Change the title of the current chat session',
        title: 'Change Chat Title',
        inputSchema: changeTitleInputSchema,
    }, async (args: Record<string, unknown>) => {
        return client.callTool({ name: 'change_title', arguments: args });
    });
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in next commit

const changeTitleInputSchema: z.ZodTypeAny = z.object({
title: z.string().describe('The new title for the chat session'),
});

mcp.registerTool<any, any>('change_title', {
description: 'Change the title of the current chat session',
title: 'Change Chat Title',
inputSchema: changeTitleInputSchema,
}, async (args: { title: string }) => {
const response = await handler(args.title);
logger.debug('[hapiMCP] Response:', response);

if (response.success) {
return {
content: [
{
type: 'text' as const,
text: `Successfully changed chat title to: "${args.title}"`,
},
],
isError: false,
};
} else {
return {
content: [
{
type: 'text' as const,
text: `Failed to change chat title: ${response.error || 'Unknown error'}`,
},
],
isError: true,
};
}
});

toolNames.push('change_title');
}

const transport = new StreamableHTTPServerTransport({
// NOTE: Returning session id here will result in claude
Expand Down Expand Up @@ -106,7 +112,7 @@ export async function startHappyServer(client: ApiSessionClient) {

return {
url: baseUrl.toString(),
toolNames: ['change_title'],
toolNames,
stop: () => {
logger.debug('[hapiMCP] Stopping server');
mcp.close();
Expand Down
27 changes: 9 additions & 18 deletions cli/src/claude/utils/systemPrompt.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import { trimIdent } from "@/utils/trimIdent";
import { shouldIncludeCoAuthoredBy } from "./claudeSettings";
import { shouldEnableAutoTitle, shouldIncludeCoAuthoredBy } from "./claudeSettings";

/**
* Base system prompt shared across all configurations
*/
const BASE_SYSTEM_PROMPT = (() => trimIdent(`
const TITLE_PROMPT = (() => trimIdent(`
ALWAYS when you start a new chat - you must call a tool "mcp__hapi__change_title" to set a chat title. When you think chat title is not relevant anymore - call the tool again to change it. When chat name is too generic and you have a change to make it more specific - call the tool again to change it. This title is needed to easily find the chat in the future. Help human.
`))();

/**
* Co-authored-by credits to append when enabled
*/
const CO_AUTHORED_CREDITS = (() => trimIdent(`
When making commit messages, you SHOULD also give credit to HAPI like so:

Expand All @@ -21,16 +15,13 @@ const CO_AUTHORED_CREDITS = (() => trimIdent(`
Co-Authored-By: HAPI <noreply@hapi.run>
`))();

/**
* System prompt with conditional Co-Authored-By lines based on Claude's settings.json configuration.
* Settings are read once on startup for performance.
*/
export const systemPrompt = (() => {
const includeCoAuthored = shouldIncludeCoAuthoredBy();

if (includeCoAuthored) {
return BASE_SYSTEM_PROMPT + '\n\n' + CO_AUTHORED_CREDITS;
} else {
return BASE_SYSTEM_PROMPT;
const parts: string[] = [];
if (shouldEnableAutoTitle()) {
parts.push(TITLE_PROMPT);
}
if (shouldIncludeCoAuthoredBy()) {
parts.push(CO_AUTHORED_CREDITS);
}
return parts.join('\n\n');
})();
53 changes: 27 additions & 26 deletions cli/src/codex/happyMcpStdioBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { z } from 'zod';
import { shouldEnableAutoTitle } from '@/claude/utils/claudeSettings';

function parseArgs(argv: string[]): { url: string | null } {
let url: string | null = null;
Expand Down Expand Up @@ -64,34 +65,34 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
version: '1.0.0',
});

// Register the single tool and forward to HTTP MCP
const changeTitleInputSchema: z.ZodTypeAny = z.object({
title: z.string().describe('The new title for the chat session'),
});
if (shouldEnableAutoTitle()) {
const changeTitleInputSchema: z.ZodTypeAny = z.object({
title: z.string().describe('The new title for the chat session'),
});

server.registerTool<any, any>(
'change_title',
{
description: 'Change the title of the current chat session',
title: 'Change Chat Title',
inputSchema: changeTitleInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
const response = await client.callTool({ name: 'change_title', arguments: args });
// Pass-through response from HTTP server
return response as any;
} catch (error) {
return {
content: [
{ type: 'text' as const, text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` },
],
isError: true,
};
server.registerTool<any, any>(
'change_title',
{
description: 'Change the title of the current chat session',
title: 'Change Chat Title',
inputSchema: changeTitleInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
const response = await client.callTool({ name: 'change_title', arguments: args });
return response as any;
} catch (error) {
return {
content: [
{ type: 'text' as const, text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` },
],
isError: true,
};
}
}
}
);
);
}

// Start STDIO transport
const stdio = new StdioServerTransport();
Expand Down
22 changes: 5 additions & 17 deletions cli/src/codex/utils/systemPrompt.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,13 @@
/**
* Codex-specific system prompt for local mode.
*
* This prompt instructs Codex to call the hapi__change_title function
* to set appropriate chat session titles.
*/

import { trimIdent } from '@/utils/trimIdent';
import { shouldEnableAutoTitle } from '@/claude/utils/claudeSettings';

/**
* Title instruction for Codex to call the hapi MCP tool.
* Note: Codex exposes MCP tools under the `functions.` namespace,
* so the tool is called as `functions.hapi__change_title`.
*/
export const TITLE_INSTRUCTION = trimIdent(`
export const TITLE_INSTRUCTION = shouldEnableAutoTitle()
? trimIdent(`
ALWAYS when you start a new chat, call the title tool to set a concise task title.
Prefer calling functions.hapi__change_title.
If that exact tool name is unavailable, call an equivalent alias such as hapi__change_title, mcp__hapi__change_title, or hapi_change_title.
If the task focus changes significantly later, call the title tool again with a better title.
`);
`)
: '';

/**
* The system prompt to inject via developer_instructions in local mode.
*/
export const codexSystemPrompt = TITLE_INSTRUCTION;
20 changes: 5 additions & 15 deletions cli/src/opencode/utils/systemPrompt.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
/**
* OpenCode-specific system prompt for change_title tool.
*
* OpenCode exposes MCP tools with the naming pattern: <server-name>_<tool-name>
* The hapi MCP server exposes `change_title`, so it's called as `hapi_change_title`.
*/

import { trimIdent } from '@/utils/trimIdent';
import { shouldEnableAutoTitle } from '@/claude/utils/claudeSettings';

/**
* Title instruction for OpenCode to call the hapi MCP tool.
*/
export const TITLE_INSTRUCTION = trimIdent(`
export const TITLE_INSTRUCTION = shouldEnableAutoTitle()
? trimIdent(`
ALWAYS when you start a new chat - you must call the tool "hapi_change_title" to set a chat title. When you think chat title is not relevant anymore - call the tool again to change it. When chat name is too generic and you have a chance to make it more specific - call the tool again to change it. This title is needed to easily find the chat in the future. Help human.
`);
`)
: '';

/**
* The system prompt to inject for OpenCode sessions.
*/
export const opencodeSystemPrompt = TITLE_INSTRUCTION;
14 changes: 14 additions & 0 deletions cli/src/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface Settings {
apiUrl?: string
// Legacy field name (for migration, read-only)
serverUrl?: string
enableAutoTitle?: boolean
}

const defaultSettings: Settings = {}
Expand Down Expand Up @@ -55,6 +56,19 @@ export async function readSettings(): Promise<Settings> {
}
}

export function readSettingsSync(): Settings {
if (!existsSync(configuration.settingsFile)) {
return { ...defaultSettings }
}

try {
const content = readFileSync(configuration.settingsFile, 'utf-8')
return JSON.parse(content)
} catch {
return { ...defaultSettings }
}
}

export async function writeSettings(settings: Settings): Promise<void> {
if (!existsSync(configuration.happyHomeDir)) {
await mkdir(configuration.happyHomeDir, { recursive: true })
Expand Down
Loading
Loading