aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
361 lines (307 loc) • 10.6 kB
JavaScript
/**
* MCP Profile Registry (Runtime ESM)
*
* Named, ordered subsets of registered MCP servers.
* Stored in ~/.aiwg/mcp-profiles.json.
*
* This is the runtime .mjs version used by cli.mjs.
* profiles.ts exists for type checking and vitest.
*
* @implements #889
*/
import { readFile, writeFile, mkdir } from 'fs/promises';
import { resolve } from 'path';
import { homedir } from 'os';
import { existsSync } from 'fs';
// ─────────────────────────────────────────────
// Config dir resolution (mirrors registry.mjs)
// ─────────────────────────────────────────────
function resolveConfigDir(overridePath) {
const envOverride = process.env.AIWG_CONFIG;
if (overridePath) return resolve(overridePath);
if (envOverride) return resolve(envOverride);
const primary = resolve(homedir(), '.aiwg');
if (existsSync(primary)) return primary;
const fallback = resolve(homedir(), '.config/aiwg');
if (existsSync(fallback)) return fallback;
return primary;
}
// ─────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────
const PROFILES_FILENAME = 'mcp-profiles.json';
const RESERVED_NAMES = new Set(['all', 'none', 'default']);
const NAME_RE = /^[a-z0-9-]+$/;
const DEFAULT_DATA = {
apiVersion: 'aiwg.io/v1',
kind: 'McpProfileRegistry',
profiles: {},
};
// ─────────────────────────────────────────────
// Preset profiles
// ─────────────────────────────────────────────
export const PRESET_PROFILES = {
minimal: {
description: 'Minimal toolset for smoke tests (~6K token budget)',
servers: [],
providerOverrides: {},
},
dev: {
description: 'Code editing + git + memory (~12K token budget)',
servers: ['git-gitea', 'codeindex-codehound', 'memory-fortemi'],
providerOverrides: {
codex: { toolDeny: ['git-gitea__delete_*', 'git-gitea__actions_config_write'] },
},
},
ops: {
description: 'Infra + git + CMDB operations (~14K token budget)',
servers: ['git-gitea', 'cmdb-itassets', 'memory-fortemi'],
providerOverrides: {},
},
research: {
description: 'Documentation + memory + calendar (~10K token budget)',
servers: ['memory-fortemi', 'claude_ai_Google_Drive', 'claude_ai_Google_Calendar'],
providerOverrides: {},
},
incident: {
description: 'Incident response — git + CMDB + memory (~16K token budget)',
servers: ['git-gitea', 'cmdb-itassets', 'memory-fortemi', 'codeindex-codehound'],
providerOverrides: {},
},
full: {
description: 'All registered servers — for exploration (~21K token budget)',
servers: ['__all__'],
providerOverrides: {},
},
};
// ─────────────────────────────────────────────
// Registry class
// ─────────────────────────────────────────────
export class McpProfileRegistry {
#configDir;
#cache = null;
constructor(configDirOverride) {
this.#configDir = resolveConfigDir(configDirOverride);
}
getPath() {
return resolve(this.#configDir, PROFILES_FILENAME);
}
async load() {
if (this.#cache) return this.#cache;
const filePath = this.getPath();
try {
const content = await readFile(filePath, 'utf-8');
const parsed = JSON.parse(content);
this.#cache = {
...DEFAULT_DATA,
...parsed,
profiles: parsed.profiles || {},
};
} catch {
this.#cache = { ...DEFAULT_DATA, profiles: {} };
}
return this.#cache;
}
async save() {
if (!this.#cache) return;
await mkdir(this.#configDir, { recursive: true });
await writeFile(
this.getPath(),
JSON.stringify(this.#cache, null, 2) + '\n',
'utf-8',
);
}
/**
* Validate a profile name. Throws on invalid.
*/
#validateName(name) {
if (!NAME_RE.test(name)) {
throw new Error(
`Invalid profile name "${name}". Names must match [a-z0-9-]+.`,
);
}
if (RESERVED_NAMES.has(name)) {
throw new Error(
`"${name}" is a reserved profile name. Choose a different name.`,
);
}
}
/**
* Validate that all server names exist in the server registry.
* Skip validation for the special '__all__' sentinel.
*/
async #validateServers(serverNames, serverRegistry) {
if (!serverRegistry) return; // skip if no registry provided
const missing = [];
for (const name of serverNames) {
if (name === '__all__') continue;
const server = await serverRegistry.get(name);
if (!server) missing.push(name);
}
if (missing.length > 0) {
throw new Error(
`Server(s) not found in registry: ${missing.join(', ')}.\n` +
`Use "aiwg mcp list" to see registered servers.`,
);
}
}
/** Add a new profile */
async add(profile, serverRegistry) {
this.#validateName(profile.name);
const data = await this.load();
if (data.profiles[profile.name]) {
throw new Error(
`Profile "${profile.name}" already exists. Use "aiwg mcp profile edit" to modify it.`,
);
}
await this.#validateServers(profile.servers ?? [], serverRegistry);
data.profiles[profile.name] = {
name: profile.name,
description: profile.description,
servers: profile.servers ?? [],
providerOverrides: profile.providerOverrides ?? {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await this.save();
}
/** Get a profile by name */
async get(name) {
const data = await this.load();
return data.profiles[name];
}
/** List all profiles */
async list() {
const data = await this.load();
return Object.values(data.profiles);
}
/** Edit an existing profile (add/remove servers, update description) */
async edit(name, changes, serverRegistry) {
const data = await this.load();
const existing = data.profiles[name];
if (!existing) {
throw new Error(`Profile "${name}" not found.`);
}
const current = { ...existing };
if (changes.description !== undefined) {
current.description = changes.description;
}
if (changes.addServers && changes.addServers.length > 0) {
await this.#validateServers(changes.addServers, serverRegistry);
for (const s of changes.addServers) {
if (!current.servers.includes(s)) current.servers.push(s);
}
}
if (changes.removeServers && changes.removeServers.length > 0) {
current.servers = current.servers.filter(
(s) => !changes.removeServers.includes(s),
);
}
current.updatedAt = new Date().toISOString();
data.profiles[name] = current;
await this.save();
return data.profiles[name];
}
/** Remove a profile */
async remove(name) {
const data = await this.load();
if (!data.profiles[name]) {
throw new Error(`Profile "${name}" not found.`);
}
delete data.profiles[name];
await this.save();
}
/**
* Resolve the effective server list for a profile.
* If the profile contains '__all__', expand to all servers in serverRegistry.
*/
async resolveServers(name, serverRegistry) {
const profile = await this.get(name);
if (!profile) {
throw new Error(`Profile "${name}" not found.`);
}
if (profile.servers.includes('__all__') && serverRegistry) {
return serverRegistry.list();
}
if (!serverRegistry) return profile.servers;
const resolved = [];
for (const serverName of profile.servers) {
const server = await serverRegistry.get(serverName);
if (server) resolved.push(server);
}
return resolved;
}
/** Import profiles from a JSON file */
async importFrom(filePath) {
const content = await readFile(filePath, 'utf-8');
const imported = JSON.parse(content);
const data = await this.load();
let added = 0;
let updated = 0;
const profiles = imported.profiles ?? (
typeof imported === 'object' && !Array.isArray(imported)
? imported
: {}
);
for (const [name, profile] of Object.entries(profiles)) {
try {
this.#validateName(name);
} catch {
continue; // skip invalid names silently during import
}
if (data.profiles[name]) {
data.profiles[name] = {
...data.profiles[name],
...profile,
name,
updatedAt: new Date().toISOString(),
};
updated++;
} else {
data.profiles[name] = {
name,
description: profile.description,
servers: profile.servers ?? [],
providerOverrides: profile.providerOverrides ?? {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
added++;
}
}
await this.save();
return { added, updated };
}
/** Export a profile (or all profiles) to a JSON file */
async exportTo(filePath, profileName) {
const data = await this.load();
const toExport = profileName
? { [profileName]: data.profiles[profileName] }
: data.profiles;
if (profileName && !data.profiles[profileName]) {
throw new Error(`Profile "${profileName}" not found.`);
}
await writeFile(filePath, JSON.stringify({ profiles: toExport }, null, 2) + '\n', 'utf-8');
}
/** Install preset profiles (does not overwrite existing) */
async initPresets() {
const data = await this.load();
let added = 0;
for (const [name, preset] of Object.entries(PRESET_PROFILES)) {
if (!data.profiles[name]) {
data.profiles[name] = {
name,
...preset,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
added++;
}
}
await this.save();
return { added, total: Object.keys(PRESET_PROFILES).length };
}
clearCache() {
this.#cache = null;
}
}