Skip to content

Commit b695bbd

Browse files
committed
feat: add Trae hook support
1 parent 07c5b64 commit b695bbd

18 files changed

Lines changed: 384 additions & 9 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

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

5+
## What's Changed [0.4.0-beta.17] - 2026-08-06
6+
7+
### Added
8+
9+
- **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.
10+
511
## What's Changed [0.4.0-beta.16] - 2026-08-05
612

713
### Fixed

assets/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "0.4.0-beta.16",
2+
"version": "0.4.0-beta.17",
33
"skills": [
44
"comet/SKILL.md",
55
"comet-classic/SKILL.md",

domains/bundle/bundle-platform.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ function hookDestination(target: BundlePlatformTarget, hookId: string): string |
118118
case 'gemini':
119119
return path.join(platformRoot, 'settings.json');
120120
case 'windsurf':
121+
case 'trae':
121122
return path.join(platformRoot, 'hooks.json');
122123
case 'copilot':
123124
return path.join(platformRoot, 'hooks', `${hookId}.json`);
@@ -291,6 +292,18 @@ async function applyHookInstallFile(file: PlatformInstallFile): Promise<void> {
291292
await writeFile(file.destination, JSON.stringify(settings, null, 2) + '\n');
292293
return;
293294
}
295+
case 'trae': {
296+
hooks.PreToolUse = mergeCommandHookGroup(
297+
asHookGroups(hooks.PreToolUse),
298+
matcher,
299+
{ ...commandHook, timeout: 30 },
300+
operation.command,
301+
);
302+
settings.version = settings.version ?? 1;
303+
settings.hooks = hooks;
304+
await writeFile(file.destination, JSON.stringify(settings, null, 2) + '\n');
305+
return;
306+
}
294307
case 'gemini': {
295308
const geminiMatcher = matcher === 'Write|Edit' ? 'write_file|edit_file' : matcher;
296309
hooks.BeforeTool = mergeCommandHookGroup(

domains/bundle/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,8 @@ export interface PlatformInstallFile {
334334
| 'qwen'
335335
| 'kiro'
336336
| 'qoder'
337-
| 'codebuddy';
337+
| 'codebuddy'
338+
| 'trae';
338339
event: NormalizedHook['event'];
339340
matcher?: string;
340341
command: string;

domains/skill/platform-inspect.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,34 @@ function countGroupedHookMatches(
142142
}, 0);
143143
}
144144

145+
function countTraeHookMatches(
146+
config: Record<string, unknown>,
147+
expected: ExpectedHookDescriptor,
148+
): number {
149+
const hooks = config.hooks;
150+
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return 0;
151+
const groups = (hooks as Record<string, unknown>).PreToolUse;
152+
if (!Array.isArray(groups)) return 0;
153+
return groups.reduce((count, group) => {
154+
if (!group || typeof group !== 'object' || Array.isArray(group)) return count;
155+
const record = group as Record<string, unknown>;
156+
if (record.matcher !== expected.matcher || !Array.isArray(record.hooks)) return count;
157+
return (
158+
count +
159+
record.hooks.filter(
160+
(handler) =>
161+
handler !== null &&
162+
typeof handler === 'object' &&
163+
!Array.isArray(handler) &&
164+
(handler as Record<string, unknown>).type === 'command' &&
165+
(handler as Record<string, unknown>).command === expected.command &&
166+
typeof (handler as Record<string, unknown>).timeout === 'number' &&
167+
((handler as Record<string, unknown>).timeout as number) > 0,
168+
).length
169+
);
170+
}, 0);
171+
}
172+
145173
function collectCommandArray(config: Record<string, unknown>, groupName: string): unknown[] {
146174
const hooks = config.hooks;
147175
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return [];
@@ -440,6 +468,14 @@ export async function inspectCometHooksForPlatform(
440468
countWindsurfHookMatches,
441469
);
442470
break;
471+
case 'trae':
472+
inspection = await inspectSingleHookJson(
473+
path.join(platformBase, 'hooks.json'),
474+
expectedHooks,
475+
(config) => collectGroupedCommands(config, 'PreToolUse'),
476+
countTraeHookMatches,
477+
);
478+
break;
443479
case 'copilot':
444480
inspection = await inspectSingleHookJson(
445481
path.join(platformBase, 'hooks', 'comet-guard.json'),

domains/skill/platform-install.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,7 @@ ${content}`;
10061006
* 'windsurf' — hooks.json with pre_write_code array
10071007
* 'copilot' — hooks/*.json with preToolUse
10081008
* 'kiro' — hooks/*.kiro.hook JSON files
1009+
* 'trae' — hooks.json with version and PreToolUse grouped command hooks
10091010
*/
10101011
async function installCometHooksForPlatform(
10111012
baseDir: string,
@@ -1023,7 +1024,7 @@ async function installCometHooksForPlatform(
10231024
};
10241025
}
10251026

1026-
if (scope === 'global') {
1027+
if (scope === 'global' && platform.hookFormat !== 'trae') {
10271028
return {
10281029
status: 'skipped',
10291030
reason: 'blocking Hooks are project-scoped',
@@ -1114,6 +1115,15 @@ async function installCometHooksForPlatform(
11141115
platformId: platform.id,
11151116
scope,
11161117
});
1118+
case 'trae':
1119+
return await installTraeHooks(
1120+
baseDir,
1121+
platformBase,
1122+
skillsDir,
1123+
hooksConfig,
1124+
platform.name,
1125+
{ platformId: platform.id, scope },
1126+
);
11171127
default:
11181128
return { status: 'failed', reason: `unsupported hook format: ${hookFormat}` };
11191129
}
@@ -1542,6 +1552,53 @@ async function installWindsurfHooks(
15421552
return { status: 'installed' };
15431553
}
15441554

1555+
/**
1556+
* Trae format:
1557+
* Writes to hooks.json with { version: 1, hooks: { PreToolUse: [{ matcher, hooks: [{ type, command, timeout }] }] } }
1558+
*/
1559+
async function installTraeHooks(
1560+
baseDir: string,
1561+
platformBase: string,
1562+
skillsDir: string,
1563+
hooksConfig: Record<string, HookConfig>,
1564+
platformName: string,
1565+
context: HookCommandContext,
1566+
): Promise<HookInstallResult> {
1567+
const hooksPath = path.join(platformBase, 'hooks.json');
1568+
const matcherGroups: Record<
1569+
string,
1570+
Array<{ type: string; command: string; timeout: number }>
1571+
> = {};
1572+
1573+
for (const [scriptRelPath, config] of Object.entries(hooksConfig)) {
1574+
matcherGroups[config.matcher] ??= [];
1575+
matcherGroups[config.matcher].push({
1576+
type: 'command',
1577+
command: buildHookCommand(baseDir, skillsDir, scriptRelPath, context),
1578+
timeout: 30,
1579+
});
1580+
}
1581+
1582+
const preToolUseEntries = Object.entries(matcherGroups).map(([matcher, hooks]) => ({
1583+
matcher,
1584+
hooks,
1585+
}));
1586+
const hooksFile = await readSettingsJsonObject(hooksPath, platformName);
1587+
const existingHooks = (hooksFile.hooks as Record<string, unknown>) ?? {};
1588+
const existingPreToolUse = asHookGroup(existingHooks.PreToolUse);
1589+
const merged = mergeHookGroups(
1590+
existingPreToolUse,
1591+
preToolUseEntries,
1592+
managedHookScriptPaths(hooksConfig),
1593+
);
1594+
1595+
hooksFile.version = hooksFile.version ?? 1;
1596+
hooksFile.hooks = { ...existingHooks, PreToolUse: merged };
1597+
await ensureDir(path.dirname(hooksPath));
1598+
await writeFile(hooksPath, JSON.stringify(hooksFile, null, 2) + '\n', 'utf-8');
1599+
return { status: 'installed' };
1600+
}
1601+
15451602
/**
15461603
* GitHub Copilot format:
15471604
* Writes to .github/hooks/comet-guard.json with preToolUse hooks config.

domains/skill/uninstall.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,6 +1047,8 @@ async function removeCometHooksForPlatform(
10471047
return await removeGeminiHooks(platformBase, scriptRelPaths);
10481048
case 'windsurf':
10491049
return await removeWindsurfHooks(platformBase, scriptRelPaths);
1050+
case 'trae':
1051+
return await removeTraeHooks(platformBase, scriptRelPaths);
10501052
case 'copilot':
10511053
return await removeCopilotHooks(platformBase, scriptRelPaths);
10521054
case 'kiro':
@@ -1209,6 +1211,58 @@ async function removeWindsurfHooks(
12091211
return { removed, failed: 0 };
12101212
}
12111213

1214+
async function removeTraeHooks(
1215+
platformBase: string,
1216+
scriptRelPaths: string[],
1217+
): Promise<RemovalResult> {
1218+
const hooksPath = path.join(platformBase, 'hooks.json');
1219+
if (!(await fileExists(hooksPath))) return { removed: 0, failed: 0 };
1220+
let removed = 0;
1221+
const readResult = await readJsonObjectFile(hooksPath);
1222+
if (readResult.status === 'missing') return { removed: 0, failed: 0 };
1223+
if (readResult.status === 'error') return { removed: 0, failed: 1 };
1224+
const hooksFile = readResult.value;
1225+
1226+
const existingHooks = hooksFile.hooks as Record<string, unknown> | undefined;
1227+
if (!existingHooks) {
1228+
return { removed: 0, failed: 0 };
1229+
}
1230+
1231+
const existingPreToolUse = existingHooks.PreToolUse as Array<Record<string, unknown>> | undefined;
1232+
if (!existingPreToolUse || !Array.isArray(existingPreToolUse)) {
1233+
return { removed: 0, failed: 0 };
1234+
}
1235+
1236+
const filtered = existingPreToolUse.flatMap((group) => {
1237+
if (!Array.isArray(group.hooks)) return [group];
1238+
1239+
const hooksBefore = (group.hooks as Array<Record<string, unknown>>).length;
1240+
const hooks = (group.hooks as Array<Record<string, unknown>>).filter(
1241+
(hook) => !isManagedHookCommand(hook.command, scriptRelPaths),
1242+
);
1243+
removed += hooksBefore - hooks.length;
1244+
1245+
const hasUnknownMetadata = Object.keys(group).some(
1246+
(key) => key !== 'matcher' && key !== 'hooks',
1247+
);
1248+
if (hooks.length === 0) return hasUnknownMetadata ? [{ ...group, hooks: [] }] : [];
1249+
return [{ ...group, hooks }];
1250+
});
1251+
1252+
if (filtered.length === 0) {
1253+
delete existingHooks.PreToolUse;
1254+
} else {
1255+
existingHooks.PreToolUse = filtered;
1256+
}
1257+
1258+
if (Object.keys(existingHooks).length === 0) {
1259+
delete hooksFile.hooks;
1260+
}
1261+
1262+
await writeFile(hooksPath, JSON.stringify(hooksFile, null, 2) + '\n', 'utf-8');
1263+
return { removed, failed: 0 };
1264+
}
1265+
12121266
async function removeCopilotHooks(
12131267
platformBase: string,
12141268
scriptRelPaths: string[],

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@rpamis/comet",
3-
"version": "0.4.0-beta.16",
3+
"version": "0.4.0-beta.17",
44
"description": "Agent Skill Harness For Turning Ideas Into Evaluated Workflows",
55
"keywords": [
66
"comet",

platform/install/platforms.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface Platform {
1515
legacySkillsDirs?: string[];
1616
/** Platform configuration and hook root when it differs from the Skill root. */
1717
configDir?: string;
18+
/** Global platform configuration and hook root when it differs from the global Skill root. */
19+
globalConfigDir?: string;
1820
detectionPaths?: string[];
1921
openspecToolId: string;
2022
/** OpenSpec's generated tool root when it differs from Comet's canonical Skill root. */
@@ -36,7 +38,8 @@ export interface Platform {
3638
| 'qwen'
3739
| 'kiro'
3840
| 'qoder'
39-
| 'codebuddy';
41+
| 'codebuddy'
42+
| 'trae';
4043
/** Hook config filename relative to the platform config root when it differs from the format default. */
4144
hookConfigFile?: string;
4245
/** Historical hook config filenames checked during migration and uninstall. */
@@ -63,6 +66,9 @@ export function getPlatformSkillsDirs(platform: Platform, scope: InstallScope):
6366
}
6467

6568
export function getPlatformConfigDir(platform: Platform, scope: InstallScope): string {
69+
if (scope === 'global' && platform.globalConfigDir) {
70+
return platform.globalConfigDir;
71+
}
6672
return platform.configDir ?? getPlatformSkillsDir(platform, scope);
6773
}
6874

@@ -306,17 +312,23 @@ export const PLATFORMS: Platform[] = [
306312
openspecToolId: 'trae',
307313
rulesDir: 'rules',
308314
rulesFormat: 'md',
315+
supportsHooks: true,
316+
hookFormat: 'trae',
309317
},
310318
{
311319
id: 'trae-cn',
312320
name: 'Trae CN',
313321
skillsDir: '.trae-cn',
314322
globalSkillsDir: '.trae-cn',
323+
configDir: '.trae',
324+
globalConfigDir: '.trae-cn',
315325
// OpenSpec exposes Trae as one tool id; keep Comet's CN-specific install
316326
// directories but reuse the supported OpenSpec Trae integration.
317327
openspecToolId: 'trae',
318328
rulesDir: 'rules',
319329
rulesFormat: 'md',
330+
supportsHooks: true,
331+
hookFormat: 'trae',
320332
},
321333
{
322334
id: 'zcode',

0 commit comments

Comments
 (0)