mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(claude): separate skills from slash commands (#1293)
* fix(claude): separate skills from slash commands * fix(claude): make native skills ready before use * fix(claude): keep skill commands before attachments * fix(claude): align skill catalog with launch args * fix(claude): include launch plugin skills * fix(claude): use native loaded skill list * refactor(claude): remove obsolete metadata callback * fix(claude): wait for skill catalog before draining messages * fix(claude): cancel deferred startup messages * fix(claude): bound skill catalog startup probe
This commit is contained in:
@@ -105,7 +105,7 @@ export async function claudeLocal(opts: {
|
||||
// Prepare environment variables
|
||||
// Note: Local mode uses global Claude installation
|
||||
//
|
||||
// SDK metadata extraction (extractSDKMetadataAsync → query()) sets
|
||||
// SDK metadata extraction (extractSDKMetadata → query()) sets
|
||||
// CLAUDE_CODE_ENTRYPOINT='sdk-ts' on the current process. If leaked
|
||||
// into the local spawn, Claude Code thinks it was SDK-launched and
|
||||
// excludes the session from `claude --resume`. Destructure it out
|
||||
|
||||
@@ -11,6 +11,7 @@ import { systemPrompt } from "./utils/systemPrompt";
|
||||
import { PermissionResult } from "./sdk/types";
|
||||
import { getHapiBlobsDir } from "@/constants/uploadPaths";
|
||||
import { getDefaultClaudeCodePath } from "./sdk/utils";
|
||||
import { filterCatalogAffectingClaudeArgs } from "./sdk/metadataExtractor";
|
||||
|
||||
export async function claudeRemote(opts: {
|
||||
|
||||
@@ -129,6 +130,7 @@ export async function claudeRemote(opts: {
|
||||
// Prepare SDK options
|
||||
let mode = initial.mode;
|
||||
const sdkOptions: Options = {
|
||||
additionalArgs: filterCatalogAffectingClaudeArgs(opts.claudeArgs),
|
||||
cwd: opts.path,
|
||||
resume: startFrom ?? undefined,
|
||||
mcpServers: opts.mcpServers,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { EnhancedMode } from './loop'
|
||||
const harness = vi.hoisted(() => ({
|
||||
callCount: 0,
|
||||
claudeArgsPerCall: [] as (string[] | undefined)[],
|
||||
initialMessages: [] as string[],
|
||||
triggerSwitch: null as (() => void) | null,
|
||||
switchAfterCall: 2
|
||||
}))
|
||||
@@ -30,6 +31,7 @@ vi.mock('./claudeRemote', () => ({
|
||||
// never fires and the --resume flag is never actually used.
|
||||
return
|
||||
}
|
||||
harness.initialMessages.push(initial.message)
|
||||
|
||||
// Mirrors claudeRemote()'s /clear contract: it reports the context as
|
||||
// discarded and returns before spawning Claude, so onSessionFound
|
||||
@@ -139,6 +141,7 @@ describe('claudeRemoteLauncher resume anchor', () => {
|
||||
afterEach(() => {
|
||||
harness.callCount = 0
|
||||
harness.claudeArgsPerCall = []
|
||||
harness.initialMessages = []
|
||||
harness.triggerSwitch = null
|
||||
harness.switchAfterCall = 2
|
||||
vi.clearAllMocks()
|
||||
@@ -249,4 +252,52 @@ describe('claudeRemoteLauncher resume anchor', () => {
|
||||
session.stopKeepAlive()
|
||||
}
|
||||
})
|
||||
|
||||
it('sends an advertised $skill through Claude native slash invocation', async () => {
|
||||
const client = createClientStub()
|
||||
const { session, queue } = createSession(client, undefined)
|
||||
|
||||
try {
|
||||
session.setNativeSkillNames(['hapi'])
|
||||
expect(session.expandSkillReference('$unknown inspect')).toBe('$unknown inspect')
|
||||
expect(session.expandSkillReference('ask $hapi')).toBe('ask $hapi')
|
||||
queue.push('$hapi inspect', { permissionMode: 'default' }, 'local-1')
|
||||
harness.switchAfterCall = 1
|
||||
harness.triggerSwitch = () => {
|
||||
client.rpcHandlers.get(RPC_METHODS.Switch)?.()
|
||||
}
|
||||
|
||||
await claudeRemoteLauncher(session as any)
|
||||
|
||||
expect(harness.initialMessages).toEqual(['/hapi inspect'])
|
||||
} finally {
|
||||
session.stopKeepAlive()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an advertised $skill first when attachments are present', async () => {
|
||||
const client = createClientStub()
|
||||
const { session, queue } = createSession(client, undefined)
|
||||
|
||||
try {
|
||||
session.setNativeSkillNames(['hapi'])
|
||||
const prompt = session.expandSkillReference(
|
||||
'$hapi inspect',
|
||||
'@C:\\Users\\Jane Doe\\input.txt'
|
||||
)
|
||||
queue.push(prompt, { permissionMode: 'default' }, 'local-1')
|
||||
harness.switchAfterCall = 1
|
||||
harness.triggerSwitch = () => {
|
||||
client.rpcHandlers.get(RPC_METHODS.Switch)?.()
|
||||
}
|
||||
|
||||
await claudeRemoteLauncher(session as any)
|
||||
|
||||
expect(harness.initialMessages).toEqual([
|
||||
'/hapi inspect\n\n@C:\\Users\\Jane Doe\\input.txt'
|
||||
])
|
||||
} finally {
|
||||
session.stopKeepAlive()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -418,7 +418,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
|
||||
sdkToLogConverter.updateSelectedModel(p.mode.model ?? null);
|
||||
inFlightMessage = { items: p.items, mode: p.mode, isolate: p.isolate };
|
||||
deliveredMessageThisAttempt = true;
|
||||
return p;
|
||||
return { ...p, message: session.expandSkillReference(p.message) };
|
||||
}
|
||||
|
||||
let msg = await session.queue.waitForMessagesAndGetAsString(controller.signal);
|
||||
@@ -449,7 +449,7 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
|
||||
inFlightMessage = { items: msg.items, mode: msg.mode, isolate: msg.isolate };
|
||||
deliveredMessageThisAttempt = true;
|
||||
return {
|
||||
message: msg.message,
|
||||
message: session.expandSkillReference(msg.message),
|
||||
mode: msg.mode
|
||||
};
|
||||
}
|
||||
|
||||
+100
-21
@@ -4,7 +4,7 @@ import { AgentState, SessionEffort, SessionModel } from '@/api/types';
|
||||
import { EnhancedMode, PermissionMode } from './loop';
|
||||
import { MessageQueue2 } from '@/utils/MessageQueue2';
|
||||
import { hashObject } from '@/utils/deterministicJson';
|
||||
import { extractSDKMetadataAsync } from '@/claude/sdk/metadataExtractor';
|
||||
import { classifyClaudeSlashCatalog, extractSDKMetadata } from '@/claude/sdk/metadataExtractor';
|
||||
import { parseSpecialCommand } from '@/parsers/specialCommands';
|
||||
import { getEnvironmentInfo } from '@/ui/doctor';
|
||||
import { startHappyServer, toClaudeAllowedHapiMcpTools } from '@/claude/utils/startHappyServer';
|
||||
@@ -18,11 +18,12 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f
|
||||
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
|
||||
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
|
||||
import { PermissionModeSchema } from '@hapi/protocol/schemas';
|
||||
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { formatAttachmentsForClaude, formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { normalizeClaudeSessionModel } from './model';
|
||||
import { normalizeClaudeSessionEffort } from './effort';
|
||||
import { normalizeHookPermissionMode } from './utils/hookPermissionMode';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
import { listSkills, type SkillSummary } from '@/modules/common/skills';
|
||||
|
||||
export interface StartOptions {
|
||||
model?: string
|
||||
@@ -75,29 +76,75 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
const { api, session, sessionInfo } = bootstrap;
|
||||
logger.debug(`Session created: ${sessionInfo.id}`);
|
||||
|
||||
// Extract SDK metadata in background and update session when ready
|
||||
extractSDKMetadataAsync(async (sdkMetadata) => {
|
||||
logger.debug('[start] SDK metadata extracted, updating session:', sdkMetadata);
|
||||
try {
|
||||
// Update session metadata with tools and slash commands
|
||||
session.updateMetadata((currentMetadata) => ({
|
||||
...currentMetadata,
|
||||
tools: sdkMetadata.tools,
|
||||
slashCommands: sdkMetadata.slashCommands
|
||||
}));
|
||||
logger.debug('[start] Session metadata updated with SDK capabilities');
|
||||
} catch (error) {
|
||||
logger.debug('[start] Failed to update session metadata:', error);
|
||||
const currentSessionRef: { current: Session | null } = { current: null };
|
||||
let resolveSessionReady!: (session: Session) => void;
|
||||
const sessionReady = new Promise<Session>((resolve) => {
|
||||
resolveSessionReady = resolve;
|
||||
});
|
||||
let nativeSkills: SkillSummary[] | null = null;
|
||||
|
||||
const loadCatalog = async () => {
|
||||
const [sdkMetadata, discoveredSkills] = await Promise.all([
|
||||
extractSDKMetadata({ cwd: workingDirectory, claudeArgs: options.claudeArgs }),
|
||||
listSkills(workingDirectory, { flavor: 'claude' })
|
||||
]);
|
||||
return {
|
||||
sdkMetadata,
|
||||
catalog: classifyClaudeSlashCatalog(
|
||||
sdkMetadata.slashCommands,
|
||||
discoveredSkills,
|
||||
sdkMetadata.skills
|
||||
)
|
||||
};
|
||||
};
|
||||
let catalogPromise: ReturnType<typeof loadCatalog> | null = null;
|
||||
const getCatalog = (): ReturnType<typeof loadCatalog> => {
|
||||
if (!catalogPromise) {
|
||||
catalogPromise = loadCatalog().then((result) => {
|
||||
const { sdkMetadata, catalog } = result;
|
||||
logger.debug('[start] SDK metadata extracted, updating session:', sdkMetadata);
|
||||
if (sdkMetadata.slashCommands === undefined) {
|
||||
catalogPromise = null;
|
||||
if (sdkMetadata.tools !== undefined) {
|
||||
session.updateMetadata((currentMetadata) => ({
|
||||
...currentMetadata,
|
||||
tools: sdkMetadata.tools
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
nativeSkills = catalog.skills;
|
||||
currentSessionRef.current?.setNativeSkillNames(catalog.skills.map((skill) => skill.name));
|
||||
session.updateMetadata((currentMetadata) => ({
|
||||
...currentMetadata,
|
||||
tools: sdkMetadata.tools,
|
||||
slashCommands: catalog.commands
|
||||
}));
|
||||
logger.debug('[start] Session metadata updated with SDK capabilities');
|
||||
return result;
|
||||
}).catch((error) => {
|
||||
catalogPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return catalogPromise;
|
||||
};
|
||||
session.rpcHandlerManager.registerHandler(RPC_METHODS.ListSkills, async () => {
|
||||
const result = await getCatalog();
|
||||
return result.sdkMetadata.slashCommands === undefined
|
||||
? { success: false, error: 'Claude skill catalog unavailable' }
|
||||
: { success: true, skills: result.catalog.skills };
|
||||
});
|
||||
|
||||
// Extract SDK metadata in background and update session when ready
|
||||
void getCatalog().catch((error) => {
|
||||
logger.debug('[start] Failed to update session metadata:', error);
|
||||
});
|
||||
|
||||
// Start HAPI MCP server
|
||||
const happyServer = await startHappyServer(session);
|
||||
logger.debug(`[START] HAPI MCP server started at ${happyServer.url}`);
|
||||
|
||||
// Variable to track current session instance (updated via onSessionReady callback)
|
||||
const currentSessionRef: { current: Session | null } = { current: null };
|
||||
|
||||
const formatFailureReason = (message: string): string => {
|
||||
const maxLength = 200;
|
||||
if (message.length <= maxLength) {
|
||||
@@ -221,7 +268,11 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
sessionInstance.setEffort(currentEffort);
|
||||
logger.debug(`[loop] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}, effort=${currentEffort ?? 'auto'}`);
|
||||
};
|
||||
session.onUserMessage((message, localId) => {
|
||||
type UserMessageHandler = Parameters<typeof session.onUserMessage>[0];
|
||||
type UserMessageArgs = Parameters<UserMessageHandler>;
|
||||
const deferredMessages: UserMessageArgs[] = [];
|
||||
let messagePipelineReady = false;
|
||||
const handleUserMessage: UserMessageHandler = (message, localId) => {
|
||||
const sessionPermissionMode = currentSessionRef.current?.getPermissionMode();
|
||||
if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'claude')) {
|
||||
currentPermissionMode = sessionPermissionMode as PermissionMode;
|
||||
@@ -292,8 +343,14 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
// Check for special commands before processing
|
||||
const specialCommand = parseSpecialCommand(message.content.text);
|
||||
|
||||
// Format message text with attachments for Claude
|
||||
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
|
||||
// Native slash skills must stay at the start of the prompt. Regular
|
||||
// messages keep the existing attachment-first format.
|
||||
const attachmentText = formatAttachmentsForClaude(message.content.attachments);
|
||||
const expandedText = currentSessionRef.current?.expandSkillReference(message.content.text, attachmentText)
|
||||
?? message.content.text;
|
||||
const formattedText = expandedText !== message.content.text
|
||||
? expandedText
|
||||
: formatMessageWithAttachments(message.content.text, message.content.attachments);
|
||||
|
||||
if (specialCommand.type === 'compact') {
|
||||
logger.debug('[start] Detected /compact command');
|
||||
@@ -381,9 +438,27 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
};
|
||||
messageQueue.push(formattedText, enhancedMode, localId);
|
||||
logger.debugLargeJson('User message pushed to queue:', message)
|
||||
};
|
||||
session.onUserMessage((...args) => {
|
||||
if (!messagePipelineReady) {
|
||||
deferredMessages.push(args);
|
||||
return;
|
||||
}
|
||||
handleUserMessage(...args);
|
||||
});
|
||||
void Promise.allSettled([sessionReady, getCatalog()]).then(() => {
|
||||
messagePipelineReady = true;
|
||||
for (const args of deferredMessages.splice(0)) {
|
||||
handleUserMessage(...args);
|
||||
}
|
||||
});
|
||||
|
||||
session.onCancelQueuedMessage((localId) => {
|
||||
const deferredIndex = deferredMessages.findIndex(([, id]) => id === localId);
|
||||
if (deferredIndex >= 0) {
|
||||
deferredMessages.splice(deferredIndex, 1);
|
||||
return true;
|
||||
}
|
||||
const removed = messageQueue.cancelByLocalId(localId);
|
||||
logger.debug(`[claude] cancelByLocalId(${localId}): ${removed ? 'removed' : 'not found (best-effort)'}`);
|
||||
return removed;
|
||||
@@ -458,6 +533,10 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
onModeChange: createModeChangeHandler(session),
|
||||
onSessionReady: (sessionInstance) => {
|
||||
currentSessionRef.current = sessionInstance;
|
||||
resolveSessionReady(sessionInstance);
|
||||
if (nativeSkills) {
|
||||
sessionInstance.setNativeSkillNames(nativeSkills.map((skill) => skill.name));
|
||||
}
|
||||
syncSessionModes();
|
||||
},
|
||||
mcpServers: {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
classifyClaudeSlashCatalog,
|
||||
filterCatalogAffectingClaudeArgs
|
||||
} from './metadataExtractor'
|
||||
|
||||
describe('Claude skill catalog', () => {
|
||||
const discoveredSkills = [
|
||||
{ name: 'hapi', description: 'Manage HAPI' },
|
||||
{ name: 'ponytail', description: 'Keep code simple' },
|
||||
{ name: 'scanner-only', description: 'Not loaded by Claude' }
|
||||
]
|
||||
|
||||
it('separates native skills from slash commands and keeps plugin namespaces', () => {
|
||||
expect(classifyClaudeSlashCatalog(
|
||||
['help', 'hapi', 'url-plugin:url-skill', 'ponytail:ponytail', 'review'],
|
||||
discoveredSkills,
|
||||
['hapi', 'url-plugin:url-skill', 'ponytail:ponytail']
|
||||
)).toEqual({
|
||||
commands: ['help', 'review'],
|
||||
skills: [
|
||||
{ name: 'hapi', description: 'Manage HAPI' },
|
||||
{ name: 'url-plugin:url-skill', description: undefined },
|
||||
{ name: 'ponytail:ponytail', description: 'Keep code simple' }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps only launch arguments that affect the command catalog', () => {
|
||||
expect(filterCatalogAffectingClaudeArgs([
|
||||
'--resume', 'session-id',
|
||||
'--plugin-dir', '/tmp/my plugin',
|
||||
'--settings=/tmp/settings.json',
|
||||
'--add-dir', '/tmp/one', '/tmp/two',
|
||||
'--disable-slash-commands',
|
||||
'--model', 'sonnet'
|
||||
])).toEqual([
|
||||
'--plugin-dir', '/tmp/my plugin',
|
||||
'--settings=/tmp/settings.json',
|
||||
'--add-dir', '/tmp/one', '/tmp/two',
|
||||
'--disable-slash-commands'
|
||||
])
|
||||
})
|
||||
|
||||
})
|
||||
@@ -6,18 +6,82 @@
|
||||
import { query } from './query'
|
||||
import type { SDKSystemMessage } from './types'
|
||||
import { logger } from '@/ui/logger'
|
||||
import type { SkillSummary } from '@/modules/common/skills'
|
||||
|
||||
export interface SDKMetadata {
|
||||
tools?: string[]
|
||||
skills?: string[]
|
||||
slashCommands?: string[]
|
||||
}
|
||||
|
||||
const CATALOG_FLAGS = new Set([
|
||||
'--bare',
|
||||
'--disable-slash-commands',
|
||||
'--safe-mode'
|
||||
])
|
||||
const CATALOG_VALUE_FLAGS = new Set([
|
||||
'--add-dir',
|
||||
'--plugin-dir',
|
||||
'--plugin-url',
|
||||
'--settings',
|
||||
'--setting-sources'
|
||||
])
|
||||
|
||||
export function filterCatalogAffectingClaudeArgs(args: readonly string[] | undefined): string[] {
|
||||
if (!args) return []
|
||||
const filtered: string[] = []
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
const equalsIndex = arg.indexOf('=')
|
||||
if (CATALOG_FLAGS.has(arg) || (equalsIndex > 0 && CATALOG_VALUE_FLAGS.has(arg.slice(0, equalsIndex)))) {
|
||||
filtered.push(arg)
|
||||
continue
|
||||
}
|
||||
if (!CATALOG_VALUE_FLAGS.has(arg)) continue
|
||||
filtered.push(arg)
|
||||
while (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
||||
filtered.push(args[++i])
|
||||
if (arg !== '--add-dir') break
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
export function classifyClaudeSlashCatalog(
|
||||
names: readonly string[] | undefined,
|
||||
discoveredSkills: readonly SkillSummary[],
|
||||
loadedSkillNames?: readonly string[]
|
||||
): { commands: string[]; skills: SkillSummary[] } {
|
||||
const skillsByName = new Map(discoveredSkills.map((skill) => [skill.name, skill]))
|
||||
const loadedSkills = loadedSkillNames ? new Set(loadedSkillNames) : null
|
||||
const commands: string[] = []
|
||||
const skills: SkillSummary[] = []
|
||||
|
||||
for (const rawName of names ?? []) {
|
||||
const name = rawName.trim()
|
||||
if (!name) continue
|
||||
const localName = name.slice(name.lastIndexOf(':') + 1)
|
||||
const discoveredSkill = skillsByName.get(name) ?? skillsByName.get(localName)
|
||||
if (loadedSkills?.has(name) || (!loadedSkills && discoveredSkill)) {
|
||||
skills.push({ name, description: discoveredSkill?.description })
|
||||
} else {
|
||||
commands.push(name)
|
||||
}
|
||||
}
|
||||
|
||||
return { commands, skills }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract SDK metadata by running a minimal query and capturing the init message
|
||||
* @returns SDK metadata containing tools and slash commands
|
||||
*/
|
||||
export async function extractSDKMetadata(): Promise<SDKMetadata> {
|
||||
export async function extractSDKMetadata(options: {
|
||||
cwd?: string
|
||||
claudeArgs?: readonly string[]
|
||||
} = {}): Promise<SDKMetadata> {
|
||||
const abortController = new AbortController()
|
||||
const timeout = setTimeout(() => abortController.abort(), 10_000)
|
||||
|
||||
try {
|
||||
logger.debug('[metadataExtractor] Starting SDK metadata extraction')
|
||||
@@ -26,6 +90,8 @@ export async function extractSDKMetadata(): Promise<SDKMetadata> {
|
||||
const sdkQuery = query({
|
||||
prompt: 'hello',
|
||||
options: {
|
||||
cwd: options.cwd,
|
||||
additionalArgs: filterCatalogAffectingClaudeArgs(options.claudeArgs),
|
||||
allowedTools: ['Bash(echo)'],
|
||||
maxTurns: 1,
|
||||
abort: abortController.signal
|
||||
@@ -39,6 +105,7 @@ export async function extractSDKMetadata(): Promise<SDKMetadata> {
|
||||
|
||||
const metadata: SDKMetadata = {
|
||||
tools: systemMessage.tools,
|
||||
skills: systemMessage.skills,
|
||||
slashCommands: systemMessage.slash_commands
|
||||
}
|
||||
|
||||
@@ -62,21 +129,7 @@ export async function extractSDKMetadata(): Promise<SDKMetadata> {
|
||||
}
|
||||
logger.debug('[metadataExtractor] Error extracting SDK metadata:', error)
|
||||
return {}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract SDK metadata asynchronously without blocking
|
||||
* Fires the extraction and updates metadata when complete
|
||||
*/
|
||||
export function extractSDKMetadataAsync(onComplete: (metadata: SDKMetadata) => void): void {
|
||||
extractSDKMetadata()
|
||||
.then(metadata => {
|
||||
if (metadata.tools || metadata.slashCommands) {
|
||||
onComplete(metadata)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
logger.debug('[metadataExtractor] Async extraction failed:', error)
|
||||
})
|
||||
}
|
||||
@@ -97,4 +97,24 @@ describe('Query', () => {
|
||||
|
||||
await expect(result.next()).rejects.toThrow('prompt failed')
|
||||
})
|
||||
|
||||
it('places additional launch arguments before HAPI settings', async () => {
|
||||
const child = createFakeChild()
|
||||
spawnMock.mockReturnValueOnce(child)
|
||||
process.env.HAPI_CLAUDE_PATH = 'claude'
|
||||
|
||||
const { query } = await import('./query')
|
||||
query({
|
||||
prompt: 'hello',
|
||||
options: {
|
||||
additionalArgs: ['--plugin-dir', '/tmp/plugin'],
|
||||
settingsPath: '/tmp/hapi-settings.json'
|
||||
}
|
||||
})
|
||||
|
||||
const args = spawnMock.mock.calls[0][1] as string[]
|
||||
expect(args.indexOf('--plugin-dir')).toBeLessThan(args.indexOf('--settings'))
|
||||
child.stdout.end()
|
||||
child.emit('close', 0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -297,6 +297,7 @@ export function query(config: {
|
||||
const {
|
||||
prompt,
|
||||
options: {
|
||||
additionalArgs = [],
|
||||
additionalDirectories = [],
|
||||
allowedTools = [],
|
||||
appendSystemPrompt,
|
||||
@@ -341,6 +342,7 @@ export function query(config: {
|
||||
}
|
||||
if (continueConversation) args.push('--continue')
|
||||
if (resume) args.push('--resume', resume)
|
||||
args.push(...additionalArgs)
|
||||
if (settingsPath) args.push('--settings', settingsPath)
|
||||
if (allowedTools.length > 0) args.push('--allowedTools', allowedTools.join(','))
|
||||
if (disallowedTools.length > 0) args.push('--disallowedTools', disallowedTools.join(','))
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface SDKSystemMessage extends SDKMessage {
|
||||
model?: string
|
||||
cwd?: string
|
||||
tools?: string[]
|
||||
skills?: string[]
|
||||
slash_commands?: string[]
|
||||
/**
|
||||
* Present on `subtype: 'status'` messages that report a /compact outcome.
|
||||
@@ -181,6 +182,7 @@ export interface CanCallToolCallback {
|
||||
*/
|
||||
export interface QueryOptions {
|
||||
abort?: AbortSignal
|
||||
additionalArgs?: string[]
|
||||
additionalDirectories?: string[]
|
||||
allowedTools?: string[]
|
||||
appendSystemPrompt?: string
|
||||
|
||||
@@ -23,6 +23,7 @@ export class Session extends AgentSessionBase<EnhancedMode> {
|
||||
readonly startedBy: 'runner' | 'terminal';
|
||||
readonly startingMode: 'local' | 'remote';
|
||||
localLaunchFailure: LocalLaunchFailure | null = null;
|
||||
private nativeSkillNames = new Set<string>();
|
||||
|
||||
constructor(opts: {
|
||||
api: ApiClient;
|
||||
@@ -97,6 +98,17 @@ export class Session extends AgentSessionBase<EnhancedMode> {
|
||||
this.effort = effort;
|
||||
};
|
||||
|
||||
setNativeSkillNames = (names: readonly string[]): void => {
|
||||
this.nativeSkillNames = new Set(names);
|
||||
};
|
||||
|
||||
expandSkillReference = (message: string, trailingContext = ''): string => {
|
||||
const match = /^\s*\$([^\s]+)(?=\s|$)/.exec(message);
|
||||
if (!match || !this.nativeSkillNames.has(match[1])) return message;
|
||||
const expanded = `/${match[1]}${message.slice(match[0].length)}`;
|
||||
return trailingContext ? `${expanded}\n\n${trailingContext}` : expanded;
|
||||
};
|
||||
|
||||
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
|
||||
this.localLaunchFailure = { message, exitReason };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user