@omnidev-ai/core 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +331 -1
- package/dist/index.js +1200 -136
- package/package.json +1 -1
- package/src/capability/loader.ts +9 -0
- package/src/capability/registry.ts +19 -1
- package/src/capability/sources.ts +36 -1
- package/src/config/capabilities.ts +1 -1
- package/src/config/{loader.ts → config.ts} +25 -0
- package/src/config/index.ts +2 -1
- package/src/config/profiles.ts +23 -3
- package/src/config/toml-patcher.ts +309 -0
- package/src/hooks/constants.ts +100 -0
- package/src/hooks/index.ts +99 -0
- package/src/hooks/loader.ts +189 -0
- package/src/hooks/merger.ts +157 -0
- package/src/hooks/types.ts +212 -0
- package/src/hooks/validation.ts +516 -0
- package/src/hooks/variables.ts +151 -0
- package/src/index.ts +3 -0
- package/src/sync.ts +10 -1
- package/src/types/index.ts +23 -0
package/package.json
CHANGED
package/src/capability/loader.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { validateEnv } from "../config/env";
|
|
5
5
|
import { parseCapabilityConfig } from "../config/parser";
|
|
6
|
+
import { loadCapabilityHooks } from "../hooks/loader.js";
|
|
6
7
|
import type {
|
|
7
8
|
CapabilityConfig,
|
|
8
9
|
Command,
|
|
@@ -405,6 +406,9 @@ export async function loadCapability(
|
|
|
405
406
|
? (exportsAny.gitignore as string[])
|
|
406
407
|
: undefined;
|
|
407
408
|
|
|
409
|
+
// Load hooks from hooks/hooks.toml if present
|
|
410
|
+
const hooks = loadCapabilityHooks(id, capabilityPath);
|
|
411
|
+
|
|
408
412
|
// Build result object with explicit handling for optional typeDefinitions
|
|
409
413
|
const result: LoadedCapability = {
|
|
410
414
|
id,
|
|
@@ -428,5 +432,10 @@ export async function loadCapability(
|
|
|
428
432
|
result.gitignore = gitignore;
|
|
429
433
|
}
|
|
430
434
|
|
|
435
|
+
// Only add hooks if found
|
|
436
|
+
if (hooks !== null) {
|
|
437
|
+
result.hooks = hooks;
|
|
438
|
+
}
|
|
439
|
+
|
|
431
440
|
return result;
|
|
432
441
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getEnabledCapabilities } from "../config/capabilities";
|
|
2
2
|
import { loadEnvironment } from "../config/env";
|
|
3
|
-
import
|
|
3
|
+
import { mergeHooksConfigs } from "../hooks/merger.js";
|
|
4
|
+
import type { HooksConfig, CapabilityHooks, Doc, LoadedCapability, Rule, Skill } from "../types";
|
|
4
5
|
import { discoverCapabilities, loadCapability } from "./loader";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -13,6 +14,10 @@ export interface CapabilityRegistry {
|
|
|
13
14
|
getAllSkills(): Skill[];
|
|
14
15
|
getAllRules(): Rule[];
|
|
15
16
|
getAllDocs(): Doc[];
|
|
17
|
+
/** Get all capability hooks metadata */
|
|
18
|
+
getAllCapabilityHooks(): CapabilityHooks[];
|
|
19
|
+
/** Get merged hooks from all capabilities */
|
|
20
|
+
getMergedHooks(): HooksConfig;
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
/**
|
|
@@ -44,6 +49,17 @@ export async function buildCapabilityRegistry(): Promise<CapabilityRegistry> {
|
|
|
44
49
|
}
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
// Helper to get all capability hooks
|
|
53
|
+
const getAllCapabilityHooks = (): CapabilityHooks[] => {
|
|
54
|
+
const hooks: CapabilityHooks[] = [];
|
|
55
|
+
for (const cap of capabilities.values()) {
|
|
56
|
+
if (cap.hooks) {
|
|
57
|
+
hooks.push(cap.hooks);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return hooks;
|
|
61
|
+
};
|
|
62
|
+
|
|
47
63
|
return {
|
|
48
64
|
capabilities,
|
|
49
65
|
getCapability: (id: string) => capabilities.get(id),
|
|
@@ -51,5 +67,7 @@ export async function buildCapabilityRegistry(): Promise<CapabilityRegistry> {
|
|
|
51
67
|
getAllSkills: () => [...capabilities.values()].flatMap((c) => c.skills),
|
|
52
68
|
getAllRules: () => [...capabilities.values()].flatMap((c) => c.rules),
|
|
53
69
|
getAllDocs: () => [...capabilities.values()].flatMap((c) => c.docs),
|
|
70
|
+
getAllCapabilityHooks,
|
|
71
|
+
getMergedHooks: () => mergeHooksConfigs(getAllCapabilityHooks()),
|
|
54
72
|
};
|
|
55
73
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync } from "node:fs";
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
|
-
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
13
|
+
import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { parse as parseToml } from "smol-toml";
|
|
16
16
|
import type {
|
|
@@ -500,6 +500,38 @@ export interface DiscoveredContent {
|
|
|
500
500
|
docsDir: string | null;
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
+
/**
|
|
504
|
+
* Rename singular folder names to plural for consistency
|
|
505
|
+
* skill -> skills, command -> commands, rule -> rules, agent -> agents
|
|
506
|
+
*/
|
|
507
|
+
export async function normalizeFolderNames(repoPath: string): Promise<void> {
|
|
508
|
+
const renameMappings = [
|
|
509
|
+
{ from: "skill", to: "skills" },
|
|
510
|
+
{ from: "command", to: "commands" },
|
|
511
|
+
{ from: "rule", to: "rules" },
|
|
512
|
+
{ from: "agent", to: "agents" },
|
|
513
|
+
{ from: "subagent", to: "subagents" },
|
|
514
|
+
];
|
|
515
|
+
|
|
516
|
+
for (const { from, to } of renameMappings) {
|
|
517
|
+
const fromPath = join(repoPath, from);
|
|
518
|
+
const toPath = join(repoPath, to);
|
|
519
|
+
|
|
520
|
+
// Only rename if singular exists and plural doesn't
|
|
521
|
+
if (existsSync(fromPath) && !existsSync(toPath)) {
|
|
522
|
+
try {
|
|
523
|
+
const stats = await stat(fromPath);
|
|
524
|
+
if (stats.isDirectory()) {
|
|
525
|
+
await rename(fromPath, toPath);
|
|
526
|
+
}
|
|
527
|
+
} catch (error) {
|
|
528
|
+
// Ignore rename errors (might be permissions, etc.)
|
|
529
|
+
console.warn(`Failed to rename ${from} to ${to}:`, error);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
503
535
|
async function discoverContent(repoPath: string): Promise<DiscoveredContent> {
|
|
504
536
|
const result: DiscoveredContent = {
|
|
505
537
|
skills: [],
|
|
@@ -694,6 +726,9 @@ async function fetchGitCapabilitySource(
|
|
|
694
726
|
}
|
|
695
727
|
|
|
696
728
|
if (needsWrap) {
|
|
729
|
+
// Normalize folder names (singular -> plural)
|
|
730
|
+
await normalizeFolderNames(repoPath);
|
|
731
|
+
|
|
697
732
|
// Discover content and generate capability.toml
|
|
698
733
|
const content = await discoverContent(repoPath);
|
|
699
734
|
await generateCapabilityToml(id, repoPath, config.source, commit, content);
|
|
@@ -173,6 +173,31 @@ function generateConfigToml(config: OmniConfig): string {
|
|
|
173
173
|
}
|
|
174
174
|
lines.push("");
|
|
175
175
|
|
|
176
|
+
// Capability groups
|
|
177
|
+
lines.push("# =============================================================================");
|
|
178
|
+
lines.push("# Capability Groups");
|
|
179
|
+
lines.push("# =============================================================================");
|
|
180
|
+
lines.push("# Bundle multiple capabilities under a single name for cleaner profiles.");
|
|
181
|
+
lines.push('# Reference groups in profiles with the "group:" prefix.');
|
|
182
|
+
lines.push("#");
|
|
183
|
+
|
|
184
|
+
const groups = config.capabilities?.groups;
|
|
185
|
+
if (groups && Object.keys(groups).length > 0) {
|
|
186
|
+
lines.push("[capabilities.groups]");
|
|
187
|
+
for (const [name, caps] of Object.entries(groups)) {
|
|
188
|
+
const capsStr = caps.map((c) => `"${c}"`).join(", ");
|
|
189
|
+
lines.push(`${name} = [${capsStr}]`);
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
lines.push("# [capabilities.groups]");
|
|
193
|
+
lines.push('# expo = ["expo-app-design", "expo-deployment", "upgrading-expo"]');
|
|
194
|
+
lines.push('# backend = ["cloudflare", "database-tools"]');
|
|
195
|
+
lines.push("#");
|
|
196
|
+
lines.push("# [profiles.mobile]");
|
|
197
|
+
lines.push('# capabilities = ["group:expo", "react-native-tools"]');
|
|
198
|
+
}
|
|
199
|
+
lines.push("");
|
|
200
|
+
|
|
176
201
|
// MCP servers
|
|
177
202
|
lines.push("# =============================================================================");
|
|
178
203
|
lines.push("# MCP Servers");
|
package/src/config/index.ts
CHANGED
package/src/config/profiles.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readActiveProfileState, writeActiveProfileState } from "../state/active-profile.js";
|
|
2
2
|
import type { OmniConfig, ProfileConfig } from "../types/index.js";
|
|
3
|
-
import { loadConfig, writeConfig } from "./
|
|
3
|
+
import { loadConfig, writeConfig } from "./config.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Gets the name of the currently active profile.
|
|
@@ -45,9 +45,29 @@ export function resolveEnabledCapabilities(
|
|
|
45
45
|
|
|
46
46
|
const profileCapabilities = profile?.capabilities ?? [];
|
|
47
47
|
const alwaysEnabled = config.always_enabled_capabilities ?? [];
|
|
48
|
+
const groups = config.capabilities?.groups ?? {};
|
|
48
49
|
|
|
49
|
-
//
|
|
50
|
-
|
|
50
|
+
// Expand group references (group:name -> constituent capabilities)
|
|
51
|
+
const expandCapabilities = (caps: string[]): string[] => {
|
|
52
|
+
return caps.flatMap((cap) => {
|
|
53
|
+
if (cap.startsWith("group:")) {
|
|
54
|
+
const groupName = cap.slice(6);
|
|
55
|
+
const groupCaps = groups[groupName];
|
|
56
|
+
if (!groupCaps) {
|
|
57
|
+
console.warn(`Unknown capability group: ${groupName}`);
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
return groupCaps;
|
|
61
|
+
}
|
|
62
|
+
return cap;
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const expandedAlways = expandCapabilities(alwaysEnabled);
|
|
67
|
+
const expandedProfile = expandCapabilities(profileCapabilities);
|
|
68
|
+
|
|
69
|
+
// Merge always-enabled capabilities with profile capabilities (deduplicated)
|
|
70
|
+
return [...new Set([...expandedAlways, ...expandedProfile])];
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
/**
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import type { CapabilitySourceConfig, McpConfig } from "../types/index.js";
|
|
4
|
+
|
|
5
|
+
const CONFIG_PATH = "omni.toml";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read the raw TOML file content
|
|
9
|
+
*/
|
|
10
|
+
async function readConfigFile(): Promise<string> {
|
|
11
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
return readFile(CONFIG_PATH, "utf-8");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Write content to the config file
|
|
19
|
+
*/
|
|
20
|
+
async function writeConfigFile(content: string): Promise<void> {
|
|
21
|
+
await writeFile(CONFIG_PATH, content, "utf-8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Find the line index where a TOML section starts
|
|
26
|
+
* @returns The line index or -1 if not found
|
|
27
|
+
*/
|
|
28
|
+
function findSection(lines: string[], sectionPattern: RegExp): number {
|
|
29
|
+
return lines.findIndex((line) => sectionPattern.test(line.trim()));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Find the end of a TOML section (next section start or end of file)
|
|
34
|
+
*/
|
|
35
|
+
function findSectionEnd(lines: string[], startIndex: number): number {
|
|
36
|
+
// Look for the next section header after the start
|
|
37
|
+
for (let i = startIndex + 1; i < lines.length; i++) {
|
|
38
|
+
const line = lines[i];
|
|
39
|
+
if (line === undefined) continue;
|
|
40
|
+
const trimmed = line.trim();
|
|
41
|
+
// Check if this is a new section (but not a subsection of current)
|
|
42
|
+
if (/^\[(?!\[)/.test(trimmed) && !trimmed.startsWith("#")) {
|
|
43
|
+
return i;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return lines.length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format a capability source for TOML
|
|
51
|
+
*/
|
|
52
|
+
function formatCapabilitySource(name: string, source: CapabilitySourceConfig): string {
|
|
53
|
+
if (typeof source === "string") {
|
|
54
|
+
return `${name} = "${source}"`;
|
|
55
|
+
}
|
|
56
|
+
if (source.path) {
|
|
57
|
+
return `${name} = { source = "${source.source}", path = "${source.path}" }`;
|
|
58
|
+
}
|
|
59
|
+
return `${name} = "${source.source}"`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Add a capability source to [capabilities.sources]
|
|
64
|
+
*/
|
|
65
|
+
export async function patchAddCapabilitySource(
|
|
66
|
+
name: string,
|
|
67
|
+
source: CapabilitySourceConfig,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
let content = await readConfigFile();
|
|
70
|
+
const lines = content.split("\n");
|
|
71
|
+
|
|
72
|
+
// Find [capabilities.sources] section
|
|
73
|
+
const sectionIndex = findSection(lines, /^\[capabilities\.sources\]$/);
|
|
74
|
+
|
|
75
|
+
const newEntry = formatCapabilitySource(name, source);
|
|
76
|
+
|
|
77
|
+
if (sectionIndex !== -1) {
|
|
78
|
+
// Section exists, add entry after section header
|
|
79
|
+
const sectionEnd = findSectionEnd(lines, sectionIndex);
|
|
80
|
+
// Insert before the end of section (or before blank lines at end)
|
|
81
|
+
let insertIndex = sectionEnd;
|
|
82
|
+
// Find a good insertion point (after last non-blank, non-comment line in section)
|
|
83
|
+
for (let i = sectionEnd - 1; i > sectionIndex; i--) {
|
|
84
|
+
const line = lines[i];
|
|
85
|
+
if (line === undefined) continue;
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (trimmed && !trimmed.startsWith("#")) {
|
|
88
|
+
insertIndex = i + 1;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// If we only found the header, insert right after it
|
|
93
|
+
if (insertIndex === sectionEnd && sectionIndex + 1 < lines.length) {
|
|
94
|
+
insertIndex = sectionIndex + 1;
|
|
95
|
+
}
|
|
96
|
+
lines.splice(insertIndex, 0, newEntry);
|
|
97
|
+
} else {
|
|
98
|
+
// Section doesn't exist, need to create it
|
|
99
|
+
// Try to find [capabilities] section first
|
|
100
|
+
const capabilitiesIndex = findSection(lines, /^\[capabilities\]$/);
|
|
101
|
+
|
|
102
|
+
if (capabilitiesIndex !== -1) {
|
|
103
|
+
// Insert after [capabilities] section
|
|
104
|
+
const capEnd = findSectionEnd(lines, capabilitiesIndex);
|
|
105
|
+
lines.splice(capEnd, 0, "", "[capabilities.sources]", newEntry);
|
|
106
|
+
} else {
|
|
107
|
+
// No capabilities section at all, add at end
|
|
108
|
+
// Find a good place - after mcps or at end
|
|
109
|
+
const mcpsIndex = findSection(lines, /^\[mcps/);
|
|
110
|
+
if (mcpsIndex !== -1) {
|
|
111
|
+
// Insert before mcps
|
|
112
|
+
lines.splice(mcpsIndex, 0, "[capabilities.sources]", newEntry, "");
|
|
113
|
+
} else {
|
|
114
|
+
// Just append at end
|
|
115
|
+
lines.push("", "[capabilities.sources]", newEntry);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
content = lines.join("\n");
|
|
121
|
+
await writeConfigFile(content);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Format an MCP config as TOML lines
|
|
126
|
+
*/
|
|
127
|
+
function formatMcpConfig(name: string, config: McpConfig): string[] {
|
|
128
|
+
const lines: string[] = [];
|
|
129
|
+
lines.push(`[mcps.${name}]`);
|
|
130
|
+
|
|
131
|
+
// Transport (only if not stdio, since stdio is default)
|
|
132
|
+
if (config.transport && config.transport !== "stdio") {
|
|
133
|
+
lines.push(`transport = "${config.transport}"`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// For stdio transport
|
|
137
|
+
if (config.command) {
|
|
138
|
+
lines.push(`command = "${config.command}"`);
|
|
139
|
+
}
|
|
140
|
+
if (config.args && config.args.length > 0) {
|
|
141
|
+
const argsStr = config.args.map((a) => `"${a}"`).join(", ");
|
|
142
|
+
lines.push(`args = [${argsStr}]`);
|
|
143
|
+
}
|
|
144
|
+
if (config.cwd) {
|
|
145
|
+
lines.push(`cwd = "${config.cwd}"`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// For http/sse transport
|
|
149
|
+
if (config.url) {
|
|
150
|
+
lines.push(`url = "${config.url}"`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Environment variables
|
|
154
|
+
if (config.env && Object.keys(config.env).length > 0) {
|
|
155
|
+
lines.push(`[mcps.${name}.env]`);
|
|
156
|
+
for (const [key, value] of Object.entries(config.env)) {
|
|
157
|
+
lines.push(`${key} = "${value}"`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Headers
|
|
162
|
+
if (config.headers && Object.keys(config.headers).length > 0) {
|
|
163
|
+
lines.push(`[mcps.${name}.headers]`);
|
|
164
|
+
for (const [key, value] of Object.entries(config.headers)) {
|
|
165
|
+
lines.push(`${key} = "${value}"`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return lines;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Add an MCP server configuration
|
|
174
|
+
*/
|
|
175
|
+
export async function patchAddMcp(name: string, config: McpConfig): Promise<void> {
|
|
176
|
+
let content = await readConfigFile();
|
|
177
|
+
const lines = content.split("\n");
|
|
178
|
+
|
|
179
|
+
const mcpLines = formatMcpConfig(name, config);
|
|
180
|
+
|
|
181
|
+
// Find any existing [mcps.*] section to add near it
|
|
182
|
+
const existingMcpIndex = findSection(lines, /^\[mcps\./);
|
|
183
|
+
|
|
184
|
+
if (existingMcpIndex !== -1) {
|
|
185
|
+
// Find the end of all mcp sections
|
|
186
|
+
let lastMcpEnd = existingMcpIndex;
|
|
187
|
+
for (let i = existingMcpIndex; i < lines.length; i++) {
|
|
188
|
+
const line = lines[i];
|
|
189
|
+
if (line === undefined) continue;
|
|
190
|
+
const trimmed = line.trim();
|
|
191
|
+
if (/^\[mcps\./.test(trimmed)) {
|
|
192
|
+
lastMcpEnd = findSectionEnd(lines, i);
|
|
193
|
+
} else if (/^\[(?!mcps\.)/.test(trimmed) && !trimmed.startsWith("#")) {
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Insert after the last mcp section
|
|
198
|
+
lines.splice(lastMcpEnd, 0, "", ...mcpLines);
|
|
199
|
+
} else {
|
|
200
|
+
// No mcp sections exist, add after profiles or at end
|
|
201
|
+
const profilesIndex = findSection(lines, /^\[profiles\./);
|
|
202
|
+
if (profilesIndex !== -1) {
|
|
203
|
+
// Insert before profiles
|
|
204
|
+
lines.splice(profilesIndex, 0, ...mcpLines, "");
|
|
205
|
+
} else {
|
|
206
|
+
// Just append at end
|
|
207
|
+
lines.push("", ...mcpLines);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
content = lines.join("\n");
|
|
212
|
+
await writeConfigFile(content);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Add a capability to a profile's capabilities array
|
|
217
|
+
*/
|
|
218
|
+
export async function patchAddToProfile(
|
|
219
|
+
profileName: string,
|
|
220
|
+
capabilityName: string,
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
let content = await readConfigFile();
|
|
223
|
+
const lines = content.split("\n");
|
|
224
|
+
|
|
225
|
+
// Find the profile section
|
|
226
|
+
const profilePattern = new RegExp(`^\\[profiles\\.${escapeRegExp(profileName)}\\]$`);
|
|
227
|
+
const profileIndex = findSection(lines, profilePattern);
|
|
228
|
+
|
|
229
|
+
if (profileIndex !== -1) {
|
|
230
|
+
// Profile exists, find and modify capabilities line
|
|
231
|
+
const profileEnd = findSectionEnd(lines, profileIndex);
|
|
232
|
+
|
|
233
|
+
let capabilitiesLineIndex = -1;
|
|
234
|
+
for (let i = profileIndex + 1; i < profileEnd; i++) {
|
|
235
|
+
const line = lines[i];
|
|
236
|
+
if (line === undefined) continue;
|
|
237
|
+
const trimmed = line.trim();
|
|
238
|
+
if (trimmed.startsWith("capabilities")) {
|
|
239
|
+
capabilitiesLineIndex = i;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (capabilitiesLineIndex !== -1) {
|
|
245
|
+
// Parse and modify the existing capabilities array
|
|
246
|
+
const line = lines[capabilitiesLineIndex];
|
|
247
|
+
if (line !== undefined) {
|
|
248
|
+
const match = line.match(/capabilities\s*=\s*\[(.*)\]/);
|
|
249
|
+
if (match && match[1] !== undefined) {
|
|
250
|
+
const existingCaps = match[1]
|
|
251
|
+
.split(",")
|
|
252
|
+
.map((s) => s.trim())
|
|
253
|
+
.filter((s) => s.length > 0);
|
|
254
|
+
|
|
255
|
+
// Check if capability already exists
|
|
256
|
+
const quotedCap = `"${capabilityName}"`;
|
|
257
|
+
if (!existingCaps.includes(quotedCap)) {
|
|
258
|
+
existingCaps.push(quotedCap);
|
|
259
|
+
const indent = line.match(/^(\s*)/)?.[1] ?? "";
|
|
260
|
+
lines[capabilitiesLineIndex] = `${indent}capabilities = [${existingCaps.join(", ")}]`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
// No capabilities line, add one after profile header
|
|
266
|
+
lines.splice(profileIndex + 1, 0, `capabilities = ["${capabilityName}"]`);
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
// Profile doesn't exist, create it
|
|
270
|
+
// Find where to insert (after other profiles or at end)
|
|
271
|
+
const anyProfileIndex = findSection(lines, /^\[profiles\./);
|
|
272
|
+
|
|
273
|
+
if (anyProfileIndex !== -1) {
|
|
274
|
+
// Find end of all profiles sections
|
|
275
|
+
let lastProfileEnd = anyProfileIndex;
|
|
276
|
+
for (let i = anyProfileIndex; i < lines.length; i++) {
|
|
277
|
+
const line = lines[i];
|
|
278
|
+
if (line === undefined) continue;
|
|
279
|
+
const trimmed = line.trim();
|
|
280
|
+
if (/^\[profiles\./.test(trimmed)) {
|
|
281
|
+
lastProfileEnd = findSectionEnd(lines, i);
|
|
282
|
+
} else if (/^\[(?!profiles\.)/.test(trimmed) && !trimmed.startsWith("#")) {
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Insert after the last profile
|
|
287
|
+
lines.splice(
|
|
288
|
+
lastProfileEnd,
|
|
289
|
+
0,
|
|
290
|
+
"",
|
|
291
|
+
`[profiles.${profileName}]`,
|
|
292
|
+
`capabilities = ["${capabilityName}"]`,
|
|
293
|
+
);
|
|
294
|
+
} else {
|
|
295
|
+
// No profiles at all, add at end
|
|
296
|
+
lines.push("", `[profiles.${profileName}]`, `capabilities = ["${capabilityName}"]`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
content = lines.join("\n");
|
|
301
|
+
await writeConfigFile(content);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Escape special regex characters in a string
|
|
306
|
+
*/
|
|
307
|
+
function escapeRegExp(str: string): string {
|
|
308
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
309
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook system constants
|
|
3
|
+
*
|
|
4
|
+
* All constants are defined as readonly arrays/objects to enable
|
|
5
|
+
* TypeScript literal type inference.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** All supported hook events */
|
|
9
|
+
export const HOOK_EVENTS = [
|
|
10
|
+
"PreToolUse",
|
|
11
|
+
"PostToolUse",
|
|
12
|
+
"PermissionRequest",
|
|
13
|
+
"UserPromptSubmit",
|
|
14
|
+
"Stop",
|
|
15
|
+
"SubagentStop",
|
|
16
|
+
"Notification",
|
|
17
|
+
"SessionStart",
|
|
18
|
+
"SessionEnd",
|
|
19
|
+
"PreCompact",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
/** Events that support matchers (regex patterns to filter tool names) */
|
|
23
|
+
export const MATCHER_EVENTS = [
|
|
24
|
+
"PreToolUse",
|
|
25
|
+
"PostToolUse",
|
|
26
|
+
"PermissionRequest",
|
|
27
|
+
"Notification",
|
|
28
|
+
"SessionStart",
|
|
29
|
+
"PreCompact",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
/** Events that support prompt-type hooks (LLM evaluation) */
|
|
33
|
+
export const PROMPT_HOOK_EVENTS = [
|
|
34
|
+
"Stop",
|
|
35
|
+
"SubagentStop",
|
|
36
|
+
"UserPromptSubmit",
|
|
37
|
+
"PreToolUse",
|
|
38
|
+
"PermissionRequest",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
/** Hook execution types */
|
|
42
|
+
export const HOOK_TYPES = ["command", "prompt"] as const;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Common tool matchers (for validation hints, not exhaustive)
|
|
46
|
+
* These are the tools available in Claude Code that hooks commonly target.
|
|
47
|
+
*/
|
|
48
|
+
export const COMMON_TOOL_MATCHERS = [
|
|
49
|
+
"Bash",
|
|
50
|
+
"Read",
|
|
51
|
+
"Write",
|
|
52
|
+
"Edit",
|
|
53
|
+
"Glob",
|
|
54
|
+
"Grep",
|
|
55
|
+
"Task",
|
|
56
|
+
"WebFetch",
|
|
57
|
+
"WebSearch",
|
|
58
|
+
"NotebookEdit",
|
|
59
|
+
"LSP",
|
|
60
|
+
"TodoWrite",
|
|
61
|
+
"AskUserQuestion",
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
64
|
+
/** Notification type matchers */
|
|
65
|
+
export const NOTIFICATION_MATCHERS = [
|
|
66
|
+
"permission_prompt",
|
|
67
|
+
"idle_prompt",
|
|
68
|
+
"auth_success",
|
|
69
|
+
"elicitation_dialog",
|
|
70
|
+
] as const;
|
|
71
|
+
|
|
72
|
+
/** SessionStart source matchers */
|
|
73
|
+
export const SESSION_START_MATCHERS = ["startup", "resume", "clear", "compact"] as const;
|
|
74
|
+
|
|
75
|
+
/** PreCompact trigger matchers */
|
|
76
|
+
export const PRE_COMPACT_MATCHERS = ["manual", "auto"] as const;
|
|
77
|
+
|
|
78
|
+
/** Default timeout for command hooks (in seconds) */
|
|
79
|
+
export const DEFAULT_COMMAND_TIMEOUT = 60;
|
|
80
|
+
|
|
81
|
+
/** Default timeout for prompt hooks (in seconds) */
|
|
82
|
+
export const DEFAULT_PROMPT_TIMEOUT = 30;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Environment variable mappings between OmniDev and Claude Code
|
|
86
|
+
*
|
|
87
|
+
* When capabilities define hooks, they use OMNIDEV_ prefixed variables.
|
|
88
|
+
* When writing to .claude/settings.json, these are transformed to CLAUDE_ variables.
|
|
89
|
+
* When importing external capabilities, CLAUDE_ variables are transformed to OMNIDEV_.
|
|
90
|
+
*/
|
|
91
|
+
export const VARIABLE_MAPPINGS = {
|
|
92
|
+
OMNIDEV_CAPABILITY_ROOT: "CLAUDE_PLUGIN_ROOT",
|
|
93
|
+
OMNIDEV_PROJECT_DIR: "CLAUDE_PROJECT_DIR",
|
|
94
|
+
} as const;
|
|
95
|
+
|
|
96
|
+
/** The hooks configuration filename within a capability */
|
|
97
|
+
export const HOOKS_CONFIG_FILENAME = "hooks.toml";
|
|
98
|
+
|
|
99
|
+
/** The hooks directory name within a capability */
|
|
100
|
+
export const HOOKS_DIRECTORY = "hooks";
|