@ghl-ai/aw 0.1.35 → 0.1.36-beta.1

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/cli.mjs CHANGED
@@ -72,7 +72,8 @@ function printHelp() {
72
72
  const sec = (title) => `\n ${chalk.bold.underline(title)}`;
73
73
  const help = [
74
74
  sec('Setup'),
75
- cmd('aw init --namespace <team/sub-team>', 'Initialize workspace (required)'),
75
+ cmd('aw init', 'Initialize workspace (platform/ only)'),
76
+ cmd('aw init --namespace <team/sub-team>', 'Add a team namespace (optional)'),
76
77
  ` ${chalk.dim('Teams: platform, revex, mobile, commerce, leadgen, crm, marketplace, ai')}`,
77
78
  ` ${chalk.dim('Example: aw init --namespace revex/courses')}`,
78
79
 
package/commands/init.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // Uses core.hooksPath (git-lfs pattern) for system-wide hook interception.
5
5
  // Uses IDE tasks for auto-pull on workspace open.
6
6
 
7
- import { mkdirSync, existsSync, writeFileSync, symlinkSync } from 'node:fs';
7
+ import { mkdirSync, existsSync, writeFileSync, symlinkSync, readdirSync } from 'node:fs';
8
8
  import { execSync } from 'node:child_process';
9
9
  import { join, dirname } from 'node:path';
10
10
  import { homedir } from 'node:os';
@@ -14,11 +14,14 @@ import * as config from '../config.mjs';
14
14
  import * as fmt from '../fmt.mjs';
15
15
  import { chalk } from '../fmt.mjs';
16
16
  import { pullCommand, pullAsync } from './pull.mjs';
17
+ import { sparseCheckoutAsync, includeToSparsePaths, cleanup } from '../git.mjs';
18
+ import { REGISTRY_DIR, REGISTRY_REPO } from '../constants.mjs';
17
19
  import { linkWorkspace } from '../link.mjs';
18
20
  import { generateCommands, copyInstructions, initAwDocs } from '../integrate.mjs';
19
21
  import { setupMcp } from '../mcp.mjs';
20
22
  import { autoUpdate, promptUpdate } from '../update.mjs';
21
23
  import { installGlobalHooks } from '../hooks.mjs';
24
+ import { installAwEcc } from '../ecc.mjs';
22
25
 
23
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
27
  const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
@@ -95,7 +98,7 @@ function printPullSummary(pattern, actions) {
95
98
  const ALLOWED_NAMESPACES = ['platform', 'revex', 'mobile', 'commerce', 'leadgen', 'crm', 'marketplace', 'ai'];
96
99
 
97
100
  export async function initCommand(args) {
98
- const namespace = args['--namespace'] || null;
101
+ let namespace = args['--namespace'] || null;
99
102
  let user = args['--user'] || '';
100
103
  const silent = args['--silent'] === true;
101
104
 
@@ -103,24 +106,12 @@ export async function initCommand(args) {
103
106
 
104
107
  // ── Validate ──────────────────────────────────────────────────────────
105
108
 
106
- if (!namespace && !silent) {
107
- const list = ALLOWED_NAMESPACES.map(n => chalk.cyan(n)).join(', ');
108
- fmt.cancel([
109
- `Missing required ${chalk.bold('--namespace')} flag`,
110
- '',
111
- ` ${chalk.dim('Usage:')} aw init --namespace <team/sub-team>`,
112
- ` ${chalk.dim('Teams:')} ${list}`,
113
- '',
114
- ` ${chalk.dim('Example:')} ${chalk.bold('aw init --namespace commerce/payments')}`,
115
- ].join('\n'));
116
- }
117
-
118
109
  // Parse team/sub-team
119
- const nsParts = namespace ? namespace.split('/') : [];
120
- const team = nsParts[0] || null;
121
- const subTeam = nsParts[1] || null;
122
- const teamNS = subTeam ? `${team}-${subTeam}` : team; // for $TEAM_NS replacement
123
- const folderName = subTeam ? `${team}/${subTeam}` : team; // for .aw_registry/ path
110
+ let nsParts = namespace ? namespace.split('/') : [];
111
+ let team = nsParts[0] || null;
112
+ let subTeam = nsParts[1] || null;
113
+ let teamNS = subTeam ? `${team}-${subTeam}` : team; // for $TEAM_NS replacement
114
+ let folderName = subTeam ? `${team}/${subTeam}` : team; // for .aw_registry/ path
124
115
 
125
116
  if (team && !ALLOWED_NAMESPACES.includes(team)) {
126
117
  const list = ALLOWED_NAMESPACES.map(n => chalk.cyan(n)).join(', ');
@@ -151,6 +142,47 @@ export async function initCommand(args) {
151
142
  fmt.cancel(`Invalid sub-team '${subTeam}' — must match: ${SLUG_RE}`);
152
143
  }
153
144
 
145
+ // ── Probe remote registry to check if namespace exists ────────────────
146
+
147
+ let namespaceExistsInRemote = false;
148
+ if (folderName && !silent) {
149
+ try {
150
+ const probePaths = includeToSparsePaths([folderName]);
151
+ const probeDir = await sparseCheckoutAsync(REGISTRY_REPO, probePaths);
152
+ try {
153
+ const fullNsPath = join(probeDir, REGISTRY_DIR, ...folderName.split('/'));
154
+ namespaceExistsInRemote = existsSync(fullNsPath) &&
155
+ readdirSync(fullNsPath, { withFileTypes: true })
156
+ .some(d => d.isDirectory() && !d.name.startsWith('.'));
157
+ } finally {
158
+ cleanup(probeDir);
159
+ }
160
+ } catch {
161
+ // Network error — skip probe, proceed without prompt
162
+ namespaceExistsInRemote = true;
163
+ }
164
+ }
165
+
166
+ // If namespace does NOT exist in remote, ask user to confirm
167
+ if (folderName && !silent && !namespaceExistsInRemote && process.stdin.isTTY) {
168
+ const choice = await fmt.select({
169
+ message: `The namespace '${folderName}' does not exist in the registry yet and will be created from [template].\nplatform/ includes shared agents, skills & commands that cover most use cases.\nHow would you like to proceed?`,
170
+ options: [
171
+ { value: 'platform-only', label: 'Continue with platform/ only (recommended for most users)' },
172
+ { value: 'create-namespace', label: `Create '${folderName}' namespace from template` },
173
+ ],
174
+ });
175
+
176
+ if (fmt.isCancel(choice)) {
177
+ fmt.cancel('Operation cancelled.');
178
+ process.exit(0);
179
+ }
180
+
181
+ if (choice === 'platform-only') {
182
+ namespace = null; team = null; subTeam = null; teamNS = null; folderName = null;
183
+ }
184
+ }
185
+
154
186
  const hasConfig = config.exists(GLOBAL_AW_DIR);
155
187
  const hasPlatform = existsSync(join(GLOBAL_AW_DIR, 'platform'));
156
188
  const isExisting = hasConfig && hasPlatform;
@@ -181,25 +213,30 @@ export async function initCommand(args) {
181
213
  // Pull latest (parallel)
182
214
  // cfg.include has the renamed namespace (e.g. 'revex/courses'), but the repo
183
215
  // only has '.aw_registry/[template]/' — remap non-platform entries back.
216
+ // Platform is never stored in cfg.include but must always be pulled.
184
217
  const freshCfg = config.load(GLOBAL_AW_DIR);
218
+ const pullJobs = [
219
+ pullAsync({ ...args, _positional: ['platform'], _workspaceDir: GLOBAL_AW_DIR, _skipIntegrate: true }),
220
+ ];
185
221
  if (freshCfg && freshCfg.include.length > 0) {
186
- const pullJobs = freshCfg.include.map(p => {
187
- const isTeamNs = p !== 'platform';
188
- const derivedTeamNS = isTeamNs ? p.replace(/\//g, '-') : undefined;
189
- return pullAsync({
222
+ for (const p of freshCfg.include) {
223
+ if (p === 'platform') continue; // already added above
224
+ const derivedTeamNS = p.replace(/\//g, '-');
225
+ pullJobs.push(pullAsync({
190
226
  ...args,
191
- _positional: [isTeamNs ? '[template]' : p],
227
+ _positional: ['[template]'],
192
228
  _workspaceDir: GLOBAL_AW_DIR,
193
229
  _skipIntegrate: true,
194
- _renameNamespace: isTeamNs ? p : undefined,
230
+ _renameNamespace: p,
195
231
  _teamNS: derivedTeamNS,
196
- });
197
- });
198
- await Promise.all(pullJobs);
232
+ }));
233
+ }
199
234
  }
235
+ await Promise.all(pullJobs);
200
236
 
201
237
  // Re-link IDE dirs + hooks (idempotent)
202
238
  linkWorkspace(HOME);
239
+ await installAwEcc(cwd, { silent });
203
240
  generateCommands(HOME);
204
241
  copyInstructions(HOME, null, freshCfg?.namespace || team) || [];
205
242
  initAwDocs(HOME);
@@ -280,6 +317,7 @@ export async function initCommand(args) {
280
317
  // Step 3: Link IDE dirs + setup tasks
281
318
  fmt.logStep('Linking IDE symlinks...');
282
319
  linkWorkspace(HOME);
320
+ await installAwEcc(cwd, { silent });
283
321
  generateCommands(HOME);
284
322
  const instructionFiles = copyInstructions(HOME, null, team) || [];
285
323
  initAwDocs(HOME);
package/commands/nuke.mjs CHANGED
@@ -9,6 +9,7 @@ import { execSync } from 'node:child_process';
9
9
  import * as fmt from '../fmt.mjs';
10
10
  import { chalk } from '../fmt.mjs';
11
11
  import { removeGlobalHooks } from '../hooks.mjs';
12
+ import { uninstallAwEcc } from '../ecc.mjs';
12
13
 
13
14
  const HOME = homedir();
14
15
  const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
@@ -243,28 +244,31 @@ export function nukeCommand(args) {
243
244
  // 2. Remove IDE symlinks (only those pointing to .aw_registry)
244
245
  removeIdeSymlinks();
245
246
 
246
- // 3. Remove .aw_registry symlinks from ALL project directories
247
+ // 3. Remove aw-ecc installed files (agents, commands, rules, skills, hooks)
248
+ uninstallAwEcc();
249
+
250
+ // 4. Remove .aw_registry symlinks from ALL project directories
247
251
  removeProjectSymlinks();
248
252
 
249
- // 4. Remove git hooks (core.hooksPath + legacy template)
253
+ // 5. Remove git hooks (core.hooksPath + legacy template)
250
254
  removeGitHooks(manifest);
251
255
 
252
- // 5. Remove IDE auto-init tasks
256
+ // 6. Remove IDE auto-init tasks
253
257
  removeIdeTasks();
254
258
 
255
- // 5b. Remove upgrade lock/log (inside .aw_registry, must happen before dir removal)
259
+ // Remove upgrade lock/log (inside .aw_registry, must happen before dir removal)
256
260
  for (const p of [join(GLOBAL_AW_DIR, '.aw-upgrade.lock'), join(GLOBAL_AW_DIR, '.aw-upgrade.log')]) {
257
261
  try { if (existsSync(p)) rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ }
258
262
  }
259
263
 
260
- // 6. Remove ~/.aw_docs/
264
+ // 7. Remove ~/.aw_docs/
261
265
  const awDocs = join(HOME, '.aw_docs');
262
266
  if (existsSync(awDocs)) {
263
267
  rmSync(awDocs, { recursive: true, force: true });
264
268
  fmt.logStep('Removed ~/.aw_docs/');
265
269
  }
266
270
 
267
- // 7. Remove any manual `aw` symlinks (e.g. ~/.local/bin/aw)
271
+ // 8. Remove any manual `aw` symlinks (e.g. ~/.local/bin/aw)
268
272
  const manualBins = [
269
273
  join(HOME, '.local', 'bin', 'aw'),
270
274
  join(HOME, 'bin', 'aw'),
@@ -278,7 +282,7 @@ export function nukeCommand(args) {
278
282
  } catch { /* doesn't exist */ }
279
283
  }
280
284
 
281
- // 8. Uninstall npm global package (skip if already in npm uninstall lifecycle)
285
+ // 9. Uninstall npm global package (skip if already in npm uninstall lifecycle)
282
286
  if (!process.env.npm_lifecycle_event) {
283
287
  try {
284
288
  execSync('npm uninstall -g @ghl-ai/aw', { stdio: 'pipe', timeout: 15000 });
@@ -286,8 +290,12 @@ export function nukeCommand(args) {
286
290
  } catch { /* not installed via npm or no permissions */ }
287
291
  }
288
292
 
289
- // 9. Remove ~/.aw_registry/ itself (source of truth — last!)
290
- rmSync(GLOBAL_AW_DIR, { recursive: true, force: true });
293
+ // 10. Remove ~/.aw_registry/ itself (source of truth — last!)
294
+ try {
295
+ rmSync(GLOBAL_AW_DIR, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
296
+ } catch {
297
+ try { execSync(`rm -rf "${GLOBAL_AW_DIR}"`, { stdio: 'pipe' }); } catch { /* best effort */ }
298
+ }
291
299
  fmt.logStep('Removed ~/.aw_registry/');
292
300
 
293
301
  fmt.outro([
@@ -295,6 +303,7 @@ export function nukeCommand(args) {
295
303
  '',
296
304
  ` ${chalk.green('✓')} Generated files cleaned`,
297
305
  ` ${chalk.green('✓')} IDE symlinks cleaned`,
306
+ ` ${chalk.green('✓')} aw-ecc engine removed`,
298
307
  ` ${chalk.green('✓')} Project symlinks cleaned`,
299
308
  ` ${chalk.green('✓')} Git hooks removed`,
300
309
  ` ${chalk.green('✓')} IDE auto-sync tasks removed`,
package/commands/push.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // commands/push.mjs — Push local agents/skills to registry via PR (single file or batch)
2
2
 
3
- import { existsSync, statSync, mkdirSync, cpSync, mkdtempSync, readFileSync, appendFileSync, readdirSync } from 'node:fs';
3
+ import { existsSync, statSync, mkdirSync, cpSync, mkdtempSync, readFileSync, appendFileSync, readdirSync, unlinkSync, rmdirSync } from 'node:fs';
4
4
  import { basename, dirname, resolve, join, relative } from 'node:path';
5
5
  import { execSync, execFileSync } from 'node:child_process';
6
6
  import { tmpdir } from 'node:os';
@@ -68,20 +68,40 @@ function collectBatchFiles(folderAbsPath, workspaceDir) {
68
68
  namespace: entry.namespacePath,
69
69
  slug: entry.slug,
70
70
  isDir: false,
71
+ deleted: false,
71
72
  };
72
73
  });
73
74
  }
74
75
 
75
76
  /**
76
77
  * Collect all modified files from manifest (for no-args push).
77
- * Returns array of { absPath, registryTarget, type, namespace, slug, isDir }.
78
+ * Returns array of { absPath, registryTarget, type, namespace, slug, isDir, deleted }.
78
79
  */
79
80
  function collectModifiedFiles(workspaceDir) {
80
81
  const manifest = loadManifest(workspaceDir);
81
82
  const files = [];
82
83
  for (const [key, entry] of Object.entries(manifest.files || {})) {
83
84
  const filePath = join(workspaceDir, key);
84
- if (!existsSync(filePath)) continue;
85
+
86
+ // File deleted locally — include as deletion if it was previously pushed
87
+ if (!existsSync(filePath)) {
88
+ if (entry.registry_sha256) {
89
+ const meta = parseManifestKey(key);
90
+ if (meta) {
91
+ files.push({
92
+ absPath: filePath,
93
+ registryTarget: `${REGISTRY_DIR}/${key}`,
94
+ type: meta.type,
95
+ namespace: meta.namespace,
96
+ slug: meta.slug,
97
+ isDir: false,
98
+ deleted: true,
99
+ });
100
+ }
101
+ }
102
+ continue;
103
+ }
104
+
85
105
  const currentHash = hashFile(filePath);
86
106
  const isModified = currentHash !== entry.sha256;
87
107
  const isNew = !entry.registry_sha256; // Template-derived, never pushed to remote
@@ -95,6 +115,7 @@ function collectModifiedFiles(workspaceDir) {
95
115
  namespace: meta.namespace,
96
116
  slug: meta.slug,
97
117
  isDir: false,
118
+ deleted: false,
98
119
  });
99
120
  }
100
121
  }
@@ -124,69 +145,108 @@ function parseManifestKey(key) {
124
145
  function generateBranchName(files) {
125
146
  const shortId = Date.now().toString(36).slice(-5);
126
147
  const namespaces = [...new Set(files.map(f => f.namespace))];
148
+ const hasDeletes = files.some(f => f.deleted);
149
+ const allDeletes = files.every(f => f.deleted);
150
+ const prefix = allDeletes ? 'remove' : hasDeletes ? 'sync' : 'upload';
127
151
 
128
152
  if (files.length === 1) {
129
153
  const f = files[0];
130
154
  const nsSlug = f.namespace.replace(/\//g, '-');
131
- return `upload/${nsSlug}-${f.type}-${f.slug}-${shortId}`;
155
+ return `${prefix}/${nsSlug}-${f.type}-${f.slug}-${shortId}`;
132
156
  }
133
157
 
134
158
  if (namespaces.length === 1) {
135
- return `sync/${namespaces[0].replace(/\//g, '-')}-${shortId}`;
159
+ return `${prefix}/${namespaces[0].replace(/\//g, '-')}-${shortId}`;
136
160
  }
137
161
 
138
- return `sync/batch-${shortId}`;
162
+ return `${prefix}/batch-${shortId}`;
139
163
  }
140
164
 
141
165
  function generatePrTitle(files) {
142
166
  if (files.length === 1) {
143
167
  const f = files[0];
144
- return `Add ${f.slug} (${f.type}) to ${f.namespace}`;
168
+ const verb = f.deleted ? 'Remove' : 'Add';
169
+ const prep = f.deleted ? 'from' : 'to';
170
+ return `${verb} ${f.slug} (${f.type}) ${prep} ${f.namespace}`;
145
171
  }
146
172
 
147
- const counts = {};
173
+ const addCounts = {};
174
+ const deleteCounts = {};
148
175
  for (const f of files) {
149
- counts[f.type] = (counts[f.type] || 0) + 1;
176
+ const target = f.deleted ? deleteCounts : addCounts;
177
+ target[f.type] = (target[f.type] || 0) + 1;
178
+ }
179
+ const parts = [];
180
+ for (const [type, count] of Object.entries(addCounts)) {
181
+ parts.push(`+${count} ${type}`);
182
+ }
183
+ for (const [type, count] of Object.entries(deleteCounts)) {
184
+ parts.push(`-${count} ${type}`);
150
185
  }
151
- const countParts = Object.entries(counts).map(([type, count]) => `${count} ${type}`);
152
186
  const namespaces = [...new Set(files.map(f => f.namespace))];
153
187
 
154
188
  if (namespaces.length === 1) {
155
- return `sync: ${countParts.join(', ')} in ${namespaces[0]}`;
189
+ return `sync: ${parts.join(', ')} in ${namespaces[0]}`;
156
190
  }
157
- return `sync: ${countParts.join(', ')} across ${namespaces.join(', ')}`;
191
+ return `sync: ${parts.join(', ')} across ${namespaces.join(', ')}`;
158
192
  }
159
193
 
160
194
  function generatePrBody(files, newNamespaces) {
161
195
  if (files.length === 1) {
162
196
  const f = files[0];
197
+ const action = f.deleted ? 'Registry Removal' : 'Registry Upload';
163
198
  const bodyParts = [
164
- '## Registry Upload',
199
+ `## ${action}`,
165
200
  '',
166
201
  `- **Type:** ${f.type}`,
167
202
  `- **Slug:** ${f.slug}`,
168
203
  `- **Namespace:** ${f.namespace}`,
169
204
  `- **Path:** \`${f.registryTarget}\``,
170
205
  ];
206
+ if (f.deleted) {
207
+ bodyParts.push('', '> File was deleted locally and removed from registry.');
208
+ }
171
209
  if (newNamespaces.length > 0) {
172
210
  bodyParts.push('', '> **New namespace** — CODEOWNERS entry auto-added.');
173
211
  }
174
- bodyParts.push('', 'Uploaded via `aw push`');
212
+ bodyParts.push('', `${f.deleted ? 'Removed' : 'Uploaded'} via \`aw push\``);
175
213
  return bodyParts.join('\n');
176
214
  }
177
215
 
178
- // Batch body — group by type
179
- const grouped = {};
180
- for (const f of files) {
181
- if (!grouped[f.type]) grouped[f.type] = [];
182
- grouped[f.type].push(f);
183
- }
216
+ // Batch body — group by action then type
217
+ const added = files.filter(f => !f.deleted);
218
+ const deleted = files.filter(f => f.deleted);
184
219
 
185
220
  const bodyParts = ['## Registry Sync', ''];
186
- for (const [type, items] of Object.entries(grouped)) {
187
- bodyParts.push(`### ${type}`);
188
- for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
189
- bodyParts.push(`- \`${item.slug}\` (${item.namespace})`);
221
+
222
+ if (added.length > 0) {
223
+ const grouped = {};
224
+ for (const f of added) {
225
+ if (!grouped[f.type]) grouped[f.type] = [];
226
+ grouped[f.type].push(f);
227
+ }
228
+ bodyParts.push('### Added / Updated');
229
+ for (const [type, items] of Object.entries(grouped)) {
230
+ bodyParts.push(`**${type}**`);
231
+ for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
232
+ bodyParts.push(`- \`${item.slug}\` (${item.namespace})`);
233
+ }
234
+ }
235
+ bodyParts.push('');
236
+ }
237
+
238
+ if (deleted.length > 0) {
239
+ const grouped = {};
240
+ for (const f of deleted) {
241
+ if (!grouped[f.type]) grouped[f.type] = [];
242
+ grouped[f.type].push(f);
243
+ }
244
+ bodyParts.push('### Removed');
245
+ for (const [type, items] of Object.entries(grouped)) {
246
+ bodyParts.push(`**${type}**`);
247
+ for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
248
+ bodyParts.push(`- ~~\`${item.slug}\`~~ (${item.namespace})`);
249
+ }
190
250
  }
191
251
  bodyParts.push('');
192
252
  }
@@ -196,7 +256,7 @@ function generatePrBody(files, newNamespaces) {
196
256
  bodyParts.push('');
197
257
  }
198
258
 
199
- bodyParts.push(`---`, `Synced via \`aw push\` (${files.length} files)`);
259
+ bodyParts.push(`---`, `Synced via \`aw push\` (${files.length} files: ${added.length} added/updated, ${deleted.length} removed)`);
200
260
  return bodyParts.join('\n');
201
261
  }
202
262
 
@@ -205,16 +265,22 @@ function generatePrBody(files, newNamespaces) {
205
265
  function pushFiles(files, { repo, dryRun, workspaceDir }) {
206
266
  // Summary
207
267
  const counts = {};
268
+ const deleteCounts = {};
208
269
  for (const f of files) {
209
- counts[f.type] = (counts[f.type] || 0) + 1;
270
+ const target = f.deleted ? deleteCounts : counts;
271
+ target[f.type] = (target[f.type] || 0) + 1;
210
272
  }
211
- const countParts = Object.entries(counts).map(([type, count]) => `${count} ${type}`);
273
+ const countParts = [
274
+ ...Object.entries(counts).map(([type, count]) => `${count} ${type}`),
275
+ ...Object.entries(deleteCounts).map(([type, count]) => `${count} ${type} removed`),
276
+ ];
212
277
  fmt.logInfo(`${chalk.bold(files.length)} file${files.length > 1 ? 's' : ''} to push (${countParts.join(', ')})`);
213
278
 
214
279
  if (dryRun) {
215
280
  for (const f of files) {
216
281
  const ns = chalk.dim(` [${f.namespace}]`);
217
- fmt.logMessage(` ${chalk.yellow(f.type)}/${f.slug}${ns}`);
282
+ const label = f.deleted ? chalk.red('DELETE') : chalk.yellow(f.type);
283
+ fmt.logMessage(` ${label}/${f.slug}${ns}`);
218
284
  }
219
285
  fmt.logWarn('No changes made (--dry-run)');
220
286
  fmt.outro(chalk.dim('Remove --dry-run to push'));
@@ -235,12 +301,14 @@ function pushFiles(files, { repo, dryRun, workspaceDir }) {
235
301
  const branch = generateBranchName(files);
236
302
  execSync(`git checkout -b ${branch}`, { cwd: tempDir, stdio: 'pipe' });
237
303
 
304
+ const addedFiles = files.filter(f => !f.deleted);
305
+ const deletedFiles = files.filter(f => f.deleted);
238
306
  const s2 = fmt.spinner();
239
- s2.start(`Copying ${files.length} file${files.length > 1 ? 's' : ''} to registry...`);
307
+ s2.start(`Applying ${files.length} change${files.length > 1 ? 's' : ''} to registry...`);
240
308
 
241
- // Copy each file
309
+ // Copy added/modified files
242
310
  let copyErrors = [];
243
- for (const file of files) {
311
+ for (const file of addedFiles) {
244
312
  try {
245
313
  const targetFull = join(tempDir, file.registryTarget);
246
314
  mkdirSync(dirname(targetFull), { recursive: true });
@@ -254,9 +322,33 @@ function pushFiles(files, { repo, dryRun, workspaceDir }) {
254
322
  }
255
323
  }
256
324
 
325
+ // Remove deleted files from the clone
326
+ for (const file of deletedFiles) {
327
+ try {
328
+ const targetFull = join(tempDir, file.registryTarget);
329
+ if (existsSync(targetFull)) {
330
+ unlinkSync(targetFull);
331
+ // Prune empty parent dirs up to REGISTRY_DIR
332
+ let dir = dirname(targetFull);
333
+ while (basename(dir) !== REGISTRY_DIR && dir !== tempDir) {
334
+ try {
335
+ if (readdirSync(dir).length === 0) {
336
+ rmdirSync(dir);
337
+ dir = dirname(dir);
338
+ } else {
339
+ break;
340
+ }
341
+ } catch { break; }
342
+ }
343
+ }
344
+ } catch (e) {
345
+ copyErrors.push({ file: file.registryTarget, error: e.message });
346
+ }
347
+ }
348
+
257
349
  if (copyErrors.length > 0) {
258
350
  for (const err of copyErrors) {
259
- fmt.logWarn(`Failed to copy ${err.file}: ${err.error}`);
351
+ fmt.logWarn(`Failed to process ${err.file}: ${err.error}`);
260
352
  }
261
353
  }
262
354
 
@@ -289,7 +381,7 @@ function pushFiles(files, { repo, dryRun, workspaceDir }) {
289
381
 
290
382
  const prTitle = generatePrTitle(files);
291
383
  const commitMsg = files.length === 1
292
- ? `registry: add ${files[0].type}/${files[0].slug} to ${files[0].namespace}`
384
+ ? `registry: ${files[0].deleted ? 'remove' : 'add'} ${files[0].type}/${files[0].slug} ${files[0].deleted ? 'from' : 'to'} ${files[0].namespace}`
293
385
  : `registry: sync ${files.length} files (${countParts.join(', ')})`;
294
386
  execSync(`git commit -m "${commitMsg}"`, { cwd: tempDir, stdio: 'pipe' });
295
387
 
@@ -323,20 +415,28 @@ function pushFiles(files, { repo, dryRun, workspaceDir }) {
323
415
  fmt.logInfo(`New namespace${newNamespaces.length > 1 ? 's' : ''} ${chalk.cyan(newNamespaces.join(', '))} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} added`);
324
416
  }
325
417
  if (files.length > 1) {
326
- for (const [type, items] of Object.entries(groupBy(files, 'type'))) {
418
+ for (const [type, items] of Object.entries(groupBy(addedFiles, 'type'))) {
327
419
  fmt.logSuccess(`${items.length} ${type} pushed`);
328
420
  }
421
+ for (const [type, items] of Object.entries(groupBy(deletedFiles, 'type'))) {
422
+ fmt.logSuccess(`${items.length} ${type} removed`);
423
+ }
424
+ } else if (files.length === 1 && files[0].deleted) {
425
+ fmt.logSuccess(`${files[0].type}/${files[0].slug} removed`);
329
426
  }
330
- // Update manifest — mark pushed files as synced (set registry_sha256 = sha256)
427
+ // Update manifest — mark pushed files as synced, remove deleted entries
331
428
  if (workspaceDir) {
332
429
  const manifest = loadManifest(workspaceDir);
333
- for (const file of files) {
334
- // Convert registryTarget back to manifest key (strip REGISTRY_DIR/ prefix)
430
+ for (const file of addedFiles) {
335
431
  const manifestKey = file.registryTarget.replace(`${REGISTRY_DIR}/`, '');
336
432
  if (manifest.files[manifestKey]) {
337
433
  manifest.files[manifestKey].registry_sha256 = manifest.files[manifestKey].sha256;
338
434
  }
339
435
  }
436
+ for (const file of deletedFiles) {
437
+ const manifestKey = file.registryTarget.replace(`${REGISTRY_DIR}/`, '');
438
+ delete manifest.files[manifestKey];
439
+ }
340
440
  saveManifest(workspaceDir, manifest);
341
441
  }
342
442
 
@@ -374,7 +474,7 @@ export function pushCommand(args) {
374
474
  if (!input) {
375
475
  const files = collectModifiedFiles(workspaceDir);
376
476
  if (files.length === 0) {
377
- fmt.cancel('Nothing to push — no modified or new files.\n\n Use `aw status` to see synced files.');
477
+ fmt.cancel('Nothing to push — no modified, new, or deleted files.\n\n Use `aw status` to see synced files.');
378
478
  }
379
479
  pushFiles(files, { repo, dryRun, workspaceDir });
380
480
  return;
@@ -445,6 +545,7 @@ export function pushCommand(args) {
445
545
  namespace: namespacePath,
446
546
  slug,
447
547
  isDir,
548
+ deleted: false,
448
549
  }], { repo, dryRun, workspaceDir });
449
550
  }
450
551
 
package/config.mjs CHANGED
@@ -48,7 +48,7 @@ export function create(workspaceDir, { namespace, user }) {
48
48
 
49
49
  export function addPattern(workspaceDir, pattern) {
50
50
  const config = load(workspaceDir);
51
- if (!config) throw new Error('No .sync-config.json found. Run: aw --init --namespace <name>');
51
+ if (!config) throw new Error('No .sync-config.json found. Run: aw init');
52
52
  // If a parent path already covers this, skip
53
53
  if (config.include.some(p => pattern === p || pattern.startsWith(p + '/'))) {
54
54
  return config;
@@ -62,7 +62,7 @@ export function addPattern(workspaceDir, pattern) {
62
62
 
63
63
  export function removePattern(workspaceDir, pattern) {
64
64
  const config = load(workspaceDir);
65
- if (!config) throw new Error('No .sync-config.json found. Run: aw --init --namespace <name>');
65
+ if (!config) throw new Error('No .sync-config.json found. Run: aw init');
66
66
  // Remove exact match + all children
67
67
  config.include = config.include.filter(p => p !== pattern && !p.startsWith(pattern + '/'));
68
68
  save(workspaceDir, config);
package/ecc.mjs ADDED
@@ -0,0 +1,180 @@
1
+ import { execSync } from "node:child_process";
2
+ import {
3
+ existsSync, readFileSync, readdirSync,
4
+ mkdirSync, rmSync, writeFileSync,
5
+ } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import * as fmt from "./fmt.mjs";
9
+
10
+ const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
11
+ const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
12
+ const AW_ECC_TAG = "v1.2.2";
13
+
14
+ const MARKETPLACE_NAME = "aw-marketplace";
15
+ const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
16
+
17
+ function eccDir() { return join(homedir(), ".aw-ecc"); }
18
+
19
+ const FILE_COPY_TARGETS = ["cursor", "codex"];
20
+
21
+ const TARGET_STATE = {
22
+ cursor: { state: ".cursor/ecc-install-state.json" },
23
+ codex: { state: ".codex/ecc-install-state.json" },
24
+ };
25
+
26
+ function run(cmd, opts = {}) {
27
+ return execSync(cmd, { stdio: "pipe", ...opts });
28
+ }
29
+
30
+ function cloneOrUpdate(tag, dest) {
31
+ if (existsSync(join(dest, ".git"))) {
32
+ try {
33
+ run(`git -C ${dest} fetch --quiet --depth 1 origin tag ${tag}`);
34
+ run(`git -C ${dest} checkout --quiet ${tag}`);
35
+ return;
36
+ } catch { /* fall through to fresh clone */ }
37
+ }
38
+ if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
39
+ try {
40
+ run(`git clone --quiet --depth 1 --branch ${tag} ${AW_ECC_REPO_SSH} ${dest}`);
41
+ } catch {
42
+ run(`git clone --quiet --depth 1 --branch ${tag} ${AW_ECC_REPO_HTTPS} ${dest}`);
43
+ }
44
+ }
45
+
46
+ function installClaudePlugin(repoDir) {
47
+ try {
48
+ run(`claude plugin marketplace add ${repoDir} --scope user`);
49
+ } catch {
50
+ try { run(`claude plugin marketplace update ${MARKETPLACE_NAME}`); } catch { /* ok */ }
51
+ }
52
+ run(`claude plugin install ${PLUGIN_KEY} --scope user`);
53
+ }
54
+
55
+ function uninstallClaudePlugin() {
56
+ try { run(`claude plugin uninstall ${PLUGIN_KEY} --scope user`); } catch { /* not installed */ }
57
+ try { run(`claude plugin marketplace remove ${MARKETPLACE_NAME}`); } catch { /* not registered */ }
58
+ }
59
+
60
+ export async function installAwEcc(
61
+ cwd,
62
+ { targets = ["cursor", "claude", "codex"], silent = false } = {},
63
+ ) {
64
+ if (!silent) fmt.logStep("Installing aw-ecc engine...");
65
+
66
+ const repoDir = eccDir();
67
+
68
+ try {
69
+ cloneOrUpdate(AW_ECC_TAG, repoDir);
70
+
71
+ // Claude Code: plugin install via marketplace CLI (proper agent dispatch)
72
+ if (targets.includes("claude")) {
73
+ try {
74
+ installClaudePlugin(repoDir);
75
+ } catch (err) {
76
+ if (!silent) fmt.logWarn(`Claude plugin install skipped: ${err.message}`);
77
+ }
78
+ }
79
+
80
+ // Cursor + Codex: file-copy via install-apply.js
81
+ const fileCopyTargets = targets.filter((t) => FILE_COPY_TARGETS.includes(t));
82
+ if (fileCopyTargets.length > 0) {
83
+ run("npm install --no-audit --no-fund --ignore-scripts --loglevel=error", {
84
+ cwd: repoDir,
85
+ });
86
+ for (const target of fileCopyTargets) {
87
+ try {
88
+ run(
89
+ `node ${join(repoDir, "scripts/install-apply.js")} --target ${target} --profile full`,
90
+ { cwd },
91
+ );
92
+ } catch { /* target not supported — skip */ }
93
+ }
94
+ }
95
+
96
+ if (!silent) fmt.logSuccess("aw-ecc engine installed");
97
+ } catch (err) {
98
+ if (!silent) fmt.logWarn(`aw-ecc install failed: ${err.message}`);
99
+ }
100
+ }
101
+
102
+ export function uninstallAwEcc({ silent = false } = {}) {
103
+ const HOME = homedir();
104
+ let removed = 0;
105
+
106
+ // Claude Code: uninstall plugin + remove marketplace via CLI
107
+ try {
108
+ uninstallClaudePlugin();
109
+ removed++;
110
+ } catch { /* best effort */ }
111
+
112
+ // Cursor + Codex: remove file-copied content via install-state
113
+ for (const cfg of Object.values(TARGET_STATE)) {
114
+ const statePath = join(HOME, cfg.state);
115
+ if (!existsSync(statePath)) continue;
116
+
117
+ try {
118
+ const data = JSON.parse(readFileSync(statePath, "utf8"));
119
+ for (const op of data.operations || []) {
120
+ if (op.destinationPath && existsSync(op.destinationPath)) {
121
+ rmSync(op.destinationPath, { recursive: true, force: true });
122
+ removed++;
123
+ pruneEmptyParents(op.destinationPath, join(HOME, cfg.state.split("/")[0]));
124
+ }
125
+ }
126
+ rmSync(statePath, { force: true });
127
+ pruneEmptyParents(statePath, join(HOME, cfg.state.split("/")[0]));
128
+ } catch { /* corrupted state — skip */ }
129
+ }
130
+
131
+ // Clean leftover claude install-state from older file-copy versions
132
+ const claudeState = join(HOME, ".claude", "ecc", "install-state.json");
133
+ if (existsSync(claudeState)) {
134
+ try {
135
+ const data = JSON.parse(readFileSync(claudeState, "utf8"));
136
+ for (const op of data.operations || []) {
137
+ if (op.destinationPath && existsSync(op.destinationPath)) {
138
+ rmSync(op.destinationPath, { recursive: true, force: true });
139
+ removed++;
140
+ }
141
+ }
142
+ rmSync(claudeState, { force: true });
143
+ pruneEmptyParents(claudeState, join(HOME, ".claude"));
144
+ } catch { /* best effort */ }
145
+ }
146
+
147
+ // Clean leftover manual plugin cache from older versions
148
+ const oldCache = join(HOME, ".claude", "plugins", "cache", "aw");
149
+ if (existsSync(oldCache)) {
150
+ rmSync(oldCache, { recursive: true, force: true });
151
+ removed++;
152
+ }
153
+
154
+ // Remove permanent aw-ecc repo clone
155
+ const repoDir = eccDir();
156
+ if (existsSync(repoDir)) {
157
+ rmSync(repoDir, { recursive: true, force: true });
158
+ removed++;
159
+ }
160
+
161
+ if (!silent && removed > 0)
162
+ fmt.logStep(`Removed ${removed} aw-ecc file${removed > 1 ? "s" : ""}`);
163
+ return removed;
164
+ }
165
+
166
+ function pruneEmptyParents(filePath, stopAt) {
167
+ let dir = dirname(filePath);
168
+ while (dir !== stopAt && dir.startsWith(stopAt)) {
169
+ try {
170
+ if (readdirSync(dir).length === 0) {
171
+ rmSync(dir);
172
+ dir = dirname(dir);
173
+ } else {
174
+ break;
175
+ }
176
+ } catch {
177
+ break;
178
+ }
179
+ }
180
+ }
package/fmt.mjs CHANGED
@@ -36,6 +36,8 @@ export function banner(text, opts = {}) {
36
36
  export const intro = (msg) => p.intro(chalk.bgCyan.black(` ${msg} `));
37
37
  export const outro = (msg) => p.outro(chalk.green(msg));
38
38
  export const spinner = () => p.spinner();
39
+ export const select = p.select;
40
+ export const isCancel = p.isCancel;
39
41
 
40
42
  export function cancel(msg) {
41
43
  p.cancel(msg);
package/integrate.mjs CHANGED
@@ -56,11 +56,13 @@ function findFiles(dir, typeName) {
56
56
  }
57
57
 
58
58
  /**
59
- * Copy CLAUDE.md and AGENTS.md to project root.
59
+ * Copy AGENTS.md to project root.
60
+ * CLAUDE.md is intentionally NOT generated — its routing rule hijacks plugin
61
+ * commands like /aw:plan, preventing proper agent dispatch.
60
62
  */
61
63
  export function copyInstructions(cwd, tempDir, namespace) {
62
64
  const createdFiles = [];
63
- for (const file of ['CLAUDE.md', 'AGENTS.md']) {
65
+ for (const file of ['AGENTS.md']) {
64
66
  const dest = join(cwd, file);
65
67
  if (existsSync(dest)) continue;
66
68
 
@@ -78,9 +80,7 @@ export function copyInstructions(cwd, tempDir, namespace) {
78
80
  }
79
81
  }
80
82
 
81
- const content = file === 'CLAUDE.md'
82
- ? generateClaudeMd(cwd, namespace)
83
- : generateAgentsMd(cwd, namespace);
83
+ const content = generateAgentsMd(cwd, namespace);
84
84
  if (content) {
85
85
  writeFileSync(dest, content);
86
86
  fmt.logSuccess(`Created ${file}`);
@@ -100,7 +100,9 @@ Team: ${team} | Local-first orchestration via \`.aw_docs/\` | MCPs: \`memory/*\`
100
100
 
101
101
  > **Every non-trivial task MUST call \`Skill(skill: "platform-ai-task-router")\` BEFORE any response.**
102
102
  >
103
- > **Trivial** (do directly): typo fixes, single-line edits, git ops, file exploration, factual code questions.
103
+ > **Exempt from routing (execute directly):**
104
+ > - **Plugin commands**: any \`/aw:*\` slash command — these have their own agent dispatch via the plugin system. Execute the plugin command as-is, do NOT re-route through the task router.
105
+ > - **Trivial tasks**: typo fixes, single-line edits, git ops, file exploration, factual code questions.
104
106
  >
105
107
  > Everything else — including tasks phrased as questions, suggestions, or discussions — routes first.
106
108
  > **No conversational responses first. No planning first. Route first.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.35",
3
+ "version": "0.1.36-beta.1",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,8 @@
24
24
  "registry.mjs",
25
25
  "apply.mjs",
26
26
  "update.mjs",
27
- "hooks.mjs"
27
+ "hooks.mjs",
28
+ "ecc.mjs"
28
29
  ],
29
30
  "engines": {
30
31
  "node": ">=18.0.0"