@ghl-ai/aw 0.1.1 → 0.1.3

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
@@ -13,6 +13,7 @@ const COMMANDS = {
13
13
  status: () => import('./commands/status.mjs').then(m => m.statusCommand),
14
14
  search: () => import('./commands/search.mjs').then(m => m.searchCommand),
15
15
  nuke: () => import('./commands/nuke.mjs').then(m => m.nukeCommand),
16
+ daemon: () => import('./commands/daemon.mjs').then(m => m.daemonCommand),
16
17
  };
17
18
 
18
19
  function parseArgs(argv) {
@@ -85,6 +86,10 @@ function printHelp() {
85
86
  cmd('aw status', 'Show synced paths, modified files & conflicts'),
86
87
  cmd('aw drop <path>', 'Stop syncing or delete local content'),
87
88
  cmd('aw nuke', 'Remove entire .aw_registry/ & start fresh'),
89
+ cmd('aw daemon install', 'Auto-pull on a schedule (macOS launchd / Linux cron)'),
90
+ cmd('aw daemon install --interval 30m', 'Set custom interval (e.g. 30m, 2h, 3600)'),
91
+ cmd('aw daemon uninstall', 'Stop the background daemon'),
92
+ cmd('aw daemon status', 'Check if daemon is running'),
88
93
 
89
94
  sec('Examples'),
90
95
  '',
@@ -0,0 +1,192 @@
1
+ // commands/daemon.mjs — Install/uninstall a launchd (macOS) or cron (Linux) job
2
+ // that silently runs `aw pull` on a schedule without any user interaction.
3
+
4
+ import { existsSync, writeFileSync, readFileSync, mkdirSync, unlinkSync } from 'node:fs';
5
+ import { execSync } from 'node:child_process';
6
+ import { join } from 'node:path';
7
+ import { homedir, platform } from 'node:os';
8
+ import * as fmt from '../fmt.mjs';
9
+ import { chalk } from '../fmt.mjs';
10
+
11
+ const LABEL = 'ai.ghl.aw.pull';
12
+ const PLIST_PATH = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
13
+ const DEFAULT_INTERVAL = 3600; // 1 hour in seconds
14
+
15
+ function getAwBin() {
16
+ try {
17
+ return execSync('which aw', { encoding: 'utf8' }).trim();
18
+ } catch {
19
+ return 'aw';
20
+ }
21
+ }
22
+
23
+ // ── macOS: launchd ──────────────────────────────────────────────────────────
24
+
25
+ function installLaunchd(intervalSeconds) {
26
+ const awBin = getAwBin();
27
+ const logDir = join(homedir(), '.aw_registry', 'logs');
28
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
29
+
30
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
31
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
32
+ <plist version="1.0">
33
+ <dict>
34
+ <key>Label</key>
35
+ <string>${LABEL}</string>
36
+
37
+ <key>ProgramArguments</key>
38
+ <array>
39
+ <string>${awBin}</string>
40
+ <string>pull</string>
41
+ <string>--silent</string>
42
+ </array>
43
+
44
+ <key>StartInterval</key>
45
+ <integer>${intervalSeconds}</integer>
46
+
47
+ <key>RunAtLoad</key>
48
+ <true/>
49
+
50
+ <key>StandardOutPath</key>
51
+ <string>${logDir}/pull.log</string>
52
+
53
+ <key>StandardErrorPath</key>
54
+ <string>${logDir}/pull-error.log</string>
55
+
56
+ <key>EnvironmentVariables</key>
57
+ <dict>
58
+ <key>PATH</key>
59
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
60
+ </dict>
61
+ </dict>
62
+ </plist>`;
63
+
64
+ writeFileSync(PLIST_PATH, plist);
65
+
66
+ // Unload first if already loaded (to apply new interval)
67
+ try { execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`); } catch { /* not loaded */ }
68
+ execSync(`launchctl load "${PLIST_PATH}"`);
69
+
70
+ fmt.logStep(`Daemon installed — runs every ${formatInterval(intervalSeconds)}`);
71
+ fmt.logStep(`Logs: ${chalk.dim(logDir + '/pull.log')}`);
72
+ }
73
+
74
+ function uninstallLaunchd() {
75
+ if (!existsSync(PLIST_PATH)) {
76
+ fmt.logStep('No daemon installed.');
77
+ return;
78
+ }
79
+ try { execSync(`launchctl unload "${PLIST_PATH}"`); } catch { /* ignore */ }
80
+ unlinkSync(PLIST_PATH);
81
+ fmt.logStep('Daemon removed.');
82
+ }
83
+
84
+ function statusLaunchd() {
85
+ if (!existsSync(PLIST_PATH)) return null;
86
+ try {
87
+ const out = execSync(`launchctl list ${LABEL} 2>/dev/null`, { encoding: 'utf8' });
88
+ const pid = out.match(/"PID"\s*=\s*(\d+)/)?.[1];
89
+ const lastExit = out.match(/"LastExitStatus"\s*=\s*(\d+)/)?.[1];
90
+ return { running: !!pid, pid, lastExit };
91
+ } catch {
92
+ return { running: false };
93
+ }
94
+ }
95
+
96
+ // ── Linux: crontab ──────────────────────────────────────────────────────────
97
+
98
+ function toCronExpression(intervalSeconds) {
99
+ const minutes = Math.round(intervalSeconds / 60);
100
+ if (minutes < 60) return `*/${minutes} * * * *`;
101
+ const hours = Math.round(minutes / 60);
102
+ return `0 */${hours} * * *`;
103
+ }
104
+
105
+ function installCron(intervalSeconds) {
106
+ const awBin = getAwBin();
107
+ const logDir = join(homedir(), '.aw_registry', 'logs');
108
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
109
+
110
+ const cronExpr = toCronExpression(intervalSeconds);
111
+ const cronLine = `${cronExpr} ${awBin} pull --silent >> ${logDir}/pull.log 2>&1 # aw-daemon`;
112
+
113
+ let current = '';
114
+ try { current = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' }); } catch { /* empty */ }
115
+
116
+ // Remove old aw-daemon line if present
117
+ const cleaned = current.split('\n').filter(l => !l.includes('# aw-daemon')).join('\n');
118
+ const updated = cleaned.trimEnd() + '\n' + cronLine + '\n';
119
+
120
+ execSync(`echo ${JSON.stringify(updated)} | crontab -`);
121
+ fmt.logStep(`Cron job installed — runs every ${formatInterval(intervalSeconds)}`);
122
+ fmt.logStep(`Logs: ${chalk.dim(logDir + '/pull.log')}`);
123
+ }
124
+
125
+ function uninstallCron() {
126
+ let current = '';
127
+ try { current = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' }); } catch { return; }
128
+ const cleaned = current.split('\n').filter(l => !l.includes('# aw-daemon')).join('\n');
129
+ execSync(`echo ${JSON.stringify(cleaned)} | crontab -`);
130
+ fmt.logStep('Cron job removed.');
131
+ }
132
+
133
+ // ── Helpers ─────────────────────────────────────────────────────────────────
134
+
135
+ function formatInterval(seconds) {
136
+ if (seconds < 3600) return `${seconds / 60} minutes`;
137
+ if (seconds === 3600) return '1 hour';
138
+ return `${seconds / 3600} hours`;
139
+ }
140
+
141
+ function parseInterval(str) {
142
+ if (!str) return DEFAULT_INTERVAL;
143
+ const n = parseInt(str);
144
+ if (str.endsWith('m')) return n * 60;
145
+ if (str.endsWith('h')) return n * 3600;
146
+ return n; // assume seconds
147
+ }
148
+
149
+ // ── Main ─────────────────────────────────────────────────────────────────────
150
+
151
+ export function daemonCommand(args) {
152
+ const subcommand = args._positional[0] || 'install';
153
+ const interval = parseInterval(args['--interval'] || args._positional[1]);
154
+ const isMac = platform() === 'darwin';
155
+
156
+ fmt.intro(`aw daemon ${subcommand}`);
157
+
158
+ if (subcommand === 'install') {
159
+ fmt.logStep(`Platform: ${isMac ? 'macOS (launchd)' : 'Linux (cron)'}`);
160
+ fmt.logStep(`Interval: every ${formatInterval(interval)}`);
161
+ if (isMac) installLaunchd(interval);
162
+ else installCron(interval);
163
+ fmt.outro(`aw pull will run silently every ${formatInterval(interval)}`);
164
+
165
+ } else if (subcommand === 'uninstall' || subcommand === 'stop') {
166
+ if (isMac) uninstallLaunchd();
167
+ else uninstallCron();
168
+ fmt.outro('Daemon stopped.');
169
+
170
+ } else if (subcommand === 'status') {
171
+ if (isMac) {
172
+ const s = statusLaunchd();
173
+ if (!s) {
174
+ console.log(chalk.dim(' Not installed'));
175
+ } else {
176
+ console.log(` ${s.running ? chalk.green('● running') : chalk.dim('○ not running')} PID: ${s.pid || '—'} Last exit: ${s.lastExit || '—'}`);
177
+ console.log(` Plist: ${chalk.dim(PLIST_PATH)}`);
178
+ }
179
+ } else {
180
+ try {
181
+ const cron = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
182
+ const line = cron.split('\n').find(l => l.includes('# aw-daemon'));
183
+ console.log(line ? chalk.green(' ● installed: ') + chalk.dim(line) : chalk.dim(' Not installed'));
184
+ } catch {
185
+ console.log(chalk.dim(' Not installed'));
186
+ }
187
+ }
188
+
189
+ } else {
190
+ fmt.cancel(`Unknown subcommand: ${subcommand}. Use install / uninstall / status`);
191
+ }
192
+ }
package/commands/init.mjs CHANGED
@@ -1,88 +1,106 @@
1
- // commands/init.mjs — Initialize workspace config + pull ghl + template
2
-
3
- import { mkdirSync, existsSync, writeFileSync, readFileSync } from 'node:fs';
1
+ // commands/init.mjs — True omnipresent init: one install, works everywhere
2
+ //
3
+ // How it works:
4
+ // 1. Installs the source of truth at ~/.aw_registry/
5
+ // 2. Links IDE config to ~/.claude/, ~/.cursor/, ~/.codex/
6
+ // 3. Adds a chpwd hook to ~/.zshrc so every `cd` into a project
7
+ // auto-symlinks .aw_registry → ~/.aw_registry (omnipresence)
8
+ // 4. Starts a background daemon for hourly silent pulls
9
+ // 5. Writes a manifest of everything touched so `aw nuke` can do perfect cleanup
10
+
11
+ import { mkdirSync, existsSync, writeFileSync, readFileSync, chmodSync, symlinkSync, lstatSync } from 'node:fs';
4
12
  import { execSync } from 'node:child_process';
5
- import { join, resolve } from 'node:path';
6
- import { homedir } from 'node:os';
7
- import { select, isCancel } from '@clack/prompts';
13
+ import { join, resolve, relative } from 'node:path';
14
+ import { homedir, platform } from 'node:os';
8
15
  import * as config from '../config.mjs';
9
16
  import * as fmt from '../fmt.mjs';
10
17
  import { chalk } from '../fmt.mjs';
11
18
  import { pullCommand } from './pull.mjs';
12
19
  import { linkWorkspace } from '../link.mjs';
20
+ import { daemonCommand } from './daemon.mjs';
13
21
  import { generateCommands, copyInstructions, initAwDocs } from '../integrate.mjs';
14
22
  import { setupMcp } from '../mcp.mjs';
15
23
 
16
- // Scope definitions
17
- const SCOPES = {
18
- user: {
19
- label: 'Install for you (user scope)',
20
- hint: 'Available in all your projects stored in ~/.claude/',
21
- rootDir: () => homedir(),
22
- gitignore: false,
23
- },
24
- project: {
25
- label: 'Install for all collaborators (project scope)',
26
- hint: 'Checked into git shared with your whole team via .claude/',
27
- rootDir: () => process.cwd(),
28
- gitignore: false,
29
- },
30
- local: {
31
- label: 'Install for you, in this repo only (local scope)',
32
- hint: 'Added to .gitignore — only you see it in this project',
33
- rootDir: () => process.cwd(),
34
- gitignore: true,
35
- },
36
- };
37
-
38
- async function pickScope(preselected) {
39
- if (preselected && SCOPES[preselected]) return preselected;
40
-
41
- const result = await select({
42
- message: 'Where would you like to install?',
43
- options: Object.entries(SCOPES).map(([value, s]) => ({
44
- value,
45
- label: s.label,
46
- hint: s.hint,
47
- })),
48
- initialValue: 'project',
49
- });
50
-
51
- if (isCancel(result)) {
52
- fmt.cancel('Installation cancelled.');
53
- process.exit(0);
24
+ const HOME = homedir();
25
+ const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
26
+ const MANIFEST_PATH = join(GLOBAL_AW_DIR, '.aw-manifest.json');
27
+
28
+ // Shell hook that auto-symlinks .aw_registry into every directory you cd into
29
+ const CHPWD_MARKER = '# aw-omnipresence';
30
+ const CHPWD_HOOK = `
31
+ ${CHPWD_MARKER}
32
+ _aw_chpwd() {
33
+ # Only act inside git repos (has .git/) and skip home dir
34
+ [ "$PWD" = "$HOME" ] && return
35
+ [ ! -d ".git" ] && return
36
+ # Skip if .aw_registry already exists (real dir or symlink)
37
+ [ -e ".aw_registry" ] && return
38
+ # Create symlink to global source of truth
39
+ ln -s "$HOME/.aw_registry" ".aw_registry" 2>/dev/null
40
+ }
41
+ # Fire on every directory change (zsh)
42
+ if [ -n "$ZSH_VERSION" ]; then
43
+ autoload -U add-zsh-hook
44
+ add-zsh-hook chpwd _aw_chpwd
45
+ fi
46
+ # Also fire on shell startup for the initial directory
47
+ _aw_chpwd
48
+ `;
49
+
50
+ function installOmnipresenceHook() {
51
+ const profiles = [];
52
+ if (platform() === 'darwin') {
53
+ profiles.push(join(HOME, '.zshrc'));
54
+ profiles.push(join(HOME, '.zprofile'));
54
55
  }
56
+ profiles.push(join(HOME, '.bashrc'));
57
+ profiles.push(join(HOME, '.bash_profile'));
55
58
 
56
- return result;
59
+ const target = profiles.find(p => existsSync(p)) || join(HOME, '.zshrc');
60
+ const current = existsSync(target) ? readFileSync(target, 'utf8') : '';
61
+
62
+ if (current.includes(CHPWD_MARKER)) {
63
+ fmt.logStep(`Omnipresence hook already in ${target.replace(HOME, '~')}`);
64
+ return target;
65
+ }
66
+
67
+ writeFileSync(target, current + CHPWD_HOOK);
68
+ fmt.logStep(`Omnipresence hook added to ${target.replace(HOME, '~')}`);
69
+ fmt.logStep(chalk.dim(' Every cd into a git repo auto-links .aw_registry'));
70
+ return target;
57
71
  }
58
72
 
59
- function addToGitignore(cwd, patterns) {
60
- const gitignorePath = join(cwd, '.gitignore');
61
- const toAdd = patterns.filter(p => {
62
- if (!existsSync(gitignorePath)) return true;
63
- const content = readFileSync(gitignorePath, 'utf8');
64
- return !content.split('\n').some(line => line.trim() === p);
65
- });
66
-
67
- if (toAdd.length === 0) return;
68
-
69
- const block = `\n# Agentic Workspace (local scope)\n${toAdd.join('\n')}\n`;
70
- if (existsSync(gitignorePath)) {
71
- writeFileSync(gitignorePath, readFileSync(gitignorePath, 'utf8') + block);
72
- } else {
73
- writeFileSync(gitignorePath, block.trimStart());
73
+ function installAutoSync() {
74
+ const profiles = [];
75
+ if (platform() === 'darwin') {
76
+ profiles.push(join(HOME, '.zshrc'));
74
77
  }
75
- fmt.logStep(`Added to .gitignore: ${toAdd.join(', ')}`);
78
+ profiles.push(join(HOME, '.bashrc'));
79
+
80
+ const marker = '# aw-auto-sync';
81
+ const snippet = `\n${marker}\n[ -f "$HOME/.aw_registry/.sync-config.json" ] && command -v aw >/dev/null 2>&1 && aw pull --silent &\n`;
82
+
83
+ const target = profiles.find(p => existsSync(p)) || join(HOME, '.zshrc');
84
+ const current = existsSync(target) ? readFileSync(target, 'utf8') : '';
85
+
86
+ if (current.includes(marker)) return;
87
+
88
+ writeFileSync(target, current + snippet);
89
+ fmt.logStep('Auto-sync on new terminal (background, zero delay)');
90
+ }
91
+
92
+ function saveManifest(data) {
93
+ writeFileSync(MANIFEST_PATH, JSON.stringify(data, null, 2) + '\n');
76
94
  }
77
95
 
78
96
  export async function initCommand(args) {
79
97
  const namespace = args['--namespace'] || null;
80
98
  let user = args['--user'] || '';
81
- const cwd = process.cwd();
82
99
 
83
100
  fmt.intro('aw init');
84
101
 
85
- // Validate namespace slug if provided
102
+ // ── Validate ──────────────────────────────────────────────────────────
103
+
86
104
  if (namespace && !/^[a-z][a-z0-9-]{1,38}[a-z0-9]$/.test(namespace)) {
87
105
  fmt.cancel(`Invalid namespace '${namespace}' — must match: ^[a-z][a-z0-9-]{1,38}[a-z0-9]$`);
88
106
  }
@@ -91,77 +109,104 @@ export async function initCommand(args) {
91
109
  fmt.cancel("'ghl' is a reserved namespace — it is the shared platform layer");
92
110
  }
93
111
 
94
- // Pick install scope
95
- const scopeKey = await pickScope(args['--scope']);
96
- const scope = SCOPES[scopeKey];
97
- const rootDir = scope.rootDir();
98
- const workspaceDir = join(rootDir, '.aw_registry');
112
+ // ── Check existing install ────────────────────────────────────────────
99
113
 
100
- fmt.logStep(`Scope: ${chalk.bold(scope.label)}`);
114
+ if (config.exists(GLOBAL_AW_DIR)) {
115
+ fmt.cancel(`.aw_registry already initialized at ~/\nRun ${chalk.bold('aw nuke')} first to start fresh.`);
116
+ }
117
+
118
+ // ── Auto-detect user ──────────────────────────────────────────────────
101
119
 
102
- // Auto-detect user from GitHub if not provided
103
120
  if (!user) {
104
121
  try {
105
122
  user = execSync('gh api user --jq .login', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
106
- } catch {
107
- // gh not available or not logged in — skip
108
- }
123
+ } catch { /* gh not available */ }
109
124
  }
110
125
 
111
- // Create workspace dir
112
- if (!existsSync(workspaceDir)) {
113
- mkdirSync(workspaceDir, { recursive: true });
114
- fmt.logStep(`Created ${scopeKey === 'user' ? '~/' : ''}.aw_registry/`);
115
- }
126
+ // ── Step 1: Create global source of truth ─────────────────────────────
116
127
 
117
- // Check existing config
118
- if (config.exists(workspaceDir)) {
119
- fmt.cancel('.sync-config.json already exists. Delete it to re-init.');
128
+ if (!existsSync(GLOBAL_AW_DIR)) {
129
+ mkdirSync(GLOBAL_AW_DIR, { recursive: true });
120
130
  }
121
131
 
122
- // Create config (store scope so other commands know where to look)
123
- const cfg = config.create(workspaceDir, { namespace, user, scope: scopeKey });
132
+ const cfg = config.create(GLOBAL_AW_DIR, { namespace, user, scope: 'omnipresent' });
124
133
 
125
- const infoLines = [
134
+ fmt.note([
135
+ `${chalk.dim('source:')} ~/.aw_registry/`,
126
136
  namespace ? `${chalk.dim('namespace:')} ${cfg.namespace}` : `${chalk.dim('namespace:')} ${chalk.dim('none')}`,
127
137
  user ? `${chalk.dim('user:')} ${cfg.user}` : null,
128
- `${chalk.dim('scope:')} ${scopeKey} ${resolve(workspaceDir)}`,
129
- ].filter(Boolean).join('\n');
138
+ `${chalk.dim('mode:')} omnipresent works in every project`,
139
+ ].filter(Boolean).join('\n'), 'Config created');
130
140
 
131
- fmt.note(infoLines, 'Config created');
141
+ // ── Step 2: Pull registry content ─────────────────────────────────────
132
142
 
133
- // Always pull ghl/ (platform layer)
134
- pullCommand({ ...args, _positional: ['ghl'], _workspaceDir: workspaceDir });
143
+ pullCommand({ ...args, _positional: ['ghl'], _workspaceDir: GLOBAL_AW_DIR });
135
144
 
136
- // Pull [template]/ as the team namespace
137
145
  if (namespace) {
138
- pullCommand({ ...args, _positional: ['[template]'], _renameNamespace: namespace, _workspaceDir: workspaceDir });
146
+ pullCommand({ ...args, _positional: ['[template]'], _renameNamespace: namespace, _workspaceDir: GLOBAL_AW_DIR });
139
147
  }
140
148
 
141
- // Post-pull IDE integration
142
- linkWorkspace(rootDir);
143
- generateCommands(rootDir);
144
- const instructionFiles = copyInstructions(rootDir, null, namespace);
145
- initAwDocs(rootDir);
146
- const mcpFiles = setupMcp(rootDir, namespace);
147
-
148
- // Write manifest of files created by aw init so nuke only deletes what we made
149
- const manifestPath = join(workspaceDir, '.created-files.json');
150
- const createdFiles = [...instructionFiles, ...mcpFiles].map(p =>
151
- p.startsWith(rootDir) ? p.slice(rootDir.length + 1) : p
152
- );
153
- writeFileSync(manifestPath, JSON.stringify(createdFiles, null, 2) + '\n');
154
-
155
- // For local scope: add to .gitignore
156
- if (scope.gitignore) {
157
- addToGitignore(cwd, ['.aw_registry/', '.aw_docs/', '.claude/', '.cursor/', '.codex/', '.agents/', 'mcp.json', 'CLAUDE.md', 'AGENTS.md']);
149
+ // ── Step 3: Link to global IDE dirs ───────────────────────────────────
150
+
151
+ linkWorkspace(HOME);
152
+ generateCommands(HOME);
153
+ const instructionFiles = copyInstructions(HOME, null, namespace) || [];
154
+ initAwDocs(HOME);
155
+ const mcpFiles = setupMcp(HOME, namespace) || [];
156
+
157
+ // ── Step 4: Install omnipresence hook (chpwd) ─────────────────────────
158
+
159
+ const hookTarget = installOmnipresenceHook();
160
+
161
+ // ── Step 5: Install auto-sync on new terminal ─────────────────────────
162
+
163
+ installAutoSync();
164
+
165
+ // ── Step 6: Start background daemon ───────────────────────────────────
166
+
167
+ let daemonInstalled = false;
168
+ try {
169
+ daemonCommand({ _positional: ['install'], '--interval': '1h' });
170
+ daemonInstalled = true;
171
+ } catch { /* non-fatal */ }
172
+
173
+ // ── Step 7: Symlink in current directory if it's a git repo ───────────
174
+
175
+ const cwd = process.cwd();
176
+ if (cwd !== HOME && existsSync(join(cwd, '.git')) && !existsSync(join(cwd, '.aw_registry'))) {
177
+ try {
178
+ symlinkSync(GLOBAL_AW_DIR, join(cwd, '.aw_registry'));
179
+ fmt.logStep(`Linked .aw_registry in current project`);
180
+ } catch { /* best effort */ }
158
181
  }
159
182
 
160
- fmt.outro(
161
- scopeKey === 'user'
162
- ? `Installed to ~/ available in all your projects`
163
- : scopeKey === 'project'
164
- ? `Installed to .claude/ — commit this to share with your team`
165
- : `Installed locally — added to .gitignore`
166
- );
183
+ // ── Step 8: Write manifest for perfect nuke cleanup ───────────────────
184
+
185
+ const manifest = {
186
+ version: 1,
187
+ installedAt: new Date().toISOString(),
188
+ globalDir: GLOBAL_AW_DIR,
189
+ createdFiles: [
190
+ ...instructionFiles.map(p => p.startsWith(HOME) ? p.slice(HOME.length + 1) : p),
191
+ ...mcpFiles.map(p => p.startsWith(HOME) ? p.slice(HOME.length + 1) : p),
192
+ ],
193
+ shellProfile: hookTarget,
194
+ shellMarkers: [CHPWD_MARKER, '# aw-auto-sync'],
195
+ daemon: daemonInstalled ? 'launchd' : null,
196
+ };
197
+ saveManifest(manifest);
198
+
199
+ // ── Done ──────────────────────────────────────────────────────────────
200
+
201
+ fmt.outro([
202
+ `Omnipresent install complete`,
203
+ ``,
204
+ ` ${chalk.green('✓')} Source of truth: ~/.aw_registry/`,
205
+ ` ${chalk.green('✓')} IDE integration: ~/.claude/, ~/.cursor/, ~/.codex/`,
206
+ ` ${chalk.green('✓')} Auto-symlink: every git repo you cd into gets .aw_registry`,
207
+ ` ${chalk.green('✓')} Auto-sync: background daemon + new terminal pull`,
208
+ ``,
209
+ ` ${chalk.dim('Open any project in any IDE — AW is already there.')}`,
210
+ ` ${chalk.dim('To undo everything:')} ${chalk.bold('aw nuke')}`,
211
+ ].join('\n'));
167
212
  }
package/commands/nuke.mjs CHANGED
@@ -1,91 +1,319 @@
1
- // commands/nuke.mjs — Remove entire .aw_registry/ and all generated IDE files
1
+ // commands/nuke.mjs — Safe omnipresent cleanup: reads manifest, removes only what AW created
2
+ //
3
+ // Safety guarantee: NEVER deletes files that AW didn't create.
4
+ // Uses .aw-manifest.json (written by init) to know exactly what to remove.
2
5
 
3
6
  import { join } from 'node:path';
4
- import { existsSync, rmSync, lstatSync, unlinkSync, readdirSync, readFileSync } from 'node:fs';
7
+ import { existsSync, rmSync, lstatSync, unlinkSync, readdirSync, readFileSync, writeFileSync, readlinkSync } from 'node:fs';
8
+ import { execSync } from 'node:child_process';
9
+ import { homedir, platform } from 'node:os';
5
10
  import * as fmt from '../fmt.mjs';
6
11
  import { chalk } from '../fmt.mjs';
7
12
 
8
- // IDE dirs to scan for per-file symlinks
13
+ const HOME = homedir();
14
+ const GLOBAL_AW_DIR = join(HOME, '.aw_registry');
15
+ const MANIFEST_PATH = join(GLOBAL_AW_DIR, '.aw-manifest.json');
16
+ const DAEMON_LABEL = 'ai.ghl.aw.pull';
17
+ const PLIST_PATH = join(HOME, 'Library', 'LaunchAgents', `${DAEMON_LABEL}.plist`);
18
+
19
+ // IDE dirs where AW creates symlinks
9
20
  const IDE_DIRS = ['.claude', '.cursor', '.codex'];
10
21
  const CONTENT_TYPES = ['agents', 'skills', 'commands', 'blueprints', 'evals'];
11
22
 
12
- // Dirs to remove if empty after cleanup (deepest first — IDE roots intentionally excluded)
13
- const DIRS_TO_PRUNE = [
14
- // IDE content dirs
15
- ...IDE_DIRS.flatMap(ide => CONTENT_TYPES.map(t => `${ide}/${t}`)),
16
- // Codex agents dir
17
- '.agents/skills',
18
- '.agents',
19
- ];
23
+ function loadManifest() {
24
+ if (!existsSync(MANIFEST_PATH)) return null;
25
+ try {
26
+ return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
27
+ } catch { return null; }
28
+ }
20
29
 
21
- export function nukeCommand(args) {
22
- const cwd = process.cwd();
23
- const workspaceDir = join(cwd, '.aw_registry');
30
+ // ── Step 1: Stop and remove daemon ──────────────────────────────────────────
24
31
 
25
- fmt.intro('aw nuke');
32
+ function removeDaemon(manifest) {
33
+ const isMac = platform() === 'darwin';
26
34
 
27
- if (!existsSync(workspaceDir)) {
28
- fmt.cancel('No .aw_registry/ found — nothing to remove');
35
+ if (isMac) {
36
+ if (existsSync(PLIST_PATH)) {
37
+ try { execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`); } catch { /* not loaded */ }
38
+ unlinkSync(PLIST_PATH);
39
+ fmt.logStep('Removed launchd daemon');
40
+ }
41
+ } else {
42
+ // Linux: remove cron line with # aw-daemon marker
43
+ try {
44
+ const current = execSync('crontab -l 2>/dev/null', { encoding: 'utf8' });
45
+ if (current.includes('# aw-daemon')) {
46
+ const cleaned = current.split('\n').filter(l => !l.includes('# aw-daemon')).join('\n');
47
+ execSync(`echo ${JSON.stringify(cleaned)} | crontab -`);
48
+ fmt.logStep('Removed cron job');
49
+ }
50
+ } catch { /* no crontab */ }
29
51
  }
52
+ }
53
+
54
+ // ── Step 2: Remove shell hooks (by marker, surgically) ──────────────────────
55
+
56
+ function removeShellHooks(manifest) {
57
+ const markers = manifest?.shellMarkers || ['# aw-omnipresence', '# aw-auto-sync'];
58
+ const profilePath = manifest?.shellProfile;
59
+
60
+ // Find all shell profiles that might have our hooks
61
+ const profiles = new Set();
62
+ if (profilePath && existsSync(profilePath)) profiles.add(profilePath);
63
+ for (const p of ['.zshrc', '.zprofile', '.bashrc', '.bash_profile']) {
64
+ const full = join(HOME, p);
65
+ if (existsSync(full)) profiles.add(full);
66
+ }
67
+
68
+ for (const profile of profiles) {
69
+ let content;
70
+ try { content = readFileSync(profile, 'utf8'); } catch { continue; }
71
+
72
+ let modified = false;
73
+ for (const marker of markers) {
74
+ if (!content.includes(marker)) continue;
75
+
76
+ // Remove the block: from marker line to next blank line or end
77
+ const lines = content.split('\n');
78
+ const result = [];
79
+ let skipping = false;
80
+
81
+ for (let i = 0; i < lines.length; i++) {
82
+ if (lines[i].includes(marker)) {
83
+ skipping = true;
84
+ modified = true;
85
+ continue;
86
+ }
87
+ if (skipping) {
88
+ // Stop skipping at a blank line (end of block)
89
+ if (lines[i].trim() === '') {
90
+ skipping = false;
91
+ // Don't add the trailing blank line of the block
92
+ continue;
93
+ }
94
+ continue; // skip this line (part of the hook block)
95
+ }
96
+ result.push(lines[i]);
97
+ }
98
+ content = result.join('\n');
99
+ }
100
+
101
+ if (modified) {
102
+ // Clean up trailing newlines (max 1)
103
+ content = content.replace(/\n{3,}$/g, '\n');
104
+ writeFileSync(profile, content);
105
+ fmt.logStep(`Cleaned hooks from ${profile.replace(HOME, '~')}`);
106
+ }
107
+ }
108
+ }
109
+
110
+ // ── Step 3: Remove AW-created files (from manifest) ─────────────────────────
111
+
112
+ function removeCreatedFiles(manifest) {
113
+ if (!manifest?.createdFiles?.length) return;
114
+
115
+ let removed = 0;
116
+ for (const rel of manifest.createdFiles) {
117
+ const p = join(HOME, rel);
118
+ try {
119
+ if (existsSync(p)) {
120
+ rmSync(p);
121
+ removed++;
122
+ }
123
+ } catch { /* best effort */ }
124
+ }
125
+ if (removed > 0) {
126
+ fmt.logStep(`Removed ${removed} generated file${removed > 1 ? 's' : ''}`);
127
+ }
128
+ }
129
+
130
+ // ── Step 4: Remove AW symlinks from IDE dirs ────────────────────────────────
131
+
132
+ function removeIdeSymlinks() {
133
+ let removed = 0;
30
134
 
31
- // 1. Remove symlinks from all IDE dirs
32
135
  for (const ide of IDE_DIRS) {
33
136
  for (const type of CONTENT_TYPES) {
34
- const dir = join(cwd, ide, type);
137
+ const dir = join(HOME, ide, type);
35
138
  if (!existsSync(dir)) continue;
139
+
36
140
  for (const entry of readdirSync(dir)) {
37
141
  const p = join(dir, entry);
38
142
  try {
39
143
  const stat = lstatSync(p);
40
144
  if (stat.isSymbolicLink()) {
41
- unlinkSync(p);
145
+ const target = readlinkSync(p);
146
+ // Only remove if it points into .aw_registry
147
+ if (target.includes('.aw_registry') || target.includes('aw_registry')) {
148
+ unlinkSync(p);
149
+ removed++;
150
+ }
42
151
  }
43
152
  } catch { /* best effort */ }
44
153
  }
45
154
  }
46
- // Clean commands/aw/ (generated commands + symlinked registry commands)
47
- const awCmdDir = join(cwd, ide, 'commands/aw');
48
- if (existsSync(awCmdDir)) {
49
- rmSync(awCmdDir, { recursive: true, force: true });
155
+
156
+ // Remove commands/aw/ dir (generated commands) and commands/ghl/ (generated platform commands)
157
+ for (const sub of ['commands/aw', 'commands/ghl']) {
158
+ const dir = join(HOME, ide, sub);
159
+ if (!existsSync(dir)) continue;
160
+ try {
161
+ // Only remove if contents are symlinks to .aw_registry or generated by AW
162
+ const entries = readdirSync(dir);
163
+ for (const entry of entries) {
164
+ const p = join(dir, entry);
165
+ const stat = lstatSync(p);
166
+ if (stat.isSymbolicLink()) {
167
+ unlinkSync(p);
168
+ removed++;
169
+ }
170
+ }
171
+ // Remove dir if now empty
172
+ if (readdirSync(dir).length === 0) rmSync(dir);
173
+ } catch { /* best effort */ }
50
174
  }
51
175
  }
52
176
 
53
- // 2. Clean .agents/skills/ (symlinks + generated Codex skill dirs)
54
- const agentsSkillsDir = join(cwd, '.agents/skills');
55
- if (existsSync(agentsSkillsDir)) {
56
- rmSync(agentsSkillsDir, { recursive: true, force: true });
177
+ // Remove .agents/skills/ symlinks
178
+ const agentsSkills = join(HOME, '.agents', 'skills');
179
+ if (existsSync(agentsSkills)) {
180
+ try {
181
+ for (const entry of readdirSync(agentsSkills)) {
182
+ const p = join(agentsSkills, entry);
183
+ if (lstatSync(p).isSymbolicLink()) {
184
+ const target = readlinkSync(p);
185
+ if (target.includes('.aw_registry') || target.includes('aw_registry')) {
186
+ unlinkSync(p);
187
+ removed++;
188
+ }
189
+ }
190
+ }
191
+ if (readdirSync(agentsSkills).length === 0) rmSync(agentsSkills);
192
+ const agentsDir = join(HOME, '.agents');
193
+ if (existsSync(agentsDir) && readdirSync(agentsDir).length === 0) rmSync(agentsDir);
194
+ } catch { /* best effort */ }
57
195
  }
58
196
 
59
- // 3. Remove only files that aw init actually created (from manifest)
60
- const manifestPath = join(workspaceDir, '.created-files.json');
61
- let filesToClean = [];
62
- if (existsSync(manifestPath)) {
197
+ if (removed > 0) {
198
+ fmt.logStep(`Removed ${removed} IDE symlink${removed > 1 ? 's' : ''}`);
199
+ }
200
+ }
201
+
202
+ // ── Step 5: Find and remove .aw_registry symlinks from project dirs ─────────
203
+
204
+ function removeProjectSymlinks() {
205
+ // Check common project locations for .aw_registry symlinks
206
+ // We look in the current directory + scan known parent dirs
207
+ let removed = 0;
208
+
209
+ const dirsToCheck = new Set();
210
+ dirsToCheck.add(process.cwd());
211
+
212
+ // Also check the home directory's immediate children (common project roots)
213
+ try {
214
+ for (const entry of readdirSync(HOME)) {
215
+ if (entry.startsWith('.')) continue; // skip dotfiles
216
+ const p = join(HOME, entry);
217
+ try {
218
+ if (lstatSync(p).isDirectory()) {
219
+ // Check this dir and its immediate children
220
+ dirsToCheck.add(p);
221
+ try {
222
+ for (const sub of readdirSync(p)) {
223
+ const sp = join(p, sub);
224
+ try { if (lstatSync(sp).isDirectory()) dirsToCheck.add(sp); } catch {}
225
+ }
226
+ } catch {}
227
+ }
228
+ } catch {}
229
+ }
230
+ } catch {}
231
+
232
+ for (const dir of dirsToCheck) {
233
+ const link = join(dir, '.aw_registry');
63
234
  try {
64
- filesToClean = JSON.parse(readFileSync(manifestPath, 'utf8'));
65
- } catch { /* ignore parse errors */ }
235
+ if (lstatSync(link).isSymbolicLink()) {
236
+ unlinkSync(link);
237
+ removed++;
238
+ }
239
+ } catch { /* doesn't exist or not a symlink */ }
66
240
  }
67
- for (const rel of filesToClean) {
68
- const p = join(cwd, rel);
69
- try { if (existsSync(p)) rmSync(p); } catch { /* best effort */ }
241
+
242
+ if (removed > 0) {
243
+ fmt.logStep(`Removed ${removed} project .aw_registry symlink${removed > 1 ? 's' : ''}`);
70
244
  }
245
+ }
246
+
247
+ // ── Main ─────────────────────────────────────────────────────────────────────
71
248
 
72
- // 4. Remove .aw_registry/ and .aw_docs/
73
- rmSync(workspaceDir, { recursive: true, force: true });
74
- const awDocsDir = join(cwd, '.aw_docs');
75
- if (existsSync(awDocsDir)) {
76
- rmSync(awDocsDir, { recursive: true, force: true });
249
+ export function nukeCommand(args) {
250
+ fmt.intro('aw nuke');
251
+
252
+ // Check if omnipresent install exists
253
+ if (!existsSync(GLOBAL_AW_DIR)) {
254
+ // Fallback: check cwd for project-scope install
255
+ const localAw = join(process.cwd(), '.aw_registry');
256
+ if (existsSync(localAw)) {
257
+ try {
258
+ if (lstatSync(localAw).isSymbolicLink()) {
259
+ unlinkSync(localAw);
260
+ fmt.logSuccess('Removed local .aw_registry symlink');
261
+ } else {
262
+ rmSync(localAw, { recursive: true, force: true });
263
+ fmt.logSuccess('Removed local .aw_registry/');
264
+ }
265
+ } catch {}
266
+ fmt.outro('Done');
267
+ return;
268
+ }
269
+ fmt.cancel('No .aw_registry found — nothing to remove');
270
+ return;
77
271
  }
78
272
 
79
- // 5. Prune empty dirs (IDE roots are excluded — never fully delete .claude/.cursor/.codex)
80
- for (const rel of DIRS_TO_PRUNE) {
81
- const p = join(cwd, rel);
82
- try {
83
- if (existsSync(p) && readdirSync(p).length === 0) {
84
- rmSync(p, { recursive: true });
85
- }
86
- } catch { /* best effort */ }
273
+ const manifest = loadManifest();
274
+
275
+ fmt.note([
276
+ `${chalk.dim('source:')} ~/.aw_registry/`,
277
+ manifest ? `${chalk.dim('installed:')} ${manifest.installedAt}` : null,
278
+ manifest?.daemon ? `${chalk.dim('daemon:')} ${manifest.daemon}` : null,
279
+ ].filter(Boolean).join('\n'), 'Cleaning up omnipresent install');
280
+
281
+ // 1. Stop daemon
282
+ removeDaemon(manifest);
283
+
284
+ // 2. Remove shell hooks
285
+ removeShellHooks(manifest);
286
+
287
+ // 3. Remove AW-created files (instruction files, MCP configs, etc.)
288
+ removeCreatedFiles(manifest);
289
+
290
+ // 4. Remove IDE symlinks (only those pointing to .aw_registry)
291
+ removeIdeSymlinks();
292
+
293
+ // 5. Remove .aw_registry symlinks from project directories
294
+ removeProjectSymlinks();
295
+
296
+ // 6. Remove .aw_docs/ from home
297
+ const awDocs = join(HOME, '.aw_docs');
298
+ if (existsSync(awDocs)) {
299
+ rmSync(awDocs, { recursive: true, force: true });
300
+ fmt.logStep('Removed ~/.aw_docs/');
87
301
  }
88
302
 
89
- fmt.logSuccess('Removed .aw_registry/, .aw_docs/, and all generated IDE files');
90
- fmt.outro(`Run ${chalk.dim('aw init')} to start fresh`);
303
+ // 7. Remove ~/.aw_registry/ itself (the source of truth)
304
+ rmSync(GLOBAL_AW_DIR, { recursive: true, force: true });
305
+ fmt.logStep('Removed ~/.aw_registry/');
306
+
307
+ fmt.outro([
308
+ 'Omnipresent install fully removed',
309
+ '',
310
+ ` ${chalk.green('✓')} Daemon stopped`,
311
+ ` ${chalk.green('✓')} Shell hooks cleaned`,
312
+ ` ${chalk.green('✓')} IDE symlinks removed`,
313
+ ` ${chalk.green('✓')} Project symlinks removed`,
314
+ ` ${chalk.green('✓')} Source of truth deleted`,
315
+ '',
316
+ ` ${chalk.dim('No existing files were touched.')}`,
317
+ ` ${chalk.dim('Run')} ${chalk.bold('aw init')} ${chalk.dim('to reinstall.')}`,
318
+ ].join('\n'));
91
319
  }
package/commands/pull.mjs CHANGED
@@ -19,7 +19,7 @@ import { generateCommands, copyInstructions } from '../integrate.mjs';
19
19
  export function pullCommand(args) {
20
20
  const input = args._positional?.[0] || '';
21
21
  const cwd = process.cwd();
22
- const workspaceDir = join(cwd, '.aw_registry');
22
+ const workspaceDir = args._workspaceDir || join(cwd, '.aw_registry');
23
23
  const dryRun = args['--dry-run'] === true;
24
24
  const verbose = args['-v'] === true || args['--verbose'] === true;
25
25
  const renameNamespace = args._renameNamespace || null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ghl-ai/aw",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Agentic Workspace CLI — pull, push & manage agents, skills and commands from the registry",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@clack/prompts": "^1.1.0",
43
+ "@clack/prompts": "0.8.2",
44
44
  "chalk": "^5.6.2",
45
45
  "figlet": "^1.11.0"
46
46
  }