@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/apply.mjs +49 -0
- package/bin.js +3 -0
- package/cli.mjs +141 -0
- package/commands/drop.mjs +88 -0
- package/commands/init.mjs +75 -0
- package/commands/nuke.mjs +95 -0
- package/commands/pull.mjs +252 -0
- package/commands/push.mjs +214 -0
- package/commands/search.mjs +183 -0
- package/commands/status.mjs +108 -0
- package/config.mjs +70 -0
- package/constants.mjs +7 -0
- package/fmt.mjs +99 -0
- package/git.mjs +61 -0
- package/glob.mjs +22 -0
- package/integrate.mjs +466 -0
- package/link.mjs +209 -0
- package/manifest.mjs +62 -0
- package/mcp.mjs +166 -0
- package/package.json +47 -0
- package/paths.mjs +139 -0
- package/plan.mjs +133 -0
- package/registry.mjs +138 -0
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
|
+
}
|