@ghl-ai/aw 0.1.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/manifest.mjs ADDED
@@ -0,0 +1,62 @@
1
+ // manifest.mjs — .sync-manifest.json management. Zero dependencies.
2
+
3
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { hashFile } from './registry.mjs';
6
+
7
+ const MANIFEST_FILE = '.sync-manifest.json';
8
+
9
+ export function manifestPath(workspaceDir) {
10
+ return join(workspaceDir, MANIFEST_FILE);
11
+ }
12
+
13
+ export function load(workspaceDir) {
14
+ const p = manifestPath(workspaceDir);
15
+ if (!existsSync(p)) return { version: 3, synced_at: '', namespace: '', files: {} };
16
+ try {
17
+ return JSON.parse(readFileSync(p, 'utf8'));
18
+ } catch {
19
+ return { version: 3, synced_at: '', namespace: '', files: {} };
20
+ }
21
+ }
22
+
23
+ export function save(workspaceDir, manifest) {
24
+ writeFileSync(manifestPath(workspaceDir), JSON.stringify(manifest, null, 2) + '\n');
25
+ }
26
+
27
+ export function update(workspaceDir, actions, namespaceName) {
28
+ const manifest = load(workspaceDir);
29
+ manifest.synced_at = new Date().toISOString();
30
+ manifest.namespace = namespaceName || manifest.namespace;
31
+ manifest.version = 3;
32
+
33
+ for (const act of actions) {
34
+ const manifestKey = act.targetFilename;
35
+
36
+ if (act.action === 'ADD' || act.action === 'UPDATE' || act.action === 'UNCHANGED') {
37
+ if (existsSync(act.targetPath)) {
38
+ manifest.files[manifestKey] = {
39
+ sha256: hashFile(act.targetPath),
40
+ source: act.source,
41
+ registry_sha256: act.registryHash,
42
+ };
43
+ }
44
+ } else if (act.action === 'CONFLICT') {
45
+ // Update local hash but keep OLD registry_sha256
46
+ // so next sync still detects the unacknowledged registry change
47
+ if (existsSync(act.targetPath)) {
48
+ const existing = manifest.files[manifestKey];
49
+ manifest.files[manifestKey] = {
50
+ sha256: hashFile(act.targetPath),
51
+ source: act.source,
52
+ registry_sha256: existing?.registry_sha256 ?? act.registryHash,
53
+ };
54
+ }
55
+ } else if (act.action === 'ORPHAN') {
56
+ delete manifest.files[manifestKey];
57
+ }
58
+ }
59
+
60
+ save(workspaceDir, manifest);
61
+ return manifest;
62
+ }
package/mcp.mjs ADDED
@@ -0,0 +1,166 @@
1
+ // mcp.mjs — MCP config generation for all 3 IDEs (init only, never overwrites)
2
+
3
+ import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import * as fmt from './fmt.mjs';
7
+
8
+ const HOME = homedir();
9
+
10
+ /**
11
+ * Auto-detect MCP server paths.
12
+ */
13
+ function detectPaths() {
14
+ const ghlAiBridge = join(HOME, '.claude', 'ghl-ai-bridge.mjs');
15
+ const gitJenkinsCandidates = [
16
+ join(HOME, 'Documents', 'GitHub', 'git-jenkins-mcp', 'dist', 'index.js'),
17
+ resolve('..', 'git-jenkins-mcp', 'dist', 'index.js'),
18
+ ];
19
+
20
+ const gitJenkinsPath = gitJenkinsCandidates.find(p => existsSync(p)) || null;
21
+ const ghlMcpUrl = process.env.GHL_MCP_URL || 'https://ghl-ai-staging.highlevel-staging.com';
22
+
23
+ return {
24
+ ghlAiBridge: existsSync(ghlAiBridge) ? ghlAiBridge : null,
25
+ gitJenkinsPath,
26
+ ghlMcpUrl,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Setup MCP configs for all IDEs. Only writes if file doesn't exist.
32
+ */
33
+ export function setupMcp(cwd, namespace) {
34
+ const paths = detectPaths();
35
+ let created = 0;
36
+
37
+ // .claude/settings.local.json
38
+ created += writeIfMissing(
39
+ join(cwd, '.claude', 'settings.local.json'),
40
+ () => {
41
+ const servers = {};
42
+ if (paths.ghlAiBridge) {
43
+ servers['ghl-ai'] = {
44
+ command: 'node',
45
+ args: [paths.ghlAiBridge],
46
+ env: { GHL_MCP_URL: paths.ghlMcpUrl, TEAM_NAME: namespace || '' },
47
+ };
48
+ }
49
+ if (paths.gitJenkinsPath) {
50
+ servers['git-jenkins'] = {
51
+ command: 'node',
52
+ args: [paths.gitJenkinsPath],
53
+ };
54
+ }
55
+ if (Object.keys(servers).length === 0) return null;
56
+ return JSON.stringify({ mcpServers: servers }, null, 2) + '\n';
57
+ }
58
+ );
59
+
60
+ // .cursor/mcp.json
61
+ created += writeIfMissing(
62
+ join(cwd, '.cursor', 'mcp.json'),
63
+ () => {
64
+ const servers = {};
65
+ if (paths.ghlAiBridge) {
66
+ servers['ghl-ai'] = {
67
+ command: 'node',
68
+ args: [paths.ghlAiBridge],
69
+ env: { GHL_MCP_URL: paths.ghlMcpUrl, TEAM_NAME: namespace || '' },
70
+ };
71
+ }
72
+ if (paths.gitJenkinsPath) {
73
+ servers['git-jenkins'] = {
74
+ command: 'node',
75
+ args: [paths.gitJenkinsPath],
76
+ };
77
+ }
78
+ if (Object.keys(servers).length === 0) return null;
79
+ return JSON.stringify({ mcpServers: servers }, null, 2) + '\n';
80
+ }
81
+ );
82
+
83
+ // .codex/config.toml — append MCP servers
84
+ const codexConfig = join(cwd, '.codex', 'config.toml');
85
+ if (!hasMcpSection(codexConfig)) {
86
+ created += writeIfMissing(
87
+ codexConfig,
88
+ () => {
89
+ const lines = [];
90
+ if (paths.ghlAiBridge) {
91
+ lines.push(
92
+ '[mcp_servers.ghl-ai]',
93
+ 'command = "node"',
94
+ `args = ["${paths.ghlAiBridge}"]`,
95
+ '',
96
+ '[mcp_servers.ghl-ai.env]',
97
+ `GHL_MCP_URL = "${paths.ghlMcpUrl}"`,
98
+ `TEAM_NAME = "${namespace || ''}"`,
99
+ '',
100
+ );
101
+ }
102
+ if (paths.gitJenkinsPath) {
103
+ lines.push(
104
+ '[mcp_servers.git-jenkins]',
105
+ 'command = "node"',
106
+ `args = ["${paths.gitJenkinsPath}"]`,
107
+ '',
108
+ );
109
+ }
110
+ if (lines.length === 0) return null;
111
+ return lines.join('\n');
112
+ },
113
+ true // append mode
114
+ );
115
+ }
116
+
117
+ // mcp.json at project root (Claude Code auto-discovery)
118
+ created += writeIfMissing(
119
+ join(cwd, 'mcp.json'),
120
+ () => {
121
+ const servers = {};
122
+ if (paths.ghlAiBridge) {
123
+ servers['ghl-ai'] = {
124
+ command: 'node',
125
+ args: [paths.ghlAiBridge],
126
+ env: { GHL_MCP_URL: paths.ghlMcpUrl, TEAM_NAME: namespace || '' },
127
+ };
128
+ }
129
+ if (Object.keys(servers).length === 0) return null;
130
+ return JSON.stringify({ mcpServers: servers }, null, 2) + '\n';
131
+ }
132
+ );
133
+
134
+ if (created > 0) {
135
+ fmt.logSuccess(`Created ${created} MCP config${created > 1 ? 's' : ''}`);
136
+ }
137
+
138
+ const warnings = [];
139
+ if (!paths.ghlAiBridge) warnings.push('ghl-ai bridge not found at ~/.claude/ghl-ai-bridge.mjs');
140
+ if (!paths.gitJenkinsPath) warnings.push('git-jenkins MCP not found');
141
+ for (const w of warnings) {
142
+ fmt.logWarn(w);
143
+ }
144
+
145
+ return created;
146
+ }
147
+
148
+ function writeIfMissing(filePath, contentFn, append = false) {
149
+ if (!append && existsSync(filePath)) return 0;
150
+ const content = contentFn();
151
+ if (!content) return 0;
152
+ mkdirSync(join(filePath, '..'), { recursive: true });
153
+ if (append && existsSync(filePath)) {
154
+ const existing = readFileSync(filePath, 'utf8');
155
+ writeFileSync(filePath, existing + '\n' + content);
156
+ } else {
157
+ writeFileSync(filePath, content);
158
+ }
159
+ return 1;
160
+ }
161
+
162
+ function hasMcpSection(filePath) {
163
+ if (!existsSync(filePath)) return false;
164
+ const content = readFileSync(filePath, 'utf8');
165
+ return content.includes('[mcp_servers');
166
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@ghl-ai/aw",
3
+ "version": "0.1.0",
4
+ "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
+ "type": "module",
6
+ "bin": {
7
+ "aw": "./bin.js"
8
+ },
9
+ "files": [
10
+ "bin.js",
11
+ "cli.mjs",
12
+ "commands/",
13
+ "config.mjs",
14
+ "constants.mjs",
15
+ "fmt.mjs",
16
+ "git.mjs",
17
+ "glob.mjs",
18
+ "integrate.mjs",
19
+ "link.mjs",
20
+ "manifest.mjs",
21
+ "mcp.mjs",
22
+ "paths.mjs",
23
+ "plan.mjs",
24
+ "registry.mjs",
25
+ "apply.mjs"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "keywords": [
31
+ "ai",
32
+ "agents",
33
+ "claude",
34
+ "agentic-workspace",
35
+ "cli"
36
+ ],
37
+ "author": "GoHighLevel",
38
+ "license": "MIT",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@clack/prompts": "^1.1.0",
44
+ "chalk": "^5.6.2",
45
+ "figlet": "^1.11.0"
46
+ }
47
+ }
package/paths.mjs ADDED
@@ -0,0 +1,139 @@
1
+ // paths.mjs — Convert between local workspace paths and registry paths. Zero dependencies.
2
+
3
+ import { existsSync, statSync, lstatSync, readlinkSync } from 'node:fs';
4
+ import { join, resolve, relative, basename, dirname } from 'node:path';
5
+
6
+ const VALID_TYPES = new Set(['agents', 'skills', 'commands', 'blueprints', 'evals']);
7
+
8
+ // IDE dirs that may contain symlinks into .aw_registry/
9
+ const IDE_PREFIXES = ['.claude/', '.cursor/', '.codex/', '.agents/'];
10
+
11
+ /**
12
+ * Convert a local workspace path to a registry path.
13
+ *
14
+ * .aw_registry/ghl/agents/architecture-reviewer.md
15
+ * → ghl/agents/architecture-reviewer
16
+ *
17
+ * .aw_registry/dev-nitro/skills/my-skill/SKILL.md
18
+ * → dev-nitro/skills/my-skill/SKILL
19
+ */
20
+ export function localToRegistry(localPath) {
21
+ // Normalize to relative path inside .aw_registry/
22
+ let rel = localPath;
23
+ const awIdx = rel.indexOf('.aw_registry/');
24
+ if (awIdx !== -1) {
25
+ rel = rel.slice(awIdx + '.aw_registry/'.length);
26
+ } else if (rel.startsWith('.aw_registry\\')) {
27
+ rel = rel.slice('.aw_registry\\'.length);
28
+ }
29
+
30
+ // Strip .md extension and return — path mirrors registry directly
31
+ return rel.replace(/\.md$/, '') || null;
32
+ }
33
+
34
+ /**
35
+ * Convert a registry path to a local workspace path.
36
+ *
37
+ * ghl/agents/architecture-reviewer
38
+ * → ghl/agents/architecture-reviewer.md
39
+ *
40
+ * dev-nitro/skills/my-skill/SKILL
41
+ * → dev-nitro/skills/my-skill/SKILL.md
42
+ *
43
+ * Returns relative path inside .aw_registry/
44
+ */
45
+ export function registryToLocal(registryPath) {
46
+ const clean = registryPath.replace(/\.md$/, '');
47
+ const parts = clean.split('/');
48
+
49
+ // Find the type segment
50
+ let typeIdx = -1;
51
+ for (let i = 0; i < parts.length; i++) {
52
+ if (VALID_TYPES.has(parts[i])) {
53
+ typeIdx = i;
54
+ break;
55
+ }
56
+ }
57
+
58
+ if (typeIdx === -1 || typeIdx + 1 >= parts.length) return null;
59
+ if (typeIdx === 0) return null; // no namespace
60
+
61
+ const type = parts[typeIdx];
62
+
63
+ if (type === 'skills') {
64
+ // Skills may reference a subfile or just the folder
65
+ if (parts.length > typeIdx + 2) {
66
+ // Subfile: namespace/skills/slug/subpath → namespace/skills/slug/subpath.md
67
+ return clean + '.md';
68
+ }
69
+ // Just the skill folder
70
+ return clean;
71
+ } else {
72
+ // Flat files: namespace/agents/slug → namespace/agents/slug.md
73
+ return clean + '.md';
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Detect input type and normalize to registry path.
79
+ * Accepts either local path or registry path.
80
+ * Returns { registryPath, localPath (if resolvable) }
81
+ */
82
+ export function resolveInput(input, workspaceDir) {
83
+ const normalized = input.replace(/\/+$/, '');
84
+
85
+ // Resolve IDE symlink paths → .aw_registry/ paths
86
+ const rewritten = resolveIdePath(normalized);
87
+
88
+ // Local path — starts with .aw_registry/
89
+ if (rewritten.startsWith('.aw_registry/') || rewritten.startsWith('.aw_registry\\')) {
90
+ const registryPath = localToRegistry(rewritten);
91
+ const absPath = resolve(rewritten);
92
+ return {
93
+ registryPath: registryPath?.replace(/\.md$/, '') || null,
94
+ localAbsPath: absPath,
95
+ isLocalPath: true,
96
+ };
97
+ }
98
+
99
+ // Registry path
100
+ const cleanPath = rewritten.replace(/\.md$/, '');
101
+ const localRel = registryToLocal(cleanPath);
102
+ return {
103
+ registryPath: cleanPath,
104
+ localAbsPath: localRel ? join(workspaceDir, localRel) : null,
105
+ isLocalPath: false,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Rewrite IDE paths (.claude/agents/foo.md) to .aw_registry/ paths
111
+ * by resolving the symlink target. Falls back to input if not a symlink.
112
+ */
113
+ function resolveIdePath(input) {
114
+ const isIde = IDE_PREFIXES.some(p => input.startsWith(p));
115
+ if (!isIde) return input;
116
+
117
+ // Try each file/dir in the path to find a symlink
118
+ const parts = input.split('/');
119
+ for (let i = 2; i <= parts.length; i++) {
120
+ const candidate = parts.slice(0, i).join('/');
121
+ try {
122
+ const stat = lstatSync(candidate);
123
+ if (stat.isSymbolicLink()) {
124
+ const target = resolve(dirname(candidate), readlinkSync(candidate));
125
+ const rest = parts.slice(i).join('/');
126
+ // Convert absolute target back to relative with .aw_registry/
127
+ const awIdx = target.indexOf('.aw_registry/');
128
+ if (awIdx !== -1) {
129
+ const awRel = target.slice(awIdx);
130
+ return rest ? `${awRel}/${rest}` : awRel;
131
+ }
132
+ }
133
+ } catch {
134
+ // doesn't exist yet, keep trying
135
+ }
136
+ }
137
+
138
+ return input;
139
+ }
package/plan.mjs ADDED
@@ -0,0 +1,133 @@
1
+ // plan.mjs — Compute sync plan (what actions to take). Zero dependencies.
2
+
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { walkRegistryTree, hashFile } from './registry.mjs';
6
+ import { load as loadManifest } from './manifest.mjs';
7
+ import { matchesAny } from './glob.mjs';
8
+
9
+ /**
10
+ * Compute sync actions from registry dirs → workspace.
11
+ *
12
+ * @param {Array<{name: string, path: string}>} registryDirs — sources to scan
13
+ * @param {string} workspaceDir — .aw_registry path
14
+ * @param {string[]} includePatterns — paths to filter (empty = all)
15
+ * @returns {{ actions: Array }}
16
+ */
17
+ export function computePlan(registryDirs, workspaceDir, includePatterns = [], { skipOrphans = false } = {}) {
18
+ const plan = new Map();
19
+
20
+ for (const { name, path } of registryDirs) {
21
+ for (const entry of walkRegistryTree(path, name)) {
22
+ // Apply include filter
23
+ if (includePatterns.length > 0 && !matchesAny(entry.registryPath, includePatterns)) {
24
+ continue;
25
+ }
26
+
27
+ // For skill files: key includes relative path to avoid collisions
28
+ // For other files: key is type/slug
29
+ const key = entry.skillRelPath
30
+ ? `${entry.type}/${entry.slug}/${entry.skillRelPath}`
31
+ : `${entry.type}/${entry.slug}`;
32
+ plan.set(key, { ...entry, source: entry.namespacePath || name });
33
+ }
34
+ }
35
+
36
+ const manifest = loadManifest(workspaceDir);
37
+ const actions = [];
38
+
39
+ for (const [key, entry] of plan) {
40
+ // Mirror registry structure: namespace/type/slug
41
+ // Skills: namespace/skills/slug/relPath
42
+ // Others: namespace/agents/slug.md
43
+ let targetFilename, targetPath;
44
+ if (entry.skillRelPath) {
45
+ targetFilename = `${entry.namespacePath}/${entry.type}/${entry.slug}/${entry.skillRelPath}`;
46
+ targetPath = join(workspaceDir, entry.namespacePath, entry.type, entry.slug, entry.skillRelPath);
47
+ } else {
48
+ targetFilename = `${entry.namespacePath}/${entry.type}/${entry.slug}.md`;
49
+ targetPath = join(workspaceDir, entry.namespacePath, entry.type, `${entry.slug}.md`);
50
+ }
51
+
52
+ const manifestKey = targetFilename;
53
+ const registryHash = hashFile(entry.sourcePath);
54
+
55
+ let action;
56
+
57
+ if (!existsSync(targetPath)) {
58
+ action = 'ADD';
59
+ } else {
60
+ const localHash = hashFile(targetPath);
61
+
62
+ if (localHash === registryHash) {
63
+ action = 'UNCHANGED';
64
+ } else {
65
+ const manifestEntry = manifest.files?.[manifestKey];
66
+ if (!manifestEntry) {
67
+ action = 'UPDATE';
68
+ } else {
69
+ const localChanged = localHash !== manifestEntry.sha256;
70
+ const registryChanged = registryHash !== manifestEntry.registry_sha256;
71
+ if (localChanged && registryChanged) {
72
+ action = 'CONFLICT';
73
+ } else if (localChanged) {
74
+ action = 'UNCHANGED';
75
+ } else {
76
+ action = 'UPDATE';
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ actions.push({
83
+ slug: entry.slug,
84
+ source: entry.source,
85
+ type: entry.type,
86
+ action,
87
+ sourcePath: entry.sourcePath,
88
+ targetPath,
89
+ targetFilename,
90
+ isDirectory: false,
91
+ registryHash,
92
+ namespacePath: entry.namespacePath,
93
+ registryPath: entry.registryPath,
94
+ });
95
+ }
96
+
97
+ // Orphans: in manifest but not in plan
98
+ if (manifest.files && !skipOrphans) {
99
+ for (const [manifestKey, manifestEntry] of Object.entries(manifest.files)) {
100
+ // manifestKey is now namespace/type/slug.md or namespace/type/slug/relPath
101
+ const parts = manifestKey.split('/');
102
+ // Find the type segment
103
+ let typeIdx = -1;
104
+ for (let i = 0; i < parts.length; i++) {
105
+ if (['agents', 'skills', 'commands', 'blueprints', 'evals'].includes(parts[i])) {
106
+ typeIdx = i;
107
+ break;
108
+ }
109
+ }
110
+ if (typeIdx === -1) continue;
111
+ const type = parts[typeIdx];
112
+ const slug = parts[typeIdx + 1]?.replace(/\.md$/, '');
113
+ const key = `${type}/${slug}`;
114
+ if (!plan.has(key)) {
115
+ actions.push({
116
+ slug,
117
+ source: manifestEntry.source || 'unknown',
118
+ type,
119
+ action: 'ORPHAN',
120
+ sourcePath: '',
121
+ targetPath: join(workspaceDir, manifestKey),
122
+ targetFilename: manifestKey,
123
+ isDirectory: false,
124
+ registryHash: '',
125
+ namespacePath: parts.slice(0, typeIdx).join('/'),
126
+ registryPath: '',
127
+ });
128
+ }
129
+ }
130
+ }
131
+
132
+ return { actions };
133
+ }
package/registry.mjs ADDED
@@ -0,0 +1,138 @@
1
+ // registry.mjs — Registry walking, scanning, hashing. Zero dependencies.
2
+
3
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
4
+ import { join, relative } from 'node:path';
5
+ import { createHash } from 'node:crypto';
6
+
7
+ const TYPE_DIRS = new Set(['agents', 'skills', 'commands', 'blueprints', 'evals']);
8
+
9
+ export function sha256(content) {
10
+ return createHash('sha256').update(content).digest('hex');
11
+ }
12
+
13
+ export function hashFile(filePath) {
14
+ return sha256(readFileSync(filePath));
15
+ }
16
+
17
+ export function getAllFiles(dirPath) {
18
+ const results = [];
19
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
20
+ const full = join(dirPath, entry.name);
21
+ if (entry.isDirectory()) {
22
+ results.push(...getAllFiles(full));
23
+ } else {
24
+ results.push(full);
25
+ }
26
+ }
27
+ return results;
28
+ }
29
+
30
+ /**
31
+ * Walk a registry directory tree and collect file-level entries.
32
+ * Every file gets its own entry with its full registry path.
33
+ * Skills are NOT atomic — each file inside a skill is registered individually.
34
+ */
35
+ export function walkRegistryTree(baseDir, baseName) {
36
+ const entries = [];
37
+ if (!baseDir || !existsSync(baseDir)) return entries;
38
+
39
+ function recurse(dir, pathSegments) {
40
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
41
+ if (entry.name.startsWith('.')) continue;
42
+ const fullPath = join(dir, entry.name);
43
+
44
+ if (entry.isDirectory() && TYPE_DIRS.has(entry.name)) {
45
+ const typeDir = entry.name;
46
+ const namespace = pathSegments.join('/');
47
+
48
+ if (typeDir === 'skills') {
49
+ // Skills are directories — register each file inside individually
50
+ for (const skillEntry of readdirSync(fullPath, { withFileTypes: true })) {
51
+ if (skillEntry.name.startsWith('.')) continue;
52
+ const skillPath = join(fullPath, skillEntry.name);
53
+ if (!statSync(skillPath).isDirectory()) continue;
54
+ const slug = skillEntry.name;
55
+
56
+ for (const file of getAllFiles(skillPath)) {
57
+ const relPath = relative(skillPath, file);
58
+ const relNoExt = relPath.replace(/\.md$/, '');
59
+ const registryPath = `${namespace}/${typeDir}/${slug}/${relNoExt}`;
60
+ entries.push({
61
+ slug, type: typeDir, sourcePath: file,
62
+ isDirectory: false, namespacePath: namespace, registryPath,
63
+ filename: relPath,
64
+ skillRelPath: relPath,
65
+ });
66
+ }
67
+ }
68
+ } else if (typeDir === 'evals') {
69
+ // Evals: evals/agents/<name>/<files>, evals/skills/<name>/<files>
70
+ for (const file of getAllFiles(fullPath)) {
71
+ const relFromEvals = relative(fullPath, file);
72
+ const parts = relFromEvals.split('/');
73
+ // slug = subtype/name (e.g., agents/architecture-reviewer)
74
+ const slug = parts.slice(0, -1).join('/');
75
+ const filename = parts[parts.length - 1];
76
+ const relNoExt = relFromEvals.replace(/\.md$/, '');
77
+ const registryPath = `${namespace}/${typeDir}/${relNoExt}`;
78
+ entries.push({
79
+ slug, type: typeDir, sourcePath: file,
80
+ isDirectory: false, namespacePath: namespace, registryPath,
81
+ filename,
82
+ skillRelPath: filename,
83
+ });
84
+ }
85
+ } else {
86
+ // Agents, commands, blueprints — flat files
87
+ for (const fileEntry of readdirSync(fullPath)) {
88
+ if (fileEntry === '.gitkeep' || fileEntry.startsWith('.')) continue;
89
+ const filePath = join(fullPath, fileEntry);
90
+ if (!statSync(filePath).isFile()) continue;
91
+ const slug = fileEntry.replace(/\.md$/, '');
92
+ const registryPath = `${namespace}/${typeDir}/${slug}`;
93
+ entries.push({
94
+ slug, type: typeDir, sourcePath: filePath,
95
+ isDirectory: false, namespacePath: namespace, registryPath,
96
+ filename: fileEntry,
97
+ });
98
+ }
99
+ }
100
+ } else if (entry.isDirectory()) {
101
+ recurse(fullPath, [...pathSegments, entry.name]);
102
+ }
103
+ }
104
+ }
105
+
106
+ recurse(baseDir, baseName ? [baseName] : []);
107
+ return entries;
108
+ }
109
+
110
+ /**
111
+ * Extract metadata from a file (name, description from frontmatter).
112
+ */
113
+ export function extractMeta(filePath, slug) {
114
+ let name = slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
115
+ let description = '';
116
+
117
+ try {
118
+ const content = readFileSync(filePath, 'utf8');
119
+ if (content.startsWith('---')) {
120
+ const endIdx = content.indexOf('---', 3);
121
+ if (endIdx !== -1) {
122
+ const fm = content.slice(3, endIdx);
123
+ const nameMatch = fm.match(/^name:\s*(.+)$/m);
124
+ if (nameMatch) name = nameMatch[1].trim().replace(/^["']|["']$/g, '');
125
+ const descMatch = fm.match(/^description:\s*(.+)$/m);
126
+ if (descMatch) description = descMatch[1].trim().replace(/^["']|["']$/g, '');
127
+ }
128
+ }
129
+ if (name === slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())) {
130
+ const headingMatch = content.match(/^#\s+(.+)$/m);
131
+ if (headingMatch) name = headingMatch[1].trim();
132
+ }
133
+ } catch {
134
+ // Fall through
135
+ }
136
+
137
+ return { name, description };
138
+ }