@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
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// commands/pull.mjs — Pull content from registry
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, existsSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import * as config from '../config.mjs';
|
|
7
|
+
import * as fmt from '../fmt.mjs';
|
|
8
|
+
import { chalk } from '../fmt.mjs';
|
|
9
|
+
import { sparseCheckout, cleanup, includeToSparsePaths } from '../git.mjs';
|
|
10
|
+
import { walkRegistryTree } from '../registry.mjs';
|
|
11
|
+
import { matchesAny } from '../glob.mjs';
|
|
12
|
+
import { computePlan } from '../plan.mjs';
|
|
13
|
+
import { applyActions } from '../apply.mjs';
|
|
14
|
+
import { update as updateManifest } from '../manifest.mjs';
|
|
15
|
+
import { resolveInput } from '../paths.mjs';
|
|
16
|
+
import { linkWorkspace } from '../link.mjs';
|
|
17
|
+
import { generateCommands, copyInstructions } from '../integrate.mjs';
|
|
18
|
+
|
|
19
|
+
export function pullCommand(args) {
|
|
20
|
+
const input = args._positional?.[0] || '';
|
|
21
|
+
const cwd = process.cwd();
|
|
22
|
+
const workspaceDir = join(cwd, '.aw_registry');
|
|
23
|
+
const dryRun = args['--dry-run'] === true;
|
|
24
|
+
const verbose = args['-v'] === true || args['--verbose'] === true;
|
|
25
|
+
const renameNamespace = args._renameNamespace || null;
|
|
26
|
+
|
|
27
|
+
// No args = re-pull everything in sync config
|
|
28
|
+
if (!input) {
|
|
29
|
+
const cfg = config.load(workspaceDir);
|
|
30
|
+
if (!cfg) fmt.cancel('No .sync-config.json found. Run: aw init');
|
|
31
|
+
if (cfg.include.length === 0) {
|
|
32
|
+
fmt.cancel('Nothing to pull. Add paths first:\n\n aw pull <path>');
|
|
33
|
+
}
|
|
34
|
+
fmt.logInfo(`Pulling ${chalk.cyan(cfg.include.length)} synced path${cfg.include.length > 1 ? 's' : ''}...`);
|
|
35
|
+
for (const p of cfg.include) {
|
|
36
|
+
pullCommand({ ...args, _positional: [p], _skipIntegrate: true });
|
|
37
|
+
}
|
|
38
|
+
// Post-pull IDE integration (once after all paths pulled)
|
|
39
|
+
linkWorkspace(cwd);
|
|
40
|
+
generateCommands(cwd);
|
|
41
|
+
copyInstructions(cwd, null, cfg.namespace);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Resolve input — accepts both local and registry paths
|
|
46
|
+
const resolved = resolveInput(input, workspaceDir);
|
|
47
|
+
let pattern = resolved.registryPath;
|
|
48
|
+
|
|
49
|
+
if (!pattern) {
|
|
50
|
+
fmt.cancel(`Could not resolve "${input}" to a registry path`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Ensure workspace exists
|
|
54
|
+
if (!existsSync(workspaceDir)) {
|
|
55
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Load config
|
|
59
|
+
const cfg = config.load(workspaceDir);
|
|
60
|
+
if (!cfg) {
|
|
61
|
+
fmt.cancel('No .sync-config.json found. Run: aw init');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fetch from registry
|
|
65
|
+
const s = fmt.spinner();
|
|
66
|
+
s.start('Fetching from registry...');
|
|
67
|
+
|
|
68
|
+
const sparsePaths = includeToSparsePaths([pattern]);
|
|
69
|
+
let tempDir;
|
|
70
|
+
try {
|
|
71
|
+
tempDir = sparseCheckout(cfg.repo, sparsePaths);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
s.stop(chalk.red('Fetch failed'));
|
|
74
|
+
fmt.cancel(e.message);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const registryDirs = [];
|
|
79
|
+
const regBase = join(tempDir, 'registry');
|
|
80
|
+
|
|
81
|
+
if (existsSync(regBase)) {
|
|
82
|
+
for (const name of listDirs(regBase)) {
|
|
83
|
+
registryDirs.push({ name, path: join(regBase, name) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (registryDirs.length === 0) {
|
|
88
|
+
s.stop(chalk.red('Not found'));
|
|
89
|
+
if (args._silent) return;
|
|
90
|
+
fmt.cancel(`Nothing found in registry for ${chalk.cyan(pattern)}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Rename namespace if requested (e.g., [template] → dev)
|
|
94
|
+
if (renameNamespace) {
|
|
95
|
+
for (const rd of registryDirs) {
|
|
96
|
+
if (rd.name === pattern) {
|
|
97
|
+
rd.name = renameNamespace;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Also remap pattern for include filter matching
|
|
101
|
+
pattern = renameNamespace;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Validate pattern matches actual content
|
|
105
|
+
let hasMatch = false;
|
|
106
|
+
for (const { name, path } of registryDirs) {
|
|
107
|
+
const entries = walkRegistryTree(path, name);
|
|
108
|
+
if (entries.some(e => matchesAny(e.registryPath, [pattern]))) {
|
|
109
|
+
hasMatch = true;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!hasMatch) {
|
|
115
|
+
s.stop(chalk.red('Not found'));
|
|
116
|
+
cleanup(tempDir);
|
|
117
|
+
if (args._silent) return;
|
|
118
|
+
fmt.cancel(`Nothing found in registry for ${chalk.cyan(pattern)}\n\n Check the pattern exists in the registry repo.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fetched = registryDirs.map(d => chalk.cyan(d.name)).join(', ');
|
|
122
|
+
s.stop(`Fetched ${fetched}`);
|
|
123
|
+
|
|
124
|
+
// Add to config if not already there
|
|
125
|
+
if (!cfg.include.includes(pattern)) {
|
|
126
|
+
config.addPattern(workspaceDir, pattern);
|
|
127
|
+
fmt.logSuccess(`Added ${chalk.cyan(pattern)} to config`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Compute plan
|
|
131
|
+
const { actions } = computePlan(registryDirs, workspaceDir, [pattern], {
|
|
132
|
+
skipOrphans: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (dryRun) {
|
|
136
|
+
printDryRun(actions, verbose);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Apply
|
|
141
|
+
const s2 = fmt.spinner();
|
|
142
|
+
s2.start('Applying changes...');
|
|
143
|
+
const conflictCount = applyActions(actions);
|
|
144
|
+
updateManifest(workspaceDir, actions, cfg.namespace);
|
|
145
|
+
s2.stop('Changes applied');
|
|
146
|
+
|
|
147
|
+
// MCP registration (second-class — skip if not available)
|
|
148
|
+
if (cfg.namespace) {
|
|
149
|
+
registerMcp(cfg.namespace);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Summary
|
|
153
|
+
printSummary(actions, verbose, conflictCount);
|
|
154
|
+
|
|
155
|
+
// Post-pull IDE integration (skip if called from batch re-pull)
|
|
156
|
+
if (!args._skipIntegrate) {
|
|
157
|
+
linkWorkspace(cwd);
|
|
158
|
+
generateCommands(cwd);
|
|
159
|
+
copyInstructions(cwd, null, cfg.namespace);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
} finally {
|
|
163
|
+
cleanup(tempDir);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function listDirs(dir) {
|
|
168
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
169
|
+
.filter(d => d.isDirectory() && !d.name.startsWith('.'))
|
|
170
|
+
.map(d => d.name);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function registerMcp(namespace) {
|
|
174
|
+
const mcpUrl = process.env.GHL_MCP_URL;
|
|
175
|
+
if (!mcpUrl) return;
|
|
176
|
+
try {
|
|
177
|
+
execSync(`TEAM_NAME=${namespace} node scripts/register-team-mcp.mjs`, { stdio: 'pipe' });
|
|
178
|
+
fmt.logSuccess('MCP registration complete');
|
|
179
|
+
} catch {
|
|
180
|
+
fmt.logWarn('MCP registration failed (pull still successful)');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function printDryRun(actions, verbose) {
|
|
185
|
+
const counts = { ADD: 0, UPDATE: 0, CONFLICT: 0, ORPHAN: 0, UNCHANGED: 0 };
|
|
186
|
+
const lines = [];
|
|
187
|
+
|
|
188
|
+
for (const type of ['agents', 'skills', 'commands', 'blueprints', 'evals']) {
|
|
189
|
+
const items = actions.filter(a => a.type === type);
|
|
190
|
+
if (items.length === 0) continue;
|
|
191
|
+
|
|
192
|
+
lines.push(chalk.bold(`${type}/`));
|
|
193
|
+
for (const act of items.sort((a, b) => a.targetFilename.localeCompare(b.targetFilename))) {
|
|
194
|
+
counts[act.action] = (counts[act.action] || 0) + 1;
|
|
195
|
+
if (!verbose && act.action === 'UNCHANGED') continue;
|
|
196
|
+
const ns = act.namespacePath ? chalk.dim(` [${act.namespacePath}]`) : '';
|
|
197
|
+
lines.push(` ${fmt.actionLabel(act.action)} ${act.targetFilename}${ns}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (lines.length > 0) {
|
|
202
|
+
fmt.note(lines.join('\n'), 'Dry Run');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fmt.logInfo(`Summary: ${fmt.countSummary(counts)}`);
|
|
206
|
+
fmt.logWarn('No files modified (--dry-run)');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function printSummary(actions, verbose, conflictCount) {
|
|
210
|
+
const conflicts = actions.filter(a => a.action === 'CONFLICT');
|
|
211
|
+
|
|
212
|
+
for (const type of ['agents', 'skills', 'commands', 'blueprints', 'evals']) {
|
|
213
|
+
const typeActions = actions.filter(a => a.type === type);
|
|
214
|
+
if (typeActions.length === 0) continue;
|
|
215
|
+
|
|
216
|
+
const counts = { ADD: 0, UPDATE: 0, CONFLICT: 0, ORPHAN: 0, UNCHANGED: 0 };
|
|
217
|
+
for (const a of typeActions) counts[a.action]++;
|
|
218
|
+
|
|
219
|
+
const parts = [];
|
|
220
|
+
if (counts.ADD > 0) parts.push(chalk.green(`${counts.ADD} new`));
|
|
221
|
+
if (counts.UPDATE > 0) parts.push(chalk.cyan(`${counts.UPDATE} updated`));
|
|
222
|
+
if (counts.CONFLICT > 0) parts.push(chalk.red(`${counts.CONFLICT} conflict`));
|
|
223
|
+
const detail = parts.length > 0 ? ` (${parts.join(', ')})` : '';
|
|
224
|
+
|
|
225
|
+
fmt.logSuccess(`${typeActions.length} ${type} pulled${detail}`);
|
|
226
|
+
|
|
227
|
+
if (verbose) {
|
|
228
|
+
for (const a of typeActions.filter(a => a.action !== 'UNCHANGED')) {
|
|
229
|
+
const ns = a.namespacePath ? chalk.dim(` [${a.namespacePath}]`) : '';
|
|
230
|
+
fmt.logMessage(` ${fmt.actionLabel(a.action)} ${a.targetFilename}${ns}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (conflicts.length > 0) {
|
|
236
|
+
const conflictLines = conflicts.map(c => {
|
|
237
|
+
return `${chalk.red('both modified:')} ${c.type}/${c.targetFilename}`;
|
|
238
|
+
}).join('\n');
|
|
239
|
+
|
|
240
|
+
fmt.note(
|
|
241
|
+
conflictLines + '\n\n' +
|
|
242
|
+
chalk.dim('Fix conflicts, then re-run pull to verify.\n') +
|
|
243
|
+
chalk.dim('grep -r "<<<<<<< " .aw_registry/'),
|
|
244
|
+
chalk.red('Merge Conflicts')
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
fmt.outro(chalk.red('Pull completed with conflicts — resolve and re-run'));
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fmt.outro('Pull complete');
|
|
252
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// commands/push.mjs — Push local agent/skill to registry via PR
|
|
2
|
+
|
|
3
|
+
import { existsSync, statSync, mkdirSync, cpSync, mkdtempSync, readFileSync, appendFileSync } from 'node:fs';
|
|
4
|
+
import { basename, dirname, resolve, join } from 'node:path';
|
|
5
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import * as fmt from '../fmt.mjs';
|
|
8
|
+
import { chalk } from '../fmt.mjs';
|
|
9
|
+
import { REGISTRY_REPO, REGISTRY_BASE_BRANCH } from '../constants.mjs';
|
|
10
|
+
import { resolveInput } from '../paths.mjs';
|
|
11
|
+
import { load as loadManifest } from '../manifest.mjs';
|
|
12
|
+
import { hashFile } from '../registry.mjs';
|
|
13
|
+
|
|
14
|
+
export function pushCommand(args) {
|
|
15
|
+
const input = args._positional?.[0];
|
|
16
|
+
const dryRun = args['--dry-run'] === true;
|
|
17
|
+
const repo = args['--repo'] || REGISTRY_REPO;
|
|
18
|
+
const cwd = process.cwd();
|
|
19
|
+
const workspaceDir = join(cwd, '.aw_registry');
|
|
20
|
+
|
|
21
|
+
fmt.intro('aw push');
|
|
22
|
+
|
|
23
|
+
// No args = find and list modified files for user to push
|
|
24
|
+
if (!input) {
|
|
25
|
+
const manifest = loadManifest(workspaceDir);
|
|
26
|
+
const modified = [];
|
|
27
|
+
for (const [key, entry] of Object.entries(manifest.files || {})) {
|
|
28
|
+
const filePath = join(workspaceDir, key);
|
|
29
|
+
if (!existsSync(filePath)) continue;
|
|
30
|
+
const currentHash = hashFile(filePath);
|
|
31
|
+
if (currentHash !== entry.sha256) {
|
|
32
|
+
modified.push(key);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (modified.length === 0) {
|
|
36
|
+
fmt.cancel('Nothing to push — no modified files.\n\n To push a specific file:\n aw push <path>');
|
|
37
|
+
}
|
|
38
|
+
fmt.logInfo(`${chalk.bold(modified.length)} modified file${modified.length > 1 ? 's' : ''}:`);
|
|
39
|
+
for (const m of modified) {
|
|
40
|
+
fmt.logMessage(` ${chalk.yellow(m)}`);
|
|
41
|
+
}
|
|
42
|
+
fmt.cancel(`\nSpecify which file to push:\n aw push .aw_registry/${modified[0]}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Resolve input — accept both registry path and local path
|
|
46
|
+
const resolved = resolveInput(input, workspaceDir);
|
|
47
|
+
|
|
48
|
+
if (!resolved.registryPath) {
|
|
49
|
+
const hint = input.startsWith('.claude/') || input.startsWith('.cursor/') || input.startsWith('.codex/')
|
|
50
|
+
? `\n\n Tip: Use the .aw_registry/ path instead:\n aw push .aw_registry/${input.split('/').slice(1).join('/')}`
|
|
51
|
+
: '';
|
|
52
|
+
fmt.cancel(`Could not resolve "${input}" to a registry path.${hint}\n\n Only files inside .aw_registry/ can be pushed.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Find the local file to upload
|
|
56
|
+
let absPath = resolved.localAbsPath;
|
|
57
|
+
if (!absPath || !existsSync(absPath)) {
|
|
58
|
+
// Try resolving with .md extension for flat files
|
|
59
|
+
if (absPath && !absPath.endsWith('.md') && existsSync(absPath + '.md')) {
|
|
60
|
+
absPath = absPath + '.md';
|
|
61
|
+
} else {
|
|
62
|
+
fmt.cancel(`File not found: ${absPath || input}\n\n Only files inside .aw_registry/ can be pushed.\n Use ${chalk.dim('aw status')} to see modified files.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Parse registry path to get type, namespace, slug
|
|
67
|
+
const regParts = resolved.registryPath.split('/');
|
|
68
|
+
let typeIdx = -1;
|
|
69
|
+
const validTypes = ['agents', 'skills', 'commands', 'blueprints', 'evals'];
|
|
70
|
+
for (let i = 0; i < regParts.length; i++) {
|
|
71
|
+
if (validTypes.includes(regParts[i])) { typeIdx = i; break; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (typeIdx === -1 || typeIdx + 1 >= regParts.length) {
|
|
75
|
+
fmt.cancel(`Could not determine type from path: ${resolved.registryPath}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const namespaceParts = regParts.slice(0, typeIdx);
|
|
79
|
+
const parentDir = regParts[typeIdx];
|
|
80
|
+
const slug = regParts[typeIdx + 1];
|
|
81
|
+
const namespacePath = namespaceParts.join('/');
|
|
82
|
+
const topNamespace = namespaceParts[0];
|
|
83
|
+
|
|
84
|
+
if (topNamespace === 'ghl') {
|
|
85
|
+
fmt.cancel("Cannot push to 'ghl' namespace — it is the shared platform layer");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isDir = statSync(absPath).isDirectory();
|
|
89
|
+
const registryTarget = isDir
|
|
90
|
+
? `registry/${namespacePath}/${parentDir}/${slug}`
|
|
91
|
+
: `registry/${namespacePath}/${parentDir}/${slug}.md`;
|
|
92
|
+
|
|
93
|
+
fmt.note(
|
|
94
|
+
[
|
|
95
|
+
`${chalk.dim('source:')} ${absPath}`,
|
|
96
|
+
`${chalk.dim('type:')} ${parentDir}`,
|
|
97
|
+
`${chalk.dim('namespace:')} ${namespacePath}`,
|
|
98
|
+
`${chalk.dim('slug:')} ${slug}`,
|
|
99
|
+
`${chalk.dim('target:')} ${registryTarget}`,
|
|
100
|
+
].join('\n'),
|
|
101
|
+
'Upload mapping'
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (dryRun) {
|
|
105
|
+
fmt.logWarn('No changes made (--dry-run)');
|
|
106
|
+
fmt.outro(chalk.dim('Remove --dry-run to upload'));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const s = fmt.spinner();
|
|
111
|
+
s.start('Cloning registry...');
|
|
112
|
+
|
|
113
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'aw-upload-'));
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
execSync(`gh repo clone ${repo} ${tempDir} -- --filter=blob:none --no-checkout`, { stdio: 'pipe' });
|
|
117
|
+
execSync(`git checkout ${REGISTRY_BASE_BRANCH}`, { cwd: tempDir, stdio: 'pipe' });
|
|
118
|
+
s.stop('Repository cloned');
|
|
119
|
+
|
|
120
|
+
const shortId = Date.now().toString(36).slice(-5);
|
|
121
|
+
const branch = `upload/${namespacePath.replace(/\//g, '-')}-${parentDir}-${slug}-${shortId}`;
|
|
122
|
+
execSync(`git checkout -b ${branch}`, { cwd: tempDir, stdio: 'pipe' });
|
|
123
|
+
|
|
124
|
+
const s2 = fmt.spinner();
|
|
125
|
+
s2.start('Preparing upload...');
|
|
126
|
+
|
|
127
|
+
// Copy to target
|
|
128
|
+
const targetFull = join(tempDir, registryTarget);
|
|
129
|
+
if (isDir) {
|
|
130
|
+
mkdirSync(targetFull, { recursive: true });
|
|
131
|
+
cpSync(absPath, targetFull, { recursive: true });
|
|
132
|
+
} else {
|
|
133
|
+
mkdirSync(dirname(targetFull), { recursive: true });
|
|
134
|
+
cpSync(absPath, targetFull);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check if this is a new namespace — auto-add CODEOWNERS entry
|
|
138
|
+
let newNamespace = false;
|
|
139
|
+
const nsDir = join(tempDir, 'registry', topNamespace);
|
|
140
|
+
const codeownersPath = join(tempDir, 'CODEOWNERS');
|
|
141
|
+
if (!existsSync(nsDir) || isNewNamespaceInCodeowners(codeownersPath, topNamespace)) {
|
|
142
|
+
newNamespace = true;
|
|
143
|
+
const ghUser = getGitHubUser();
|
|
144
|
+
if (ghUser && existsSync(codeownersPath)) {
|
|
145
|
+
const line = `/registry/${topNamespace}/ @${ghUser}\n`;
|
|
146
|
+
appendFileSync(codeownersPath, line);
|
|
147
|
+
execSync('git add CODEOWNERS', { cwd: tempDir, stdio: 'pipe' });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Stage + commit + push + PR
|
|
152
|
+
execSync(`git add "${registryTarget}"`, { cwd: tempDir, stdio: 'pipe' });
|
|
153
|
+
|
|
154
|
+
const commitMsg = newNamespace
|
|
155
|
+
? `registry: create namespace ${topNamespace} + add ${parentDir}/${slug}`
|
|
156
|
+
: `registry: add ${parentDir}/${slug} to ${namespacePath}`;
|
|
157
|
+
execSync(`git commit -m "${commitMsg}"`, { cwd: tempDir, stdio: 'pipe' });
|
|
158
|
+
|
|
159
|
+
s2.stop('Upload prepared');
|
|
160
|
+
|
|
161
|
+
const s3 = fmt.spinner();
|
|
162
|
+
s3.start('Pushing and creating PR...');
|
|
163
|
+
|
|
164
|
+
execSync(`git push -u origin ${branch}`, { cwd: tempDir, stdio: 'pipe' });
|
|
165
|
+
|
|
166
|
+
const bodyParts = [
|
|
167
|
+
'## Registry Upload',
|
|
168
|
+
'',
|
|
169
|
+
`- **Type:** ${parentDir}`,
|
|
170
|
+
`- **Slug:** ${slug}`,
|
|
171
|
+
`- **Namespace:** ${namespacePath}`,
|
|
172
|
+
`- **Path:** \`${registryTarget}\``,
|
|
173
|
+
];
|
|
174
|
+
if (newNamespace) {
|
|
175
|
+
bodyParts.push('', '> **New namespace** — CODEOWNERS entry auto-added.');
|
|
176
|
+
}
|
|
177
|
+
bodyParts.push('', 'Uploaded via `aw push`');
|
|
178
|
+
|
|
179
|
+
const prTitle = `Add ${slug} (${parentDir}) to ${namespacePath}`;
|
|
180
|
+
const prBody = bodyParts.join('\n');
|
|
181
|
+
const prUrl = execFileSync('gh', [
|
|
182
|
+
'pr', 'create',
|
|
183
|
+
'--base', REGISTRY_BASE_BRANCH,
|
|
184
|
+
'--title', prTitle,
|
|
185
|
+
'--body', prBody,
|
|
186
|
+
], { cwd: tempDir, encoding: 'utf8' }).trim();
|
|
187
|
+
|
|
188
|
+
s3.stop('PR created');
|
|
189
|
+
|
|
190
|
+
if (newNamespace) {
|
|
191
|
+
fmt.logInfo(`New namespace ${chalk.cyan(topNamespace)} — CODEOWNERS entry added`);
|
|
192
|
+
}
|
|
193
|
+
fmt.logSuccess(`PR: ${chalk.cyan(prUrl)}`);
|
|
194
|
+
fmt.outro('Upload complete');
|
|
195
|
+
} catch (e) {
|
|
196
|
+
fmt.cancel(`Upload failed: ${e.message}`);
|
|
197
|
+
} finally {
|
|
198
|
+
execSync(`rm -rf "${tempDir}"`, { stdio: 'pipe' });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function getGitHubUser() {
|
|
203
|
+
try {
|
|
204
|
+
return execSync('gh api user --jq .login', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isNewNamespaceInCodeowners(codeownersPath, namespace) {
|
|
211
|
+
if (!existsSync(codeownersPath)) return true;
|
|
212
|
+
const content = readFileSync(codeownersPath, 'utf8');
|
|
213
|
+
return !content.includes(`/registry/${namespace}/`);
|
|
214
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// commands/search.mjs — Search local workspace + remote registry
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import * as config from '../config.mjs';
|
|
7
|
+
import * as fmt from '../fmt.mjs';
|
|
8
|
+
import { chalk } from '../fmt.mjs';
|
|
9
|
+
import { REGISTRY_BASE_BRANCH, REGISTRY_REPO } from '../constants.mjs';
|
|
10
|
+
|
|
11
|
+
export function searchCommand(args) {
|
|
12
|
+
const query = (args._positional || []).join(' ').toLowerCase();
|
|
13
|
+
|
|
14
|
+
if (!query) {
|
|
15
|
+
fmt.cancel('Missing search query. Usage: aw search <query>');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
fmt.intro('aw search');
|
|
19
|
+
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
const workspaceDir = join(cwd, '.aw_registry');
|
|
22
|
+
|
|
23
|
+
// ── Local search ──
|
|
24
|
+
const localResults = searchLocal(workspaceDir, query);
|
|
25
|
+
|
|
26
|
+
// ── Remote search ──
|
|
27
|
+
const s = fmt.spinner();
|
|
28
|
+
s.start('Searching registry...');
|
|
29
|
+
const cfg = config.load(workspaceDir);
|
|
30
|
+
const repo = cfg?.repo || REGISTRY_REPO;
|
|
31
|
+
const remoteResults = searchRemote(repo, query);
|
|
32
|
+
s.stop('Registry searched');
|
|
33
|
+
|
|
34
|
+
// Build set of local files for "already pulled" detection
|
|
35
|
+
const localPaths = new Set(localResults.map(r => r.registryPath.replace(/\.md$/, '')));
|
|
36
|
+
|
|
37
|
+
// ── Display local results ──
|
|
38
|
+
if (localResults.length > 0) {
|
|
39
|
+
const lines = localResults.slice(0, 20).map(r => {
|
|
40
|
+
const desc = r.description
|
|
41
|
+
? chalk.dim(` — ${r.description.slice(0, 70)}${r.description.length > 70 ? '...' : ''}`)
|
|
42
|
+
: '';
|
|
43
|
+
return `${chalk.cyan(r.localPath)}${desc}`;
|
|
44
|
+
});
|
|
45
|
+
if (localResults.length > 20) {
|
|
46
|
+
lines.push(chalk.dim(`... and ${localResults.length - 20} more`));
|
|
47
|
+
}
|
|
48
|
+
fmt.note(lines.join('\n'), `Local (${localResults.length})`);
|
|
49
|
+
} else {
|
|
50
|
+
fmt.logInfo(chalk.dim('No local matches'));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Display remote results ──
|
|
54
|
+
if (remoteResults.length > 0) {
|
|
55
|
+
// Mark items already pulled
|
|
56
|
+
const lines = remoteResults.slice(0, 20).map(r => {
|
|
57
|
+
const pulled = localPaths.has((r.path || '').replace(/\.md$/, ''));
|
|
58
|
+
const icon = pulled ? chalk.green('✓') : chalk.yellow('↓');
|
|
59
|
+
const desc = r.description
|
|
60
|
+
? chalk.dim(` — ${r.description.slice(0, 60)}${r.description.length > 60 ? '...' : ''}`)
|
|
61
|
+
: '';
|
|
62
|
+
return `${icon} ${chalk.cyan(r.path)}${desc}`;
|
|
63
|
+
});
|
|
64
|
+
if (remoteResults.length > 20) {
|
|
65
|
+
lines.push(chalk.dim(`... and ${remoteResults.length - 20} more`));
|
|
66
|
+
}
|
|
67
|
+
fmt.note(
|
|
68
|
+
lines.join('\n') + '\n\n' + chalk.dim(`${chalk.green('✓')} = pulled ${chalk.yellow('↓')} = available`),
|
|
69
|
+
`Registry (${remoteResults.length})`
|
|
70
|
+
);
|
|
71
|
+
} else if (remoteResults !== null) {
|
|
72
|
+
fmt.logInfo(chalk.dim('No registry matches'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (localResults.length === 0 && remoteResults.length === 0) {
|
|
76
|
+
fmt.outro(chalk.dim('Try a different query'));
|
|
77
|
+
} else {
|
|
78
|
+
fmt.outro(`${chalk.dim('aw pull <path>')} to download`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Search local .aw_registry/ files by name, slug, and frontmatter.
|
|
84
|
+
*/
|
|
85
|
+
function searchLocal(workspaceDir, query) {
|
|
86
|
+
const results = [];
|
|
87
|
+
if (!existsSync(workspaceDir)) return results;
|
|
88
|
+
|
|
89
|
+
const queryTerms = query.split(' ');
|
|
90
|
+
|
|
91
|
+
// Walk namespace dirs
|
|
92
|
+
for (const nsEntry of readdirSync(workspaceDir, { withFileTypes: true })) {
|
|
93
|
+
if (!nsEntry.isDirectory() || nsEntry.name.startsWith('.')) continue;
|
|
94
|
+
const ns = nsEntry.name;
|
|
95
|
+
|
|
96
|
+
for (const type of ['agents', 'skills', 'commands', 'blueprints', 'evals']) {
|
|
97
|
+
const typeDir = join(workspaceDir, ns, type);
|
|
98
|
+
if (!existsSync(typeDir)) continue;
|
|
99
|
+
|
|
100
|
+
for (const entry of readdirSync(typeDir, { withFileTypes: true })) {
|
|
101
|
+
if (entry.name.startsWith('.')) continue;
|
|
102
|
+
|
|
103
|
+
const fullPath = join(typeDir, entry.name);
|
|
104
|
+
const isDir = entry.isDirectory();
|
|
105
|
+
const slug = isDir ? entry.name : entry.name.replace(/\.md$/, '');
|
|
106
|
+
|
|
107
|
+
// Build searchable text
|
|
108
|
+
let haystack = `${ns} ${slug}`.replace(/-/g, ' ').toLowerCase();
|
|
109
|
+
|
|
110
|
+
// Read frontmatter for name/description
|
|
111
|
+
let description = '';
|
|
112
|
+
const mdPath = isDir ? join(fullPath, 'SKILL.md') : fullPath;
|
|
113
|
+
if (existsSync(mdPath)) {
|
|
114
|
+
const meta = extractFrontmatter(mdPath);
|
|
115
|
+
if (meta.name) haystack += ` ${meta.name.toLowerCase()}`;
|
|
116
|
+
if (meta.description) {
|
|
117
|
+
haystack += ` ${meta.description.toLowerCase()}`;
|
|
118
|
+
description = meta.description;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
haystack += ` ${type}`;
|
|
123
|
+
|
|
124
|
+
if (queryTerms.every(q => haystack.includes(q))) {
|
|
125
|
+
const registryPath = `${ns}/${type}/${slug}`;
|
|
126
|
+
results.push({
|
|
127
|
+
localPath: `${ns}/${type}/${entry.name}`,
|
|
128
|
+
registryPath,
|
|
129
|
+
type,
|
|
130
|
+
slug,
|
|
131
|
+
description,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Search remote registry.json via GitHub API (no clone needed).
|
|
143
|
+
*/
|
|
144
|
+
function searchRemote(repo, query) {
|
|
145
|
+
try {
|
|
146
|
+
const raw = execSync(
|
|
147
|
+
`gh api "repos/${repo}/contents/registry/registry.json?ref=${REGISTRY_BASE_BRANCH}" --jq .content -H "Accept: application/vnd.github.v3+json"`,
|
|
148
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
149
|
+
);
|
|
150
|
+
const content = Buffer.from(raw.trim(), 'base64').toString('utf8');
|
|
151
|
+
const registry = JSON.parse(content);
|
|
152
|
+
|
|
153
|
+
const queryTerms = query.split(' ');
|
|
154
|
+
return (registry.entries || []).filter(e => {
|
|
155
|
+
const haystack = `${e.name || ''} ${e.slug || ''} ${e.description || ''} ${e.type || ''} ${e.scope || ''} ${e.scopeName || ''} ${e.path || ''}`.toLowerCase();
|
|
156
|
+
return queryTerms.every(q => haystack.includes(q));
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
// gh not available, not authenticated, or registry.json doesn't exist
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Extract name and description from frontmatter.
|
|
166
|
+
*/
|
|
167
|
+
function extractFrontmatter(filePath) {
|
|
168
|
+
try {
|
|
169
|
+
const content = readFileSync(filePath, 'utf8');
|
|
170
|
+
if (!content.startsWith('---')) return {};
|
|
171
|
+
const endIdx = content.indexOf('---', 3);
|
|
172
|
+
if (endIdx === -1) return {};
|
|
173
|
+
const fm = content.slice(3, endIdx);
|
|
174
|
+
const nameMatch = fm.match(/^name:\s*(.+)$/m);
|
|
175
|
+
const descMatch = fm.match(/^description:\s*(.+)$/m);
|
|
176
|
+
return {
|
|
177
|
+
name: nameMatch ? nameMatch[1].trim().replace(/^["']|["']$/g, '') : '',
|
|
178
|
+
description: descMatch ? descMatch[1].trim().replace(/^["']|["']$/g, '') : '',
|
|
179
|
+
};
|
|
180
|
+
} catch {
|
|
181
|
+
return {};
|
|
182
|
+
}
|
|
183
|
+
}
|