@ghl-ai/aw 0.1.34-beta.10 → 0.1.34-beta.12

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/commands/drop.mjs CHANGED
@@ -54,8 +54,8 @@ export function dropCommand(args) {
54
54
  fmt.logSuccess(`Removed ${chalk.cyan(regPath)} from sparse checkout`);
55
55
  }
56
56
  } else {
57
- // Sub-path: delete local files (they become "deleted" in git status, restored on next pull)
58
- const targetPath = join(workspaceDir, REGISTRY_DIR, regPath);
57
+ // Sub-path: delete local files (restored on next pull since still in sparse checkout)
58
+ const targetPath = join(workspaceDir, regPath);
59
59
  if (existsSync(targetPath)) {
60
60
  rmSync(targetPath, { recursive: true, force: true });
61
61
  fmt.logInfo(`Removed ${chalk.cyan(regPath)} from workspace`);
package/commands/init.mjs CHANGED
@@ -1,8 +1,12 @@
1
1
  // commands/init.mjs — Clean init: persistent sparse git checkout, link IDEs, global git hooks.
2
2
  //
3
- // No temp dirs. No manifest hashing. ~/.aw_registry/ IS the git repo.
3
+ // Layout:
4
+ // ~/.aw_repo/ — git clone (repo root)
5
+ // ~/.aw_registry/ — symlink → ~/.aw_repo/.aw_registry/
6
+ //
7
+ // No temp dirs. No manifest hashing. Git handles sync natively.
4
8
 
5
- import { mkdirSync, existsSync, writeFileSync, symlinkSync, cpSync, readFileSync, readdirSync, renameSync } from 'node:fs';
9
+ import { mkdirSync, existsSync, writeFileSync, symlinkSync, cpSync, readFileSync, readdirSync, renameSync, lstatSync } from 'node:fs';
6
10
  import { execSync } from 'node:child_process';
7
11
  import { join, dirname } from 'node:path';
8
12
  import { homedir } from 'node:os';
@@ -11,8 +15,9 @@ import * as config from '../config.mjs';
11
15
  import * as fmt from '../fmt.mjs';
12
16
  import { chalk } from '../fmt.mjs';
13
17
  import {
14
- gitInit, gitPull, gitSparseAdd, gitLsRemote,
15
- isGitRepo, withLock, includeToSparsePaths,
18
+ gitInit, gitPull, gitSparseAdd, gitSparseList, gitSparseSet, gitLsRemote,
19
+ isGitRepo, withLock, includeToSparsePaths, addGitExcludes,
20
+ GLOBAL_GIT_DIR,
16
21
  } from '../git.mjs';
17
22
  import { REGISTRY_BASE_BRANCH, REGISTRY_DIR, REGISTRY_REPO } from '../constants.mjs';
18
23
  import { linkWorkspace } from '../link.mjs';
@@ -74,19 +79,18 @@ const ALLOWED_NAMESPACES = ['platform', 'revex', 'mobile', 'commerce', 'leadgen'
74
79
 
75
80
  /**
76
81
  * Bootstrap a new namespace from [template].
77
- * Copies [template]/ → folderName/, replaces $TEAM_NS, removes local [template] copy.
82
+ * Copies [template]/ → folderName/, replaces $TEAM_NS.
78
83
  */
79
84
  function bootstrapFromTemplate(folderName, teamNS) {
80
- const templateDir = join(GLOBAL_AW_DIR, REGISTRY_DIR, '[template]');
81
- const targetDir = join(GLOBAL_AW_DIR, REGISTRY_DIR, ...folderName.split('/'));
85
+ // Template and target are inside GLOBAL_AW_DIR (which IS .aw_registry/)
86
+ const templateDir = join(GLOBAL_AW_DIR, '[template]');
87
+ const targetDir = join(GLOBAL_AW_DIR, ...folderName.split('/'));
82
88
 
83
89
  if (!existsSync(templateDir)) {
84
90
  throw new Error('Template directory not found. Ensure [template] is in sparse checkout.');
85
91
  }
86
92
 
87
93
  cpSync(templateDir, targetDir, { recursive: true });
88
-
89
- // Replace $TEAM_NS in .md files
90
94
  walkAndReplace(targetDir, '$TEAM_NS', teamNS);
91
95
  }
92
96
 
@@ -105,20 +109,44 @@ function walkAndReplace(dir, search, replace) {
105
109
  }
106
110
 
107
111
  /**
108
- * Ensure platform/docs symlink points to ../../content.
112
+ * Ensure platform/docs symlink points to content/ in the git repo root.
109
113
  */
110
114
  function ensureDocsSymlink() {
111
- const docsLink = join(GLOBAL_AW_DIR, REGISTRY_DIR, 'platform', 'docs');
112
- const contentDir = join(GLOBAL_AW_DIR, 'content');
115
+ const docsLink = join(GLOBAL_AW_DIR, 'platform', 'docs');
116
+ const contentDir = join(GLOBAL_GIT_DIR, 'content');
113
117
 
114
118
  if (existsSync(contentDir) && !existsSync(docsLink)) {
115
119
  try {
116
120
  mkdirSync(dirname(docsLink), { recursive: true });
117
- symlinkSync('../../content', docsLink);
121
+ // Relative symlink: .aw_registry/platform/docs → ../../../content
122
+ // (from ~/.aw_repo/.aw_registry/platform/docs to ~/.aw_repo/content)
123
+ symlinkSync('../../../content', docsLink);
118
124
  } catch { /* best effort */ }
119
125
  }
120
126
  }
121
127
 
128
+ /**
129
+ * Create the ~/.aw_registry symlink pointing to the .aw_registry/ subdir inside the git repo.
130
+ */
131
+ function ensureWorkspaceSymlink() {
132
+ const target = join(GLOBAL_GIT_DIR, REGISTRY_DIR);
133
+
134
+ if (existsSync(GLOBAL_AW_DIR)) {
135
+ // Already exists — check if it's the right symlink
136
+ try {
137
+ if (lstatSync(GLOBAL_AW_DIR).isSymbolicLink()) return; // already a symlink, good
138
+ } catch { /* */ }
139
+ return; // exists as real dir, handled by caller (backup)
140
+ }
141
+
142
+ // Ensure parent dir for the symlink target exists
143
+ if (!existsSync(target)) {
144
+ mkdirSync(target, { recursive: true });
145
+ }
146
+
147
+ symlinkSync(target, GLOBAL_AW_DIR);
148
+ }
149
+
122
150
  export async function initCommand(args) {
123
151
  const namespace = args['--namespace'] || null;
124
152
  let user = args['--user'] || '';
@@ -176,7 +204,7 @@ export async function initCommand(args) {
176
204
  }
177
205
 
178
206
  const cwd = process.cwd();
179
- const hasGitRepo = existsSync(GLOBAL_AW_DIR) && isGitRepo(GLOBAL_AW_DIR);
207
+ const hasGitRepo = existsSync(GLOBAL_GIT_DIR) && existsSync(join(GLOBAL_GIT_DIR, '.git'));
180
208
  const hasConfig = config.exists(GLOBAL_AW_DIR);
181
209
 
182
210
  // ── Fast path: already initialized → just pull + link ─────────────────
@@ -186,38 +214,38 @@ export async function initCommand(args) {
186
214
  const isNewSubTeam = folderName && cfg && !cfg.include.includes(folderName);
187
215
 
188
216
  await withLock(GLOBAL_AW_DIR, async () => {
189
- // Pull latest
190
217
  if (!silent) fmt.logStep('Syncing registry...');
191
218
  try {
192
219
  const { updated, conflicts } = gitPull(GLOBAL_AW_DIR);
193
220
  if (conflicts && !silent) {
194
- fmt.logWarn('Merge conflicts detected — resolve manually in ~/.aw_registry/');
221
+ fmt.logWarn('Merge conflicts detected — resolve manually in ~/.aw_repo/');
195
222
  }
196
223
  } catch (e) {
197
224
  if (!silent) fmt.logWarn(`Pull failed: ${e.message}`);
198
225
  }
199
226
 
200
- // Add new namespace if needed
201
227
  if (isNewSubTeam) {
202
228
  if (!silent) fmt.logStep(`Adding sub-team ${chalk.cyan(folderName)}...`);
203
229
 
204
- // Check if namespace exists in remote
205
230
  const nsExists = gitLsRemote(GLOBAL_AW_DIR, folderName);
206
231
  if (nsExists) {
207
232
  gitSparseAdd(GLOBAL_AW_DIR, [`${REGISTRY_DIR}/${folderName}`]);
208
233
  } else {
209
- // Template bootstrap
210
234
  gitSparseAdd(GLOBAL_AW_DIR, [`${REGISTRY_DIR}/[template]`]);
211
- // Pull to get template content
212
235
  try { gitPull(GLOBAL_AW_DIR); } catch { /* already up to date */ }
213
236
  bootstrapFromTemplate(folderName, teamNS);
237
+ // Remove [template] from sparse checkout
238
+ const currentPaths = gitSparseList(GLOBAL_AW_DIR);
239
+ const cleanPaths = currentPaths.filter(p => !p.includes('[template]'));
240
+ if (cleanPaths.length !== currentPaths.length) {
241
+ gitSparseSet(GLOBAL_AW_DIR, cleanPaths);
242
+ }
214
243
  }
215
244
 
216
245
  config.addPattern(GLOBAL_AW_DIR, folderName);
217
246
  }
218
247
  }, { skipIfLocked: silent });
219
248
 
220
- // Re-link IDE dirs + hooks (idempotent)
221
249
  ensureDocsSymlink();
222
250
  const freshCfg = config.load(GLOBAL_AW_DIR);
223
251
  linkWorkspace(HOME);
@@ -228,7 +256,6 @@ export async function initCommand(args) {
228
256
  if (cwd !== HOME) setupMcp(cwd, freshCfg?.namespace || team);
229
257
  installGlobalHooks();
230
258
 
231
- // Link current project if needed
232
259
  if (cwd !== HOME && !existsSync(join(cwd, '.aw_registry'))) {
233
260
  try {
234
261
  symlinkSync(GLOBAL_AW_DIR, join(cwd, '.aw_registry'));
@@ -252,11 +279,21 @@ export async function initCommand(args) {
252
279
 
253
280
  // ── Full init: first time setup ───────────────────────────────────────
254
281
 
255
- // Backup old non-git ~/.aw_registry/ if it exists
256
- if (existsSync(GLOBAL_AW_DIR) && !isGitRepo(GLOBAL_AW_DIR)) {
257
- const backupDir = `${GLOBAL_AW_DIR}.bak-${new Date().toISOString().slice(0, 10)}`;
258
- fmt.logWarn(`Existing non-git ~/.aw_registry/ found — backing up to ${backupDir}`);
259
- renameSync(GLOBAL_AW_DIR, backupDir);
282
+ // Backup old non-git ~/.aw_registry/ if it's a real directory (not a symlink)
283
+ if (existsSync(GLOBAL_AW_DIR)) {
284
+ try {
285
+ if (!lstatSync(GLOBAL_AW_DIR).isSymbolicLink()) {
286
+ const backupDir = `${GLOBAL_AW_DIR}.bak-${new Date().toISOString().slice(0, 10)}`;
287
+ fmt.logWarn(`Existing ~/.aw_registry/ directory found — backing up to ${backupDir}`);
288
+ renameSync(GLOBAL_AW_DIR, backupDir);
289
+ }
290
+ } catch { /* */ }
291
+ }
292
+
293
+ // Backup old ~/.aw_repo/ if it exists but isn't a git repo
294
+ if (existsSync(GLOBAL_GIT_DIR) && !existsSync(join(GLOBAL_GIT_DIR, '.git'))) {
295
+ const backupDir = `${GLOBAL_GIT_DIR}.bak-${new Date().toISOString().slice(0, 10)}`;
296
+ renameSync(GLOBAL_GIT_DIR, backupDir);
260
297
  }
261
298
 
262
299
  // Auto-detect user
@@ -266,8 +303,6 @@ export async function initCommand(args) {
266
303
  } catch { /* git not configured */ }
267
304
  }
268
305
 
269
- const cfg = config.exists(GLOBAL_AW_DIR) ? config.load(GLOBAL_AW_DIR) : null;
270
-
271
306
  fmt.note([
272
307
  `${chalk.dim('source:')} ~/.aw_registry/`,
273
308
  folderName ? `${chalk.dim('namespace:')} ${folderName}` : `${chalk.dim('namespace:')} ${chalk.dim('none')}`,
@@ -275,7 +310,7 @@ export async function initCommand(args) {
275
310
  `${chalk.dim('version:')} v${VERSION}`,
276
311
  ].filter(Boolean).join('\n'), 'Config created');
277
312
 
278
- // Step 1: Clone registry as persistent sparse checkout
313
+ // Step 1: Clone registry to ~/.aw_repo/
279
314
  const s = fmt.spinner();
280
315
  const pullTargets = folderName ? `platform + ${folderName}` : 'platform';
281
316
  s.start(`Cloning registry (${pullTargets})...`);
@@ -283,18 +318,27 @@ export async function initCommand(args) {
283
318
  const initialPaths = includeToSparsePaths(['platform']);
284
319
 
285
320
  try {
286
- const repo = cfg?.repo || REGISTRY_REPO;
287
- gitInit(repo, REGISTRY_BASE_BRANCH, GLOBAL_AW_DIR, initialPaths);
321
+ gitInit(REGISTRY_REPO, REGISTRY_BASE_BRANCH, GLOBAL_GIT_DIR, initialPaths);
288
322
  s.stop('Repository cloned');
289
323
  } catch (e) {
290
324
  s.stop(chalk.red('Clone failed'));
291
325
  fmt.cancel(e.message);
292
326
  }
293
327
 
294
- // Step 2: Write config now that dir exists
328
+ // Step 2: Symlink ~/.aw_registry ~/.aw_repo/.aw_registry
329
+ ensureWorkspaceSymlink();
330
+
331
+ // Step 3: Add git excludes for local config files
332
+ addGitExcludes(GLOBAL_GIT_DIR, [
333
+ `${REGISTRY_DIR}/.sync-config.json`,
334
+ `${REGISTRY_DIR}/.aw-manifest.json`,
335
+ '.aw-lock/',
336
+ ]);
337
+
338
+ // Step 4: Write config (goes into the symlinked dir)
295
339
  const newCfg = config.create(GLOBAL_AW_DIR, { namespace: team, user });
296
340
 
297
- // Step 3: Add namespace via sparse-checkout
341
+ // Step 5: Add namespace via sparse-checkout
298
342
  if (folderName) {
299
343
  const s2 = fmt.spinner();
300
344
  s2.start(`Setting up namespace ${chalk.cyan(folderName)}...`);
@@ -304,24 +348,29 @@ export async function initCommand(args) {
304
348
  gitSparseAdd(GLOBAL_AW_DIR, [`${REGISTRY_DIR}/${folderName}`]);
305
349
  s2.stop(`Namespace ${chalk.cyan(folderName)} checked out`);
306
350
  } else {
307
- // Template bootstrap
351
+ // Temporarily add [template] to sparse checkout, bootstrap, then remove it
308
352
  gitSparseAdd(GLOBAL_AW_DIR, [`${REGISTRY_DIR}/[template]`]);
309
353
  bootstrapFromTemplate(folderName, teamNS);
354
+ // Remove [template] from sparse checkout — no longer needed
355
+ const currentPaths = gitSparseList(GLOBAL_AW_DIR);
356
+ const cleanPaths = currentPaths.filter(p => !p.includes('[template]'));
357
+ if (cleanPaths.length !== currentPaths.length) {
358
+ gitSparseSet(GLOBAL_AW_DIR, cleanPaths);
359
+ }
310
360
  s2.stop(`Namespace ${chalk.cyan(folderName)} bootstrapped from template`);
311
361
  }
312
362
 
313
363
  config.addPattern(GLOBAL_AW_DIR, folderName);
314
364
  }
315
365
 
316
- // Always track platform
317
366
  if (!newCfg.include.includes('platform')) {
318
367
  config.addPattern(GLOBAL_AW_DIR, 'platform');
319
368
  }
320
369
 
321
- // Step 4: Docs symlink
370
+ // Step 6: Docs symlink
322
371
  ensureDocsSymlink();
323
372
 
324
- // Step 5: Link IDE dirs + setup tasks
373
+ // Step 7: Link IDE dirs + setup tasks
325
374
  fmt.logStep('Linking IDE symlinks...');
326
375
  linkWorkspace(HOME);
327
376
  generateCommands(HOME);
@@ -332,7 +381,7 @@ export async function initCommand(args) {
332
381
  const hooksInstalled = installGlobalHooks();
333
382
  installIdeTasks();
334
383
 
335
- // Step 6: Symlink in current directory
384
+ // Step 8: Symlink in current directory
336
385
  if (cwd !== HOME && !existsSync(join(cwd, '.aw_registry'))) {
337
386
  try {
338
387
  symlinkSync(GLOBAL_AW_DIR, join(cwd, '.aw_registry'));
@@ -340,11 +389,12 @@ export async function initCommand(args) {
340
389
  } catch { /* best effort */ }
341
390
  }
342
391
 
343
- // Step 7: Write install manifest for nuke cleanup
392
+ // Step 9: Write install manifest for nuke cleanup
344
393
  const manifest = {
345
394
  version: 2,
346
395
  installedAt: new Date().toISOString(),
347
396
  globalDir: GLOBAL_AW_DIR,
397
+ gitDir: GLOBAL_GIT_DIR,
348
398
  createdFiles: [
349
399
  ...instructionFiles.map(p => p.startsWith(HOME) ? p.slice(HOME.length + 1) : p),
350
400
  ...mcpFiles.map(p => p.startsWith(HOME) ? p.slice(HOME.length + 1) : p),
@@ -353,10 +403,8 @@ export async function initCommand(args) {
353
403
  };
354
404
  saveManifest(manifest);
355
405
 
356
- // Offer to update if a newer version is available
357
406
  await promptUpdate(await args._updateCheck);
358
407
 
359
- // Done
360
408
  fmt.outro([
361
409
  'Install complete',
362
410
  '',
package/commands/nuke.mjs CHANGED
@@ -286,8 +286,20 @@ export function nukeCommand(args) {
286
286
  } catch { /* not installed via npm or no permissions */ }
287
287
  }
288
288
 
289
- // 9. Remove ~/.aw_registry/ itself (source of truth last!)
290
- rmSync(GLOBAL_AW_DIR, { recursive: true, force: true });
289
+ // 9. Remove ~/.aw_repo/ (git clone) and ~/.aw_registry/ (symlink or dir)
290
+ const GLOBAL_GIT_DIR = join(HOME, '.aw_repo');
291
+ if (existsSync(GLOBAL_GIT_DIR)) {
292
+ rmSync(GLOBAL_GIT_DIR, { recursive: true, force: true });
293
+ fmt.logStep('Removed ~/.aw_repo/');
294
+ }
295
+ // Remove ~/.aw_registry/ — may be a symlink (new) or real dir (legacy)
296
+ try {
297
+ if (lstatSync(GLOBAL_AW_DIR).isSymbolicLink()) {
298
+ unlinkSync(GLOBAL_AW_DIR);
299
+ } else {
300
+ rmSync(GLOBAL_AW_DIR, { recursive: true, force: true });
301
+ }
302
+ } catch { rmSync(GLOBAL_AW_DIR, { recursive: true, force: true }); }
291
303
  fmt.logStep('Removed ~/.aw_registry/');
292
304
 
293
305
  fmt.outro([
package/commands/pull.mjs CHANGED
@@ -11,7 +11,7 @@ import * as config from '../config.mjs';
11
11
  import * as fmt from '../fmt.mjs';
12
12
  import { chalk } from '../fmt.mjs';
13
13
  import {
14
- gitPull, gitSparseAdd, gitLsRemote, isGitRepo, withLock,
14
+ gitPull, gitSparseAdd, gitLsRemote, isGitRepo, withLock, resolveRepoRoot,
15
15
  } from '../git.mjs';
16
16
  import { REGISTRY_BASE_BRANCH, REGISTRY_DIR } from '../constants.mjs';
17
17
  import { resolveInput } from '../paths.mjs';
@@ -165,10 +165,11 @@ export async function pullCommand(args) {
165
165
  // ── Helpers ──────────────────────────────────────────────────────────
166
166
 
167
167
  function dryRunDiff(workspaceDir, log) {
168
+ const repoDir = resolveRepoRoot(workspaceDir);
168
169
  try {
169
- execSync('git fetch origin', { cwd: workspaceDir, stdio: 'pipe' });
170
+ execSync('git fetch origin', { cwd: repoDir, stdio: 'pipe' });
170
171
  const diff = execSync(`git diff HEAD..origin/${REGISTRY_BASE_BRANCH} --stat`, {
171
- cwd: workspaceDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
172
+ cwd: repoDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
172
173
  });
173
174
 
174
175
  if (diff.trim()) {
@@ -184,9 +185,10 @@ function dryRunDiff(workspaceDir, log) {
184
185
  }
185
186
 
186
187
  function printConflicts(workspaceDir, log) {
188
+ const repoDir = resolveRepoRoot(workspaceDir);
187
189
  try {
188
190
  const output = execSync('git diff --name-only --diff-filter=U', {
189
- cwd: workspaceDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
191
+ cwd: repoDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
190
192
  });
191
193
  const conflictFiles = output.trim().split('\n').filter(Boolean);
192
194
 
@@ -195,7 +197,7 @@ function printConflicts(workspaceDir, log) {
195
197
  log.note(
196
198
  lines + '\n\n' +
197
199
  chalk.dim('Resolve conflicts, then run:\n') +
198
- chalk.dim(' cd ~/.aw_registry && git add -A && git merge --continue'),
200
+ chalk.dim(' cd ~/.aw_repo && git add -A && git merge --continue'),
199
201
  chalk.red('Merge Conflicts')
200
202
  );
201
203
  }
package/commands/push.mjs CHANGED
@@ -87,11 +87,13 @@ function collectModifiedFiles(workspaceDir) {
87
87
  // Only files under .aw_registry/
88
88
  if (!entry.path.startsWith(REGISTRY_DIR + '/')) continue;
89
89
 
90
+ // regRelPath is relative to .aw_registry/ (e.g. "platform/agents/foo.md")
90
91
  const regRelPath = entry.path.slice(REGISTRY_DIR.length + 1);
91
92
  const meta = parseRegistryPath(regRelPath);
92
93
  if (!meta) continue;
93
94
 
94
- const absPath = join(workspaceDir, entry.path);
95
+ // workspaceDir IS .aw_registry/, so absPath uses regRelPath (not the full git-relative path)
96
+ const absPath = join(workspaceDir, regRelPath);
95
97
  if (!existsSync(absPath)) continue;
96
98
 
97
99
  files.push({
@@ -138,7 +138,7 @@ export function statusCommand(args) {
138
138
 
139
139
  // Hints
140
140
  if (conflicts.length > 0) {
141
- fmt.logWarn(`Resolve conflicts: ${chalk.dim('cd ~/.aw_registry && git diff --name-only --diff-filter=U')}`);
141
+ fmt.logWarn(`Resolve conflicts: ${chalk.dim('cd ~/.aw_repo && git diff --name-only --diff-filter=U')}`);
142
142
  }
143
143
  if (untracked.length > 0 || modified.length > 0) {
144
144
  fmt.logInfo(`Push changes: ${chalk.dim('aw push')} or ${chalk.dim('aw push <path>')}`);
package/git.mjs CHANGED
@@ -1,28 +1,63 @@
1
- // git.mjs — Git-native sync engine. Persistent sparse checkout at ~/.aw_registry/.
1
+ // git.mjs — Git-native sync engine. Persistent sparse checkout.
2
2
  //
3
- // The global registry dir IS a git repo. Pull = git pull. Status = git status.
4
- // No temp dirs, no manifest hashing, no plan/apply pipeline.
3
+ // Layout:
4
+ // ~/.aw_repo/ — the git clone (repo root, has .git/)
5
+ // ~/.aw_registry/ — symlink → ~/.aw_repo/.aw_registry/
6
+ //
7
+ // All git commands run at the repo root. Callers pass workspaceDir
8
+ // (~/.aw_registry/) and functions auto-resolve to the repo root.
5
9
 
6
10
  import { execSync } from 'node:child_process';
7
- import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
8
- import { join } from 'node:path';
11
+ import { existsSync, mkdirSync, rmSync, statSync, realpathSync, readFileSync, appendFileSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
+ import { homedir } from 'node:os';
9
14
  import { REGISTRY_BASE_BRANCH, REGISTRY_DIR, DOCS_SOURCE_DIR } from './constants.mjs';
10
15
 
16
+ /** Where the git repo is cloned (the actual .git/ lives here). */
17
+ export const GLOBAL_GIT_DIR = join(homedir(), '.aw_repo');
18
+
19
+ // ── Repo root resolution ─────────────────────────────────────────────
20
+
21
+ /**
22
+ * Resolve a workspace/target dir to the git repo root (where .git/ lives).
23
+ *
24
+ * - ~/.aw_repo/ → ~/.aw_repo/ (direct)
25
+ * - ~/.aw_registry/ (symlink → ~/.aw_repo/.aw_registry/) → ~/.aw_repo/
26
+ * - Any dir with .git/ → that dir
27
+ */
28
+ export function resolveRepoRoot(dir) {
29
+ // Direct .git check
30
+ if (existsSync(join(dir, '.git'))) return dir;
31
+
32
+ // Resolve symlinks and walk up to find .git/
33
+ try {
34
+ let resolved = realpathSync(dir);
35
+ for (let i = 0; i < 3; i++) {
36
+ const parent = dirname(resolved);
37
+ if (parent === resolved) break; // reached filesystem root
38
+ if (existsSync(join(parent, '.git'))) return parent;
39
+ resolved = parent;
40
+ }
41
+ } catch { /* not a valid path */ }
42
+
43
+ return dir; // fallback — caller will get a git error if it's not a repo
44
+ }
45
+
11
46
  // ── Core operations ──────────────────────────────────────────────────
12
47
 
13
48
  /**
14
- * Clone + sparse-checkout init. Creates a persistent repo at targetDir.
49
+ * Clone + sparse-checkout init. Creates a persistent repo at gitDir.
15
50
  *
16
51
  * @param {string} repo — GitHub owner/name or full URL
17
52
  * @param {string} branch — branch to checkout
18
- * @param {string} targetDir — e.g. ~/.aw_registry
53
+ * @param {string} gitDirrepo root (e.g. ~/.aw_repo)
19
54
  * @param {string[]} sparsePaths — initial sparse-checkout paths
20
55
  */
21
- export function gitInit(repo, branch, targetDir, sparsePaths) {
56
+ export function gitInit(repo, branch, gitDir, sparsePaths) {
22
57
  const repoUrl = repo.startsWith('http') ? repo : `https://github.com/${repo}.git`;
23
58
 
24
59
  try {
25
- execSync(`git clone --filter=blob:none --sparse "${repoUrl}" "${targetDir}"`, {
60
+ execSync(`git clone --filter=blob:none --sparse "${repoUrl}" "${gitDir}"`, {
26
61
  stdio: 'pipe',
27
62
  });
28
63
  } catch (e) {
@@ -30,13 +65,13 @@ export function gitInit(repo, branch, targetDir, sparsePaths) {
30
65
  }
31
66
 
32
67
  try {
33
- execSync('git sparse-checkout init --cone', { cwd: targetDir, stdio: 'pipe' });
68
+ execSync('git sparse-checkout init --cone', { cwd: gitDir, stdio: 'pipe' });
34
69
  if (sparsePaths.length > 0) {
35
70
  execSync(`git sparse-checkout set --skip-checks ${sparsePaths.map(p => `"${p}"`).join(' ')}`, {
36
- cwd: targetDir, stdio: 'pipe',
71
+ cwd: gitDir, stdio: 'pipe',
37
72
  });
38
73
  }
39
- execSync(`git checkout ${branch}`, { cwd: targetDir, stdio: 'pipe' });
74
+ execSync(`git checkout ${branch}`, { cwd: gitDir, stdio: 'pipe' });
40
75
  } catch (e) {
41
76
  throw new Error(`Failed sparse checkout: ${e.message}`);
42
77
  }
@@ -44,12 +79,14 @@ export function gitInit(repo, branch, targetDir, sparsePaths) {
44
79
 
45
80
  /**
46
81
  * Fetch + merge. Returns { updated, conflicts, summary }.
82
+ * @param {string} targetDir — workspace or git dir (auto-resolved)
47
83
  */
48
84
  export function gitPull(targetDir) {
85
+ const repoDir = resolveRepoRoot(targetDir);
49
86
  const branch = REGISTRY_BASE_BRANCH;
50
87
 
51
88
  try {
52
- execSync('git fetch origin', { cwd: targetDir, stdio: 'pipe' });
89
+ execSync('git fetch origin', { cwd: repoDir, stdio: 'pipe' });
53
90
  } catch (e) {
54
91
  throw new Error(`Fetch failed: ${e.message}`);
55
92
  }
@@ -60,11 +97,10 @@ export function gitPull(targetDir) {
60
97
 
61
98
  try {
62
99
  summary = execSync(`git merge origin/${branch} --no-edit`, {
63
- cwd: targetDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
100
+ cwd: repoDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
64
101
  });
65
102
  updated = !summary.includes('Already up to date');
66
103
  } catch (e) {
67
- // Merge conflict
68
104
  const stderr = e.stderr?.toString() || '';
69
105
  const stdout = e.stdout?.toString() || '';
70
106
  if (stderr.includes('CONFLICT') || stdout.includes('CONFLICT')) {
@@ -80,12 +116,13 @@ export function gitPull(targetDir) {
80
116
  }
81
117
 
82
118
  /**
83
- * Add paths to sparse-checkout (additive — keeps existing paths).
119
+ * Add paths to sparse-checkout (additive).
84
120
  */
85
121
  export function gitSparseAdd(targetDir, paths) {
86
122
  if (paths.length === 0) return;
123
+ const repoDir = resolveRepoRoot(targetDir);
87
124
  execSync(`git sparse-checkout add --skip-checks ${paths.map(p => `"${p}"`).join(' ')}`, {
88
- cwd: targetDir, stdio: 'pipe',
125
+ cwd: repoDir, stdio: 'pipe',
89
126
  });
90
127
  }
91
128
 
@@ -93,8 +130,9 @@ export function gitSparseAdd(targetDir, paths) {
93
130
  * Replace sparse-checkout paths entirely.
94
131
  */
95
132
  export function gitSparseSet(targetDir, paths) {
133
+ const repoDir = resolveRepoRoot(targetDir);
96
134
  execSync(`git sparse-checkout set --skip-checks ${paths.map(p => `"${p}"`).join(' ')}`, {
97
- cwd: targetDir, stdio: 'pipe',
135
+ cwd: repoDir, stdio: 'pipe',
98
136
  });
99
137
  }
100
138
 
@@ -102,9 +140,10 @@ export function gitSparseSet(targetDir, paths) {
102
140
  * Get sparse-checkout list.
103
141
  */
104
142
  export function gitSparseList(targetDir) {
143
+ const repoDir = resolveRepoRoot(targetDir);
105
144
  try {
106
145
  const output = execSync('git sparse-checkout list', {
107
- cwd: targetDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
146
+ cwd: repoDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
108
147
  });
109
148
  return output.trim().split('\n').filter(Boolean);
110
149
  } catch {
@@ -114,12 +153,13 @@ export function gitSparseList(targetDir) {
114
153
 
115
154
  /**
116
155
  * Git porcelain status. Returns array of { status, path }.
117
- * Status codes: M=modified, ??=untracked, D=deleted, A=added, UU/AA=conflict
156
+ * Paths are relative to repo root (e.g. ".aw_registry/platform/agents/foo.md").
118
157
  */
119
158
  export function gitStatus(targetDir) {
159
+ const repoDir = resolveRepoRoot(targetDir);
120
160
  try {
121
161
  const output = execSync('git status --porcelain', {
122
- cwd: targetDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
162
+ cwd: repoDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
123
163
  });
124
164
  if (!output.trim()) return [];
125
165
  return output.trim().split('\n').map(line => {
@@ -134,12 +174,12 @@ export function gitStatus(targetDir) {
134
174
 
135
175
  /**
136
176
  * Check if a path exists in the remote tree (without checking it out).
137
- * Used to detect if a namespace already exists in the registry.
138
177
  */
139
178
  export function gitLsRemote(targetDir, path) {
179
+ const repoDir = resolveRepoRoot(targetDir);
140
180
  try {
141
181
  const output = execSync(`git ls-tree -d HEAD "${REGISTRY_DIR}/${path}"`, {
142
- cwd: targetDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
182
+ cwd: repoDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
143
183
  });
144
184
  return output.trim().length > 0;
145
185
  } catch {
@@ -148,10 +188,13 @@ export function gitLsRemote(targetDir, path) {
148
188
  }
149
189
 
150
190
  /**
151
- * Check if targetDir is a git repo.
191
+ * Check if targetDir (or its resolved parent) is backed by a git repo.
152
192
  */
153
193
  export function isGitRepo(targetDir) {
154
- return existsSync(join(targetDir, '.git'));
194
+ if (existsSync(join(targetDir, '.git'))) return true;
195
+ // Check if it's a symlink into a git repo
196
+ const resolved = resolveRepoRoot(targetDir);
197
+ return existsSync(join(resolved, '.git'));
155
198
  }
156
199
 
157
200
  // ── Concurrency lock ─────────────────────────────────────────────────
@@ -160,15 +203,12 @@ const LOCK_DIR_NAME = '.aw-lock';
160
203
  const LOCK_STALE_MS = 30_000;
161
204
 
162
205
  /**
163
- * Acquire a directory-based lock, run fn, release.
164
- * @param {string} targetDir
165
- * @param {Function} fn — async or sync function to run under lock
166
- * @param {{ skipIfLocked?: boolean }} opts
206
+ * Acquire a directory-based lock on the git repo, run fn, release.
167
207
  */
168
208
  export async function withLock(targetDir, fn, { skipIfLocked = false } = {}) {
169
- const lockDir = join(targetDir, LOCK_DIR_NAME);
209
+ const repoDir = resolveRepoRoot(targetDir);
210
+ const lockDir = join(repoDir, LOCK_DIR_NAME);
170
211
 
171
- // Check for stale lock
172
212
  if (existsSync(lockDir)) {
173
213
  try {
174
214
  const age = Date.now() - statSync(lockDir).mtimeMs;
@@ -177,7 +217,6 @@ export async function withLock(targetDir, fn, { skipIfLocked = false } = {}) {
177
217
  } else if (skipIfLocked) {
178
218
  return null;
179
219
  } else {
180
- // Wait briefly then retry
181
220
  await new Promise(r => setTimeout(r, 1000));
182
221
  if (existsSync(lockDir)) {
183
222
  throw new Error('Another aw instance is running. Retry in a moment.');
@@ -205,14 +244,24 @@ export async function withLock(targetDir, fn, { skipIfLocked = false } = {}) {
205
244
  }
206
245
  }
207
246
 
247
+ /**
248
+ * Add patterns to .git/info/exclude so local config files don't show in git status.
249
+ */
250
+ export function addGitExcludes(gitDir, patterns) {
251
+ const excludePath = join(gitDir, '.git', 'info', 'exclude');
252
+ try {
253
+ const existing = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
254
+ const toAdd = patterns.filter(p => !existing.includes(p));
255
+ if (toAdd.length > 0) {
256
+ appendFileSync(excludePath, '\n# aw local config\n' + toAdd.join('\n') + '\n');
257
+ }
258
+ } catch { /* best effort */ }
259
+ }
260
+
208
261
  // ── Path helpers ─────────────────────────────────────────────────────
209
262
 
210
263
  /**
211
264
  * Compute sparse checkout paths from include paths.
212
- * e.g., ["platform", "dev/agents/debugger"] -> [".aw_registry/platform", ".aw_registry/dev/agents/debugger"]
213
- *
214
- * When "platform" is in the paths, also includes the repo's docs source
215
- * directory (content/) so docs are pulled on-the-fly into platform/docs/.
216
265
  */
217
266
  export function includeToSparsePaths(paths) {
218
267
  const result = new Set();
package/link.mjs CHANGED
@@ -21,7 +21,7 @@ const ALL_KNOWN_TYPES = new Set([...FILE_TYPES, 'skills', 'commands', 'evals', '
21
21
  function listNamespaceDirs(awDir) {
22
22
  if (!existsSync(awDir)) return [];
23
23
  return readdirSync(awDir, { withFileTypes: true })
24
- .filter(d => d.isDirectory() && !d.name.startsWith('.'))
24
+ .filter(d => d.isDirectory() && !d.name.startsWith('.') && d.name !== '[template]')
25
25
  .map(d => d.name);
26
26
  }
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.34-beta.10",
3
+ "version": "0.1.34-beta.12",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {