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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to @rpamis/comet will be documented in this file.

## What's Changed [0.4.0-beta.18] - 2026-08-07

### Added

- **Trae Hook support**: `comet init`, `comet update`, `comet doctor`, and `comet uninstall` now support managed Hook Router entries for Trae and Trae CN, using Trae's official project and global `hooks.json` locations while preserving user-owned Hook configuration.

## What's Changed [0.4.0-beta.17] - 2026-08-05

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion assets/manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "0.4.0-beta.17",
"version": "0.4.0-beta.18",
"skills": [
"comet/SKILL.md",
"comet-classic/SKILL.md",
Expand Down
2 changes: 1 addition & 1 deletion assets/skills/comet/scripts/comet-hook-router.mjs

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions domains/bundle/bundle-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ function hookDestination(target: BundlePlatformTarget, hookId: string): string |
case 'gemini':
return path.join(platformRoot, 'settings.json');
case 'windsurf':
case 'trae':
return path.join(platformRoot, 'hooks.json');
case 'copilot':
return path.join(platformRoot, 'hooks', `${hookId}.json`);
Expand Down Expand Up @@ -291,6 +292,18 @@ async function applyHookInstallFile(file: PlatformInstallFile): Promise<void> {
await writeFile(file.destination, JSON.stringify(settings, null, 2) + '\n');
return;
}
case 'trae': {
hooks.PreToolUse = mergeCommandHookGroup(
asHookGroups(hooks.PreToolUse),
matcher,
{ ...commandHook, timeout: 30 },
operation.command,
);
settings.version = settings.version ?? 1;
settings.hooks = hooks;
await writeFile(file.destination, JSON.stringify(settings, null, 2) + '\n');
return;
}
case 'gemini': {
const geminiMatcher = matcher === 'Write|Edit' ? 'write_file|edit_file' : matcher;
hooks.BeforeTool = mergeCommandHookGroup(
Expand Down
3 changes: 2 additions & 1 deletion domains/bundle/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,8 @@ export interface PlatformInstallFile {
| 'qwen'
| 'kiro'
| 'qoder'
| 'codebuddy';
| 'codebuddy'
| 'trae';
event: NormalizedHook['event'];
matcher?: string;
command: string;
Expand Down
2 changes: 2 additions & 0 deletions domains/comet-entry/hook-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export const COMET_HOOK_PLATFORM_IDS = new Set([
'kiro',
'codebuddy',
'qoder',
'trae',
'trae-cn',
]);

function isRecord(value: unknown): value is Record<string, unknown> {
Expand Down
31 changes: 29 additions & 2 deletions domains/skill/platform-inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ function countGroupedHookMatches(
groupName: string,
expected: ExpectedHookDescriptor,
expectedMatcher: (matcher: string) => string = (matcher) => matcher,
isExpectedHandler: (
handler: Record<string, unknown>,
expected: ExpectedHookDescriptor,
) => boolean = (handler, descriptor) =>
handler.type === 'command' && handler.command === descriptor.command,
): number {
const hooks = config.hooks;
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return 0;
Expand All @@ -135,8 +140,7 @@ function countGroupedHookMatches(
handler !== null &&
typeof handler === 'object' &&
!Array.isArray(handler) &&
(handler as Record<string, unknown>).type === 'command' &&
(handler as Record<string, unknown>).command === expected.command,
isExpectedHandler(handler as Record<string, unknown>, expected),
).length
);
}, 0);
Expand Down Expand Up @@ -440,6 +444,29 @@ export async function inspectCometHooksForPlatform(
countWindsurfHookMatches,
);
break;
case 'trae':
inspection = await inspectSingleHookJson(
path.join(platformBase, 'hooks.json'),
expectedHooks,
(config) => collectGroupedCommands(config, 'PreToolUse'),
(config, expected) =>
countGroupedHookMatches(
config,
'PreToolUse',
expected,
undefined,
(handler, descriptor) => {
const timeout = handler.timeout;
return (
handler.type === 'command' &&
handler.command === descriptor.command &&
typeof timeout === 'number' &&
timeout > 0
);
},
),
);
break;
case 'copilot':
inspection = await inspectSingleHookJson(
path.join(platformBase, 'hooks', 'comet-guard.json'),
Expand Down
59 changes: 58 additions & 1 deletion domains/skill/platform-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,7 @@ ${content}`;
* 'windsurf' — hooks.json with pre_write_code array
* 'copilot' — hooks/*.json with preToolUse
* 'kiro' — hooks/*.kiro.hook JSON files
* 'trae' — hooks.json with version and PreToolUse grouped command hooks
*/
async function installCometHooksForPlatform(
baseDir: string,
Expand All @@ -1023,7 +1024,7 @@ async function installCometHooksForPlatform(
};
}

if (scope === 'global') {
if (scope === 'global' && platform.hookFormat !== 'trae') {
return {
status: 'skipped',
reason: 'blocking Hooks are project-scoped',
Expand Down Expand Up @@ -1114,6 +1115,15 @@ async function installCometHooksForPlatform(
platformId: platform.id,
scope,
});
case 'trae':
return await installTraeHooks(
baseDir,
platformBase,
skillsDir,
hooksConfig,
platform.name,
{ platformId: platform.id, scope },
);
default:
return { status: 'failed', reason: `unsupported hook format: ${hookFormat}` };
}
Expand Down Expand Up @@ -1542,6 +1552,53 @@ async function installWindsurfHooks(
return { status: 'installed' };
}

/**
* Trae format:
* Writes to hooks.json with { version: 1, hooks: { PreToolUse: [{ matcher, hooks: [{ type, command, timeout }] }] } }
*/
async function installTraeHooks(
baseDir: string,
platformBase: string,
skillsDir: string,
hooksConfig: Record<string, HookConfig>,
platformName: string,
context: HookCommandContext,
): Promise<HookInstallResult> {
const hooksPath = path.join(platformBase, 'hooks.json');
const matcherGroups: Record<
string,
Array<{ type: string; command: string; timeout: number }>
> = {};

for (const [scriptRelPath, config] of Object.entries(hooksConfig)) {
matcherGroups[config.matcher] ??= [];
matcherGroups[config.matcher].push({
type: 'command',
command: buildHookCommand(baseDir, skillsDir, scriptRelPath, context),
timeout: 30,
});
}

const preToolUseEntries = Object.entries(matcherGroups).map(([matcher, hooks]) => ({
matcher,
hooks,
}));
const hooksFile = await readSettingsJsonObject(hooksPath, platformName);
const existingHooks = (hooksFile.hooks as Record<string, unknown>) ?? {};
const existingPreToolUse = asHookGroup(existingHooks.PreToolUse);
const merged = mergeHookGroups(
existingPreToolUse,
preToolUseEntries,
managedHookScriptPaths(hooksConfig),
);

hooksFile.version = hooksFile.version ?? 1;
hooksFile.hooks = { ...existingHooks, PreToolUse: merged };
await ensureDir(path.dirname(hooksPath));
await writeFile(hooksPath, JSON.stringify(hooksFile, null, 2) + '\n', 'utf-8');
return { status: 'installed' };
}

/**
* GitHub Copilot format:
* Writes to .github/hooks/comet-guard.json with preToolUse hooks config.
Expand Down
84 changes: 68 additions & 16 deletions domains/skill/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,8 @@ async function removeCometHooksForPlatform(
return await removeGeminiHooks(platformBase, scriptRelPaths);
case 'windsurf':
return await removeWindsurfHooks(platformBase, scriptRelPaths);
case 'trae':
return await removeTraeHooks(platformBase, scriptRelPaths);
case 'copilot':
return await removeCopilotHooks(platformBase, scriptRelPaths);
case 'kiro':
Expand All @@ -1065,7 +1067,6 @@ async function removeQwenStyleHooks(
): Promise<RemovalResult> {
const settingsPath = path.join(platformBase, 'settings.json');
if (!(await fileExists(settingsPath))) return { removed: 0, failed: 0 };
let removed = 0;
const readResult = await readJsonObjectFile(settingsPath);
if (readResult.status === 'missing') return { removed: 0, failed: 0 };
if (readResult.status === 'error') return { removed: 0, failed: 1 };
Expand All @@ -1081,6 +1082,7 @@ async function removeQwenStyleHooks(
return { removed: 0, failed: 0 };
}

let removed = 0;
const filtered = existingPreToolUse.flatMap((group) => {
if (!Array.isArray(group.hooks)) return [group];

Expand Down Expand Up @@ -1117,7 +1119,6 @@ async function removeGeminiHooks(
): Promise<RemovalResult> {
const settingsPath = path.join(platformBase, 'settings.json');
if (!(await fileExists(settingsPath))) return { removed: 0, failed: 0 };
let removed = 0;
const readResult = await readJsonObjectFile(settingsPath);
if (readResult.status === 'missing') return { removed: 0, failed: 0 };
if (readResult.status === 'error') return { removed: 0, failed: 1 };
Expand All @@ -1133,7 +1134,30 @@ async function removeGeminiHooks(
return { removed: 0, failed: 0 };
}

const filtered = existingBeforeTool.flatMap((group) => {
const result = removeManagedGroupedHooks(existingBeforeTool, scriptRelPaths);
const removed = result.removed;
const filtered = result.groups;

if (filtered.length === 0) {
delete existingHooks.BeforeTool;
} else {
existingHooks.BeforeTool = filtered;
}

if (Object.keys(existingHooks).length === 0) {
delete settings.hooks;
}

await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
return { removed, failed: 0 };
}

function removeManagedGroupedHooks(
groups: Array<Record<string, unknown>>,
scriptRelPaths: string[],
): { groups: Array<Record<string, unknown>>; removed: number } {
let removed = 0;
const filtered = groups.flatMap((group) => {
if (!Array.isArray(group.hooks)) return [group];

const hooksBefore = (group.hooks as Array<Record<string, unknown>>).length;
Expand All @@ -1149,18 +1173,7 @@ async function removeGeminiHooks(
return [{ ...group, hooks }];
});

if (filtered.length === 0) {
delete existingHooks.BeforeTool;
} else {
existingHooks.BeforeTool = filtered;
}

if (Object.keys(existingHooks).length === 0) {
delete settings.hooks;
}

await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
return { removed, failed: 0 };
return { groups: filtered, removed };
}

async function removeWindsurfHooks(
Expand All @@ -1169,7 +1182,6 @@ async function removeWindsurfHooks(
): Promise<RemovalResult> {
const hooksPath = path.join(platformBase, 'hooks.json');
if (!(await fileExists(hooksPath))) return { removed: 0, failed: 0 };
let removed = 0;
const readResult = await readJsonObjectFile(hooksPath);
if (readResult.status === 'missing') return { removed: 0, failed: 0 };
if (readResult.status === 'error') return { removed: 0, failed: 1 };
Expand All @@ -1187,6 +1199,7 @@ async function removeWindsurfHooks(
return { removed: 0, failed: 0 };
}

let removed = 0;
const filtered = existingPreWrite.filter((entry) => {
if (isManagedHookCommand(entry.command, scriptRelPaths)) {
removed++;
Expand All @@ -1209,6 +1222,45 @@ async function removeWindsurfHooks(
return { removed, failed: 0 };
}

async function removeTraeHooks(
platformBase: string,
scriptRelPaths: string[],
): Promise<RemovalResult> {
const hooksPath = path.join(platformBase, 'hooks.json');
if (!(await fileExists(hooksPath))) return { removed: 0, failed: 0 };
const readResult = await readJsonObjectFile(hooksPath);
if (readResult.status === 'missing') return { removed: 0, failed: 0 };
if (readResult.status === 'error') return { removed: 0, failed: 1 };
const hooksFile = readResult.value;

const existingHooks = hooksFile.hooks as Record<string, unknown> | undefined;
if (!existingHooks) {
return { removed: 0, failed: 0 };
}

const existingPreToolUse = existingHooks.PreToolUse as Array<Record<string, unknown>> | undefined;
if (!existingPreToolUse || !Array.isArray(existingPreToolUse)) {
return { removed: 0, failed: 0 };
}

const result = removeManagedGroupedHooks(existingPreToolUse, scriptRelPaths);
const removed = result.removed;
const filtered = result.groups;

if (filtered.length === 0) {
delete existingHooks.PreToolUse;
} else {
existingHooks.PreToolUse = filtered;
}

if (Object.keys(existingHooks).length === 0) {
delete hooksFile.hooks;
}

await writeFile(hooksPath, JSON.stringify(hooksFile, null, 2) + '\n', 'utf-8');
return { removed, failed: 0 };
}

async function removeCopilotHooks(
platformBase: string,
scriptRelPaths: string[],
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rpamis/comet",
"version": "0.4.0-beta.17",
"version": "0.4.0-beta.18",
"description": "Agent Skill Harness For Turning Ideas Into Evaluated Workflows",
"keywords": [
"comet",
Expand Down
Loading
Loading