@ghl-ai/aw 0.1.38-beta.0 → 0.1.38-beta.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
@@ -4,8 +4,9 @@ import { readFileSync } from 'node:fs';
4
4
  import { join, dirname } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import * as fmt from './fmt.mjs';
7
- import { chalk } from './fmt.mjs';
7
+ import { chalk, CancelError } from './fmt.mjs';
8
8
  import { checkForUpdate, notifyUpdate } from './update.mjs';
9
+ import { startSpan } from './telemetry.mjs';
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
12
  const VERSION = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')).version;
@@ -24,6 +25,7 @@ const COMMANDS = {
24
25
  link: () => import('./commands/link-project.mjs').then(m => m.linkProjectCommand),
25
26
  nuke: () => import('./commands/nuke.mjs').then(m => m.nukeCommand),
26
27
  daemon: () => import('./commands/daemon.mjs').then(m => m.daemonCommand),
28
+ telemetry: () => import('./commands/telemetry.mjs').then(m => m.telemetryCommand),
27
29
  };
28
30
 
29
31
  function parseArgs(argv) {
@@ -109,6 +111,13 @@ function printHelp() {
109
111
  cmd('aw daemon install --interval 30m', 'Set custom interval (e.g. 30m, 2h, 3600)'),
110
112
  cmd('aw daemon uninstall', 'Stop the background daemon'),
111
113
  cmd('aw daemon status', 'Check if daemon is running'),
114
+ cmd('aw slack-sim run <scenario>', 'Replay Slack-like scenarios against real runtime'),
115
+ cmd('aw slack-sim list-scenarios', 'List built-in Slack simulator scenarios'),
116
+
117
+ sec('Settings'),
118
+ cmd('aw telemetry status', 'Show telemetry status'),
119
+ cmd('aw telemetry disable', 'Opt out of anonymous analytics'),
120
+ cmd('aw telemetry enable', 'Re-enable analytics'),
112
121
 
113
122
  sec('Examples'),
114
123
  '',
@@ -158,9 +167,21 @@ export async function run(argv) {
158
167
  }
159
168
 
160
169
  if (command && COMMANDS[command]) {
170
+ const span = await startSpan(command, args);
171
+ span.notice();
161
172
  args._updateCheck = updateCheck;
162
- const handler = await COMMANDS[command]();
163
- await handler(args);
173
+ try {
174
+ const handler = await COMMANDS[command]();
175
+ await handler(args);
176
+ await span.end({ status: 'completed' });
177
+ } catch (err) {
178
+ if (err instanceof CancelError) {
179
+ await span.end({ status: 'cancelled', error_type: 'CancelError' });
180
+ process.exit(err.exitCode ?? 1);
181
+ }
182
+ await span.end({ status: 'failed', error_type: err.constructor.name });
183
+ throw err;
184
+ }
164
185
  notifyUpdate(await updateCheck);
165
186
  return;
166
187
  }
@@ -170,5 +191,5 @@ export async function run(argv) {
170
191
  process.exit(0);
171
192
  }
172
193
 
173
- fmt.cancel(`Unknown command: ${command}`);
194
+ fmt.cancelAndExit(`Unknown command: ${command}`);
174
195
  }
package/codex.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // codex.mjs — Project-local Codex defaults for AW/ECC startup.
2
2
 
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join } from 'node:path';
5
5
 
6
6
  import { getSupportedHarnessPhaseEntries } from './hook-manifest.mjs';
@@ -750,10 +750,6 @@ function ensureManagedScript(filePath, content, marker) {
750
750
  if (existsSync(filePath)) {
751
751
  const existing = readFileSync(filePath, 'utf8');
752
752
  if (existing === content) {
753
- if (!isExecutableScript(filePath)) {
754
- chmodSync(filePath, 0o755);
755
- return true;
756
- }
757
753
  return false;
758
754
  }
759
755
  if (!existing.includes(marker)) {
@@ -762,8 +758,7 @@ function ensureManagedScript(filePath, content, marker) {
762
758
  }
763
759
 
764
760
  mkdirSync(dirname(filePath), { recursive: true });
765
- writeFileSync(filePath, content, { mode: 0o755 });
766
- chmodSync(filePath, 0o755);
761
+ writeFileSync(filePath, content);
767
762
  return true;
768
763
  }
769
764
 
@@ -786,14 +781,6 @@ function hasTomlSection(content, sectionName) {
786
781
  return pattern.test(content);
787
782
  }
788
783
 
789
- function isExecutableScript(filePath) {
790
- try {
791
- return (statSync(filePath).mode & 0o111) !== 0;
792
- } catch {
793
- return false;
794
- }
795
- }
796
-
797
784
  function tidyToml(content) {
798
785
  const normalized = content
799
786
  .replace(/\r\n/g, '\n')
package/commands/init.mjs CHANGED
@@ -26,6 +26,7 @@ import { linkWorkspace } from '../link.mjs';
26
26
  import { generateCommands, copyInstructions, initAwDocs, syncHomeHarnessInstructions } from '../integrate.mjs';
27
27
  import { setupMcp } from '../mcp.mjs';
28
28
  import { applyStoredStartupPreferences, ensureAwRuntimeHook } from '../startup.mjs';
29
+ import { installLocalCommitHook } from '../hooks.mjs';
29
30
  import { autoUpdate, promptUpdate } from '../update.mjs';
30
31
  import { installGlobalHooks } from '../hooks.mjs';
31
32
  import { installAwEcc } from '../ecc.mjs';
@@ -100,7 +101,7 @@ function installIdeTasks() {
100
101
  {
101
102
  label: 'aw: sync registry',
102
103
  type: 'shell',
103
- command: 'aw init --silent',
104
+ command: 'AW_TRIGGER=ide:task aw init --silent',
104
105
  presentation: { reveal: 'silent', panel: 'shared', close: true },
105
106
  runOptions: { runOn: 'folderOpen' },
106
107
  problemMatcher: [],
@@ -221,7 +222,7 @@ export async function initCommand(args) {
221
222
  }
222
223
 
223
224
  if (choice === 'platform-only') {
224
- namespace = null; team = null; subTeam = null; folderName = null;
225
+ namespace = 'platform'; team = 'platform'; subTeam = null; folderName = null;
225
226
  }
226
227
  }
227
228
 
@@ -310,6 +311,7 @@ export async function initCommand(args) {
310
311
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
311
312
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
312
313
  const commands = generateCommands(HOME, { silent: true });
314
+ if (cwd !== HOME) installLocalCommitHook(cwd);
313
315
 
314
316
  if (silent) {
315
317
  autoUpdate(await args._updateCheck);
@@ -397,8 +399,8 @@ export async function initCommand(args) {
397
399
  }
398
400
  }
399
401
 
400
- // Create sync config
401
- const cfg = config.create(GLOBAL_AW_DIR, { namespace: team, user });
402
+ // Create sync config — default to 'platform' when no namespace specified
403
+ const cfg = config.create(GLOBAL_AW_DIR, { namespace: team || 'platform', user });
402
404
  if (folderName) {
403
405
  config.addPattern(GLOBAL_AW_DIR, folderName);
404
406
  }
@@ -445,6 +447,7 @@ export async function initCommand(args) {
445
447
  const projectRegistryDir = cwd !== HOME ? join(cwd, '.aw', REGISTRY_DIR) : null;
446
448
  const awDirForLinks = (projectRegistryDir && existsSync(projectRegistryDir)) ? projectRegistryDir : null;
447
449
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
450
+ if (cwd !== HOME) installLocalCommitHook(cwd);
448
451
  ideSpinner.message('Generating commands...');
449
452
  const commands = generateCommands(HOME, { silent: true });
450
453
  ideSpinner.stop(`IDE wired — ${chalk.bold(symlinks)} symlinks · ${chalk.bold(commands)} commands`);
@@ -11,6 +11,7 @@ import { linkWorkspace } from '../link.mjs';
11
11
  import { generateCommands } from '../integrate.mjs';
12
12
  import { removeWorkspaceHookDefaults } from '../codex.mjs';
13
13
  import { applyStoredStartupPreferences } from '../startup.mjs';
14
+ import { installLocalCommitHook } from '../hooks.mjs';
14
15
 
15
16
  const HOME = homedir();
16
17
  const AW_HOME = join(HOME, '.aw');
@@ -43,8 +44,10 @@ export function linkProjectCommand(args) {
43
44
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
44
45
  const commands = generateCommands(HOME, { silent: true });
45
46
  applyStoredStartupPreferences(HOME);
47
+ installLocalCommitHook(cwd);
46
48
  const removedLegacyStartupFiles = removeWorkspaceHookDefaults(cwd);
47
49
  fmt.logSuccess(`Already linked — refreshed ${chalk.bold(symlinks)} IDE symlinks · ${chalk.bold(commands)} commands${removedLegacyStartupFiles.length > 0 ? ` · removed ${removedLegacyStartupFiles.length} legacy repo startup file${removedLegacyStartupFiles.length > 1 ? 's' : ''}` : ''}`);
50
+
48
51
  return;
49
52
  }
50
53
 
@@ -55,6 +58,7 @@ export function linkProjectCommand(args) {
55
58
  const symlinks = linkWorkspace(HOME, awDirForLinks, { silent: true });
56
59
  const commands = generateCommands(HOME, { silent: true });
57
60
  applyStoredStartupPreferences(HOME);
61
+ installLocalCommitHook(cwd);
58
62
  const removedLegacyStartupFiles = removeWorkspaceHookDefaults(cwd);
59
63
  fmt.logSuccess([
60
64
  `Project linked — ${chalk.bold(symlinks)} IDE symlinks · ${chalk.bold(commands)} commands`,
package/commands/nuke.mjs CHANGED
@@ -148,19 +148,24 @@ async function removeProjectSymlinks() {
148
148
  }
149
149
 
150
150
  // Also remove legacy local .git/hooks/post-checkout installed by old aw versions
151
+ // and prepare-commit-msg hooks installed by installLocalCommitHook
151
152
  let hooksRemoved = 0;
152
- const { stdout: hookFiles } = await exec(
153
- `find "${HOME}" -maxdepth 5 -path "*/.git/hooks/post-checkout" -type f 2>/dev/null || true`,
154
- { encoding: 'utf8', timeout: 30000 }
155
- );
156
- for (const hookPath of hookFiles.trim().split('\n').filter(Boolean)) {
157
- try {
158
- const content = readFileSync(hookPath, 'utf8');
159
- if (content.includes('aw: auto-link registry') || content.includes('ln -s "$AW_REGISTRY"')) {
160
- unlinkSync(hookPath);
161
- hooksRemoved++;
162
- }
163
- } catch { /* best effort */ }
153
+ const hookNames = ['post-checkout', 'prepare-commit-msg'];
154
+ for (const hookName of hookNames) {
155
+ const { stdout: hookFiles } = await exec(
156
+ `find "${HOME}" -maxdepth 5 -path "*/.git/hooks/${hookName}" -type f 2>/dev/null || true`,
157
+ { encoding: 'utf8', timeout: 30000 }
158
+ );
159
+ for (const hookPath of hookFiles.trim().split('\n').filter(Boolean)) {
160
+ try {
161
+ const content = readFileSync(hookPath, 'utf8');
162
+ // Only remove hooks that AW installed — identified by our marker comment
163
+ if (content.includes('aw:') || content.includes('aw: auto-link registry') || content.includes('ln -s "$AW_REGISTRY"')) {
164
+ unlinkSync(hookPath);
165
+ hooksRemoved++;
166
+ }
167
+ } catch { /* best effort */ }
168
+ }
164
169
  }
165
170
 
166
171
  return { removed, hooksRemoved };
@@ -209,8 +214,8 @@ function removeIdeTasks() {
209
214
 
210
215
  export async function nukeCommand(args) {
211
216
  // Catch unhandled errors and surface them instead of letting clack show generic "Something went wrong"
212
- process.on('uncaughtException', (e) => { fmt.cancel(`Unexpected error: ${e.message}`); });
213
- process.on('unhandledRejection', (e) => { fmt.cancel(`Unexpected error: ${e?.message ?? e}`); });
217
+ process.on('uncaughtException', (e) => { fmt.cancelAndExit(`Unexpected error: ${e.message}`); });
218
+ process.on('unhandledRejection', (e) => { fmt.cancelAndExit(`Unexpected error: ${e?.message ?? e}`); });
214
219
 
215
220
  fmt.intro('aw nuke');
216
221
 
package/commands/pull.mjs CHANGED
@@ -44,7 +44,7 @@ export async function pullCommand(args) {
44
44
  const silent = args['--silent'] === true || args._silent === true;
45
45
 
46
46
  const log = {
47
- cancel: silent ? () => { process.exit(0); } : fmt.cancel,
47
+ cancel: silent ? (msg) => { throw new fmt.CancelError(msg || 'silent cancel', { exitCode: 0 }); } : fmt.cancel,
48
48
  logInfo: silent ? () => {} : fmt.logInfo,
49
49
  logSuccess: silent ? () => {} : fmt.logSuccess,
50
50
  logStep: silent ? () => {} : fmt.logStep,
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
5
5
 
6
6
  import * as fmt from '../fmt.mjs';
7
7
  import { chalk } from '../fmt.mjs';
8
- import { REGISTRY_BASE_BRANCH, REGISTRY_REPO, RULES_SOURCE_DIR } from '../constants.mjs';
8
+ import { REGISTRY_BASE_BRANCH, REGISTRY_REPO, RULES_SOURCE_DIR, AW_CO_AUTHOR } from '../constants.mjs';
9
9
  import { syncFileTree } from '../file-tree.mjs';
10
10
 
11
11
  function normalizeSlashes(path) {
@@ -147,7 +147,7 @@ function pushRulesTree(sourceRoot, { repo, dryRun, cwd }) {
147
147
  const prTitle = buildRulesPrTitle(sourceRoot, cwd);
148
148
  const prBody = buildRulesPrBody(sourceRoot, sourceType, cwd);
149
149
 
150
- execSync('git commit -m "registry: sync platform rules"', { cwd: tempDir, stdio: 'pipe' });
150
+ execSync(`git commit -m "registry: sync platform rules\n\n${AW_CO_AUTHOR}"`, { cwd: tempDir, stdio: 'pipe' });
151
151
  s2.stop('Rules sync prepared');
152
152
 
153
153
  const s3 = fmt.spinner();
package/commands/push.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // commands/push.mjs — Push local agents/skills to registry via PR using persistent git clone
2
2
 
3
3
  import { existsSync, statSync, readFileSync, appendFileSync } from 'node:fs';
4
- import { join } from 'node:path';
4
+ import { join, dirname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { exec as execCb, execFile as execFileCb } from 'node:child_process';
6
7
  import { promisify } from 'node:util';
7
8
  import { homedir } from 'node:os';
@@ -10,7 +11,7 @@ const exec = promisify(execCb);
10
11
  const execFile = promisify(execFileCb);
11
12
  import * as fmt from '../fmt.mjs';
12
13
  import { chalk } from '../fmt.mjs';
13
- import { REGISTRY_REPO, REGISTRY_URL, REGISTRY_BASE_BRANCH, REGISTRY_DIR } from '../constants.mjs';
14
+ import { REGISTRY_REPO, REGISTRY_URL, REGISTRY_BASE_BRANCH, REGISTRY_DIR, AW_CO_AUTHOR } from '../constants.mjs';
14
15
  import { resolveInput } from '../paths.mjs';
15
16
  import { walkRegistryTree } from '../registry.mjs';
16
17
  import {
@@ -25,6 +26,9 @@ import {
25
26
  } from '../git.mjs';
26
27
  import { hasRulesChanges, isRulesPushInput, pushRulesCommand } from './push-rules.mjs';
27
28
 
29
+ const __dirname = dirname(fileURLToPath(import.meta.url));
30
+ const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
31
+
28
32
  const PUSHABLE_TYPES = ['agents', 'skills', 'commands', 'evals', 'references'];
29
33
 
30
34
  // ── PR content generation ────────────────────────────────────────────
@@ -187,11 +191,14 @@ function generateCommitMsg(files) {
187
191
  const deletedParts = Object.entries(groupBy(deleted, 'type')).map(([t, items]) => `${items.length} ${singular(t, items.length)} removed`);
188
192
  const countParts = [...addedParts, ...deletedParts];
189
193
 
194
+ const version = VERSION;
195
+ const trailer = `\n\nGenerated-By: aw/${version}\n${AW_CO_AUTHOR}`;
196
+
190
197
  if (files.length === 1) {
191
198
  const f = files[0];
192
- return `registry: ${f.deleted ? 'remove' : 'add'} ${f.type}/${f.slug} ${f.deleted ? 'from' : 'to'} ${f.namespace}`;
199
+ return `registry: ${f.deleted ? 'remove' : 'add'} ${f.type}/${f.slug} ${f.deleted ? 'from' : 'to'} ${f.namespace}${trailer}`;
193
200
  }
194
- return `registry: sync ${files.length} files (${countParts.join(', ')})`;
201
+ return `registry: sync ${files.length} files (${countParts.join(', ')})${trailer}`;
195
202
  }
196
203
 
197
204
  // ── Batch file collection from folder ────────────────────────────────
@@ -0,0 +1,128 @@
1
+ import * as fmt from '../fmt.mjs';
2
+ import { loadScenario, listBuiltInScenarios } from '../slack-sim/scenario.mjs';
3
+ import { printDryRun, printScenarioIntro, printState } from '../slack-sim/render.mjs';
4
+
5
+ function sleep(ms) {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
8
+
9
+ export async function slackSimCommand(args) {
10
+ const [subcommand = 'list-scenarios', scenarioRef] = args._positional;
11
+
12
+ if (args['--help']) {
13
+ printHelp();
14
+ return;
15
+ }
16
+
17
+ if (subcommand === 'list-scenarios') {
18
+ fmt.intro('aw slack-sim list-scenarios');
19
+ const scenarios = listBuiltInScenarios();
20
+ fmt.note(
21
+ scenarios.map((s) => `${s.name} ${fmt.chalk.dim(s.path)}`).join('\n'),
22
+ 'Built-in scenarios',
23
+ );
24
+ fmt.outro('Done');
25
+ return;
26
+ }
27
+
28
+ if (!['run', 'replay'].includes(subcommand)) {
29
+ fmt.cancel(`Unknown slack-sim subcommand: ${subcommand}`);
30
+ return;
31
+ }
32
+
33
+ const scenario = loadScenario(scenarioRef);
34
+ const useHttp = Boolean(args['--api-base']) && !args['--in-process'];
35
+
36
+ printScenarioIntro(scenario, useHttp ? 'http' : 'in-process');
37
+ fmt.note(
38
+ [
39
+ `source: ${scenario.__file}`,
40
+ `actions: ${scenario.actions.length}`,
41
+ `org: ${scenario.session.orgId}`,
42
+ `team: ${scenario.session.teamId}`,
43
+ ].join('\n'),
44
+ 'Scenario',
45
+ );
46
+
47
+ if (args['--dry-run']) {
48
+ printDryRun(scenario);
49
+ fmt.outro('Dry run complete');
50
+ return;
51
+ }
52
+
53
+ const transport = useHttp
54
+ ? await (await import('../slack-sim/http.mjs')).createHttpTransport({
55
+ apiBase: args['--api-base'],
56
+ orgId: scenario.session.orgId,
57
+ teamId: scenario.session.teamId,
58
+ })
59
+ : await (await import('../slack-sim/in-process.mjs')).createInProcessTransport();
60
+
61
+ try {
62
+ await transport.start?.();
63
+ let currentChannel = scenario.defaults.channel;
64
+ let currentThreadTs = scenario.defaults.threadTs;
65
+ for (let i = 0; i < scenario.actions.length; i++) {
66
+ const action = scenario.actions[i];
67
+ const actionResult = await transport.runAction(action, scenario);
68
+ currentChannel = actionResult?.channel || action.channel || currentChannel;
69
+ currentThreadTs = actionResult?.threadTs || action.threadTs || action.thread_ts || (action.type === 'app_mention_top_level' ? actionResult?.ts : currentThreadTs);
70
+ const state = await transport.getState({
71
+ channel: currentChannel,
72
+ threadTs: currentThreadTs,
73
+ });
74
+ if (action.type === 'assert') {
75
+ assertState(state, action);
76
+ fmt.logSuccess(`Assertion ${i + 1} passed`);
77
+ continue;
78
+ }
79
+ printState(i, action, state);
80
+ if (args['--wait-for-finish'] && state.status === 'running' && action.type !== 'wait') {
81
+ await sleep(250);
82
+ }
83
+ }
84
+ fmt.outro('Scenario complete');
85
+ } finally {
86
+ await transport.close?.();
87
+ }
88
+ }
89
+
90
+ function assertState(state, action) {
91
+ const failures = [];
92
+ if (action.status && state.status !== action.status) failures.push(`expected status=${action.status}, got ${state.status}`);
93
+ if (action.prRole && state.prRole !== action.prRole) failures.push(`expected prRole=${action.prRole}, got ${state.prRole}`);
94
+ if (action.pollerActive !== undefined && state.pollerActive !== action.pollerActive) {
95
+ failures.push(`expected pollerActive=${action.pollerActive}, got ${state.pollerActive}`);
96
+ }
97
+ if (action.checkpointActive !== undefined && state.checkpointActive !== action.checkpointActive) {
98
+ failures.push(`expected checkpointActive=${action.checkpointActive}, got ${state.checkpointActive}`);
99
+ }
100
+ if (action.prUrlIncludes && !String(state.prUrl || '').includes(action.prUrlIncludes)) {
101
+ failures.push(`expected prUrl to include "${action.prUrlIncludes}", got ${state.prUrl || '<none>'}`);
102
+ }
103
+ if (action.threadContains) {
104
+ const haystack = (state.messages || []).map((m) => m.text).join('\n');
105
+ if (!haystack.includes(action.threadContains)) failures.push(`expected thread to contain "${action.threadContains}"`);
106
+ }
107
+ if (action.threadNotContains) {
108
+ const haystack = (state.messages || []).map((m) => m.text).join('\n');
109
+ if (haystack.includes(action.threadNotContains)) failures.push(`expected thread to not contain "${action.threadNotContains}"`);
110
+ }
111
+
112
+ if (failures.length) {
113
+ throw new Error(failures.join('; '));
114
+ }
115
+ }
116
+
117
+ function printHelp() {
118
+ fmt.intro('aw slack-sim');
119
+ fmt.note(
120
+ [
121
+ 'aw slack-sim list-scenarios',
122
+ 'aw slack-sim run <scenario-file-or-name> [--dry-run] [--in-process] [--api-base <url>]',
123
+ 'aw slack-sim replay <scenario-file-or-name> [--dry-run] [--in-process] [--api-base <url>]',
124
+ ].join('\n'),
125
+ 'Usage',
126
+ );
127
+ fmt.outro('Done');
128
+ }
@@ -0,0 +1,31 @@
1
+ // commands/telemetry.mjs — `aw telemetry [enable|disable|status]`
2
+
3
+ import { enableTelemetry, disableTelemetry, getStatus } from '../telemetry.mjs';
4
+ import * as fmt from '../fmt.mjs';
5
+ import { chalk } from '../fmt.mjs';
6
+
7
+ export async function telemetryCommand(args) {
8
+ const sub = args._positional?.[0];
9
+
10
+ if (sub === 'disable') {
11
+ disableTelemetry();
12
+ fmt.logSuccess('Telemetry disabled. No anonymous usage data will be sent.');
13
+ return;
14
+ }
15
+
16
+ if (sub === 'enable') {
17
+ enableTelemetry();
18
+ fmt.logSuccess('Telemetry enabled. Anonymous usage stats help improve aw.');
19
+ return;
20
+ }
21
+
22
+ // status (default)
23
+ const status = getStatus();
24
+ fmt.intro('aw telemetry');
25
+ fmt.logStep(`Status: ${status.enabled ? chalk.green('enabled') : chalk.red('disabled')}`);
26
+ fmt.logStep(`Machine ID: ${chalk.dim(status.machine_id)}`);
27
+ fmt.logStep(`Config: ${chalk.dim(status.config_path)}`);
28
+ fmt.logMessage('');
29
+ fmt.logMessage(` ${chalk.dim('aw telemetry disable')} — opt out of anonymous analytics`);
30
+ fmt.logMessage(` ${chalk.dim('aw telemetry enable')} — re-enable analytics`);
31
+ }
package/config.mjs CHANGED
@@ -38,7 +38,7 @@ export function save(workspaceDir, config) {
38
38
  export function create(workspaceDir, { namespace, user }) {
39
39
  const config = {
40
40
  ...DEFAULTS,
41
- namespace: namespace || null,
41
+ namespace: namespace || 'platform',
42
42
  user: user || '',
43
43
  include: [],
44
44
  };
package/constants.mjs CHANGED
@@ -27,3 +27,10 @@ export const AW_HOME = join(homedir(), '.aw');
27
27
 
28
28
  /** Directory in platform-docs repo containing platform rules (pulled into .aw_registry/.aw_rules/) */
29
29
  export const RULES_SOURCE_DIR = '.aw_rules';
30
+ /** Telemetry endpoint — override with AW_TELEMETRY_URL env var */
31
+ export const TELEMETRY_URL = process.env.AW_TELEMETRY_URL || 'https://services.leadconnectorhq.com/agentic-workspace/api/telemetry/events';
32
+
33
+ /** AW bot identity for Co-Authored-By trailers */
34
+ export const AW_BOT_NAME = 'AW';
35
+ export const AW_BOT_EMAIL = process.env.AW_BOT_EMAIL || '273421570+ghl-aw@users.noreply.github.com';
36
+ export const AW_CO_AUTHOR = `Co-Authored-By: ${AW_BOT_NAME} <${AW_BOT_EMAIL}>`;
package/ecc.mjs CHANGED
@@ -10,7 +10,7 @@ import { applyStoredStartupPreferences } from "./startup.mjs";
10
10
 
11
11
  const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
12
12
  const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
13
- export const AW_ECC_TAG = "v1.4.20";
13
+ export const AW_ECC_TAG = "v1.4.22";
14
14
 
15
15
  const MARKETPLACE_NAME = "aw-marketplace";
16
16
  const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
@@ -37,6 +37,14 @@ const TARGET_STATE = {
37
37
  codex: { state: ".codex/ecc-install-state.json" },
38
38
  };
39
39
 
40
+ // User-owned config files that must never be clobbered by aw-ecc install.
41
+ // If these files already exist before `aw init`, we preserve them exactly.
42
+ const PROTECTED_CONFIG_BY_TARGET = {
43
+ claude: [".claude/settings.json", ".claude.json"],
44
+ cursor: [".cursor/settings.json", ".cursor/mcp.json"],
45
+ codex: [".codex/config.toml"],
46
+ };
47
+
40
48
  function run(cmd, opts = {}) {
41
49
  return execSync(cmd, { stdio: "pipe", ...opts });
42
50
  }
@@ -55,6 +63,59 @@ function cloneWithRef(url, ref, dest) {
55
63
  }
56
64
  }
57
65
 
66
+ function readIfExists(path) {
67
+ try { return existsSync(path) ? readFileSync(path, "utf8") : null; } catch { return null; }
68
+ }
69
+
70
+ function snapshotProtectedConfigs(home, target) {
71
+ const relPaths = PROTECTED_CONFIG_BY_TARGET[target] || [];
72
+ return relPaths.map((relPath) => {
73
+ const absPath = join(home, relPath);
74
+ const content = readIfExists(absPath);
75
+ return {
76
+ relPath,
77
+ absPath,
78
+ existedBeforeInstall: content !== null,
79
+ contentBeforeInstall: content,
80
+ };
81
+ });
82
+ }
83
+
84
+ function restoreProtectedConfigs(snapshot) {
85
+ let restored = 0;
86
+ for (const entry of snapshot) {
87
+ if (entry.existedBeforeInstall) {
88
+ const current = readIfExists(entry.absPath);
89
+ if (current === entry.contentBeforeInstall) continue;
90
+ try {
91
+ mkdirSync(dirname(entry.absPath), { recursive: true });
92
+ writeFileSync(entry.absPath, entry.contentBeforeInstall ?? "");
93
+ restored++;
94
+ } catch { /* best effort */ }
95
+ continue;
96
+ }
97
+
98
+ // If aw-ecc created a protected settings file from scratch, remove it.
99
+ // AW should not own these user config files.
100
+ try {
101
+ if (existsSync(entry.absPath)) {
102
+ rmSync(entry.absPath, { force: true });
103
+ restored++;
104
+ }
105
+ } catch { /* best effort */ }
106
+ }
107
+ return restored;
108
+ }
109
+
110
+ function relProtectedPath(absPath, home) {
111
+ const normalized = String(absPath || "").replace(/\\/g, "/");
112
+ for (const rel of Object.values(PROTECTED_CONFIG_BY_TARGET).flat()) {
113
+ const full = join(home, rel).replace(/\\/g, "/");
114
+ if (normalized === full) return rel;
115
+ }
116
+ return null;
117
+ }
118
+
58
119
  function cloneOrUpdate(tag, dest) {
59
120
  // AW_ECC_CLONE_URL overrides the remote (used in tests to point at a local fake repo)
60
121
  const overrideUrl = process.env.AW_ECC_CLONE_URL;
@@ -175,10 +236,14 @@ export async function installAwEcc(
175
236
  if (!silent) fmt.logStep("Installing aw-ecc engine...");
176
237
 
177
238
  const repoDir = eccDir();
239
+ const home = homedir();
178
240
 
179
241
  try {
180
242
  cloneOrUpdate(AW_ECC_TAG, repoDir);
181
243
 
244
+ // Ensure telemetry state directory exists (vendor-agnostic, shared across IDEs)
245
+ mkdirSync(join(home, ".aw", "telemetry"), { recursive: true });
246
+
182
247
  // Claude Code: plugin install via marketplace CLI (proper agent dispatch)
183
248
  if (targets.includes("claude")) {
184
249
  try {
@@ -196,6 +261,8 @@ export async function installAwEcc(
196
261
  });
197
262
  for (const target of fileCopyTargets) {
198
263
  try {
264
+ const snapshot = snapshotProtectedConfigs(home, target);
265
+
199
266
  // Always use HOME as cwd so files land in ~/.<target>/ globally.
200
267
  const runCwd = homedir();
201
268
  // For claude: install the safe no-commands module set. The plugin
@@ -219,6 +286,10 @@ export async function installAwEcc(
219
286
  if (target === "codex") {
220
287
  syncEccToCodex(repoDir);
221
288
  }
289
+
290
+ // Critical: preserve user-owned config files if they existed before
291
+ // running aw-ecc (aw should only add, never replace user settings).
292
+ restoreProtectedConfigs(snapshot);
222
293
  } catch { /* target not supported — skip */ }
223
294
  }
224
295
  }
@@ -249,10 +320,18 @@ export function uninstallAwEcc({ silent = false } = {}) {
249
320
  try {
250
321
  const data = JSON.parse(readFileSync(statePath, "utf8"));
251
322
  for (const op of data.operations || []) {
252
- if (op.destinationPath && existsSync(op.destinationPath)) {
253
- rmSync(op.destinationPath, { recursive: true, force: true });
323
+ if (!op.destinationPath) continue;
324
+ const absPath = op.destinationPath.startsWith("/") ? op.destinationPath : join(HOME, op.destinationPath);
325
+ const relProtected = relProtectedPath(absPath, HOME);
326
+ if (relProtected) {
327
+ // Never delete user settings files as part of aw-ecc uninstall.
328
+ // AW-specific MCP removal is handled separately in removeMcpConfig().
329
+ continue;
330
+ }
331
+ if (existsSync(absPath)) {
332
+ rmSync(absPath, { recursive: true, force: true });
254
333
  removed++;
255
- pruneEmptyParents(op.destinationPath, join(HOME, cfg.state.split("/")[0]));
334
+ pruneEmptyParents(absPath, join(HOME, cfg.state.split("/")[0]));
256
335
  }
257
336
  }
258
337
  rmSync(statePath, { force: true });
package/fmt.mjs CHANGED
@@ -72,7 +72,21 @@ export const isCancel = p.isCancel;
72
72
 
73
73
  export const spinner = () => p.spinner();
74
74
 
75
+ export class CancelError extends Error {
76
+ constructor(message, { exitCode = 1 } = {}) {
77
+ super(message);
78
+ this.name = 'CancelError';
79
+ this.exitCode = exitCode;
80
+ }
81
+ }
82
+
75
83
  export function cancel(msg) {
84
+ p.cancel(msg);
85
+ throw new CancelError(msg);
86
+ }
87
+
88
+ /** Hard exit — for use in process exception handlers where throwing is unsafe */
89
+ export function cancelAndExit(msg) {
76
90
  p.cancel(msg);
77
91
  process.exit(1);
78
92
  }