@ghl-ai/aw 0.1.37-beta.8 → 0.1.37

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.
@@ -0,0 +1,212 @@
1
+ import { existsSync, mkdtempSync, statSync } from 'node:fs';
2
+ import { execFileSync, execSync } from 'node:child_process';
3
+ import { join, normalize, relative } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ import * as fmt from '../fmt.mjs';
7
+ import { chalk } from '../fmt.mjs';
8
+ import { REGISTRY_BASE_BRANCH, REGISTRY_REPO, RULES_SOURCE_DIR, AW_CO_AUTHOR } from '../constants.mjs';
9
+ import { syncFileTree } from '../file-tree.mjs';
10
+
11
+ function normalizeSlashes(path) {
12
+ return path.replace(/\\/g, '/');
13
+ }
14
+
15
+ export function isRulesPushInput(input) {
16
+ if (!input) return false;
17
+ const normalized = normalizeSlashes(input).replace(/\/+$/, '');
18
+ return normalized === RULES_SOURCE_DIR
19
+ || normalized.startsWith(`${RULES_SOURCE_DIR}/`)
20
+ || normalized === `.aw_registry/${RULES_SOURCE_DIR}`
21
+ || normalized.startsWith(`.aw_registry/${RULES_SOURCE_DIR}/`);
22
+ }
23
+
24
+ export function resolveRulesPushSource(input, cwd = process.cwd()) {
25
+ const localRulesRoot = join(cwd, RULES_SOURCE_DIR);
26
+ const syncedRulesRoot = join(cwd, '.aw_registry', RULES_SOURCE_DIR);
27
+
28
+ if (!input) {
29
+ if (existsSync(localRulesRoot)) return { sourceRoot: localRulesRoot, sourceType: 'local' };
30
+ if (existsSync(syncedRulesRoot)) return { sourceRoot: syncedRulesRoot, sourceType: 'synced' };
31
+ return null;
32
+ }
33
+
34
+ const normalizedInput = normalizeSlashes(input).replace(/\/+$/, '');
35
+
36
+ if (normalizedInput === RULES_SOURCE_DIR || normalizedInput.startsWith(`${RULES_SOURCE_DIR}/`)) {
37
+ if (!existsSync(localRulesRoot)) return null;
38
+ return { sourceRoot: localRulesRoot, sourceType: 'local' };
39
+ }
40
+
41
+ if (normalizedInput === `.aw_registry/${RULES_SOURCE_DIR}` || normalizedInput.startsWith(`.aw_registry/${RULES_SOURCE_DIR}/`)) {
42
+ if (!existsSync(syncedRulesRoot)) return null;
43
+
44
+ const relativeRulesPath = normalizedInput.slice(`.aw_registry/${RULES_SOURCE_DIR}`.length).replace(/^\/+/, '');
45
+ const localOverridePath = relativeRulesPath ? join(localRulesRoot, relativeRulesPath) : localRulesRoot;
46
+ if (existsSync(localOverridePath)) {
47
+ return { sourceRoot: localRulesRoot, sourceType: 'local' };
48
+ }
49
+
50
+ return { sourceRoot: syncedRulesRoot, sourceType: 'synced' };
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ export function hasRulesChanges(cwd = process.cwd()) {
57
+ const candidateDirs = [RULES_SOURCE_DIR, `.aw_registry/${RULES_SOURCE_DIR}`]
58
+ .filter(rel => existsSync(join(cwd, rel)));
59
+
60
+ if (candidateDirs.length === 0) return false;
61
+
62
+ try {
63
+ const output = execSync(
64
+ `git status --short --untracked-files=all -- ${candidateDirs.map(dir => `"${dir}"`).join(' ')}`,
65
+ { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
66
+ ).trim();
67
+ return output.length > 0;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ export function buildRulesPushFile(input, cwd = process.cwd()) {
74
+ const resolved = resolveRulesPushSource(input, cwd);
75
+ if (!resolved) return null;
76
+
77
+ return {
78
+ absPath: resolved.sourceRoot,
79
+ registryTarget: RULES_SOURCE_DIR,
80
+ type: 'rules',
81
+ namespace: 'platform',
82
+ slug: 'rules',
83
+ isDir: true,
84
+ };
85
+ }
86
+
87
+ function buildRulesPrTitle(sourceRoot, cwd) {
88
+ const rel = normalizeSlashes(relative(cwd, sourceRoot));
89
+ return rel === RULES_SOURCE_DIR
90
+ ? 'sync: update platform rules'
91
+ : `sync: update platform rules from ${rel}`;
92
+ }
93
+
94
+ function buildRulesPrBody(sourceRoot, sourceType, cwd) {
95
+ const rel = normalizeSlashes(relative(cwd, sourceRoot)) || RULES_SOURCE_DIR;
96
+ return [
97
+ '## Platform Rules Sync',
98
+ '',
99
+ `- **Source:** \`${rel}\``,
100
+ `- **Mode:** ${sourceType === 'local' ? 'canonical local rules' : 'synced rules copy'}`,
101
+ '',
102
+ 'Uploaded via `aw push-rules`',
103
+ ].join('\n');
104
+ }
105
+
106
+ function pushRulesTree(sourceRoot, { repo, dryRun, cwd }) {
107
+ if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) {
108
+ fmt.cancel(`Rules path not found: ${sourceRoot}`);
109
+ }
110
+
111
+ if (dryRun) {
112
+ fmt.logInfo(`Would push rules from ${chalk.cyan(normalizeSlashes(relative(cwd, sourceRoot)) || RULES_SOURCE_DIR)}`);
113
+ fmt.logWarn('No changes made (--dry-run)');
114
+ fmt.outro(chalk.dim('Remove --dry-run to push'));
115
+ return;
116
+ }
117
+
118
+ const s = fmt.spinner();
119
+ s.start('Cloning registry...');
120
+
121
+ const tempDir = mkdtempSync(join(tmpdir(), 'aw-push-rules-'));
122
+
123
+ try {
124
+ const repoUrl = repo.startsWith('http') ? repo : `https://github.com/${repo}.git`;
125
+ execSync(`git clone --filter=blob:none --no-checkout "${repoUrl}" "${tempDir}"`, { stdio: 'pipe' });
126
+ execSync(`git checkout ${REGISTRY_BASE_BRANCH}`, { cwd: tempDir, stdio: 'pipe' });
127
+ s.stop('Repository cloned');
128
+
129
+ const branch = `sync/rules-${Date.now().toString(36).slice(-5)}`;
130
+ execSync(`git checkout -b ${branch}`, { cwd: tempDir, stdio: 'pipe' });
131
+
132
+ const s2 = fmt.spinner();
133
+ s2.start('Copying platform rules...');
134
+
135
+ syncFileTree(sourceRoot, join(tempDir, RULES_SOURCE_DIR));
136
+ execSync(`git add -A "${RULES_SOURCE_DIR}"`, { cwd: tempDir, stdio: 'pipe' });
137
+
138
+ const diffStatus = execSync('git diff --cached --name-only', { cwd: tempDir, encoding: 'utf8' }).trim();
139
+ if (!diffStatus) {
140
+ s2.stop('No changes');
141
+ fmt.cancel('Nothing to push — remote rules already match local content.');
142
+ }
143
+
144
+ const sourceType = normalize(sourceRoot).includes(normalize(join('.aw_registry', RULES_SOURCE_DIR)))
145
+ ? 'synced'
146
+ : 'local';
147
+ const prTitle = buildRulesPrTitle(sourceRoot, cwd);
148
+ const prBody = buildRulesPrBody(sourceRoot, sourceType, cwd);
149
+
150
+ execSync(`git commit -m "registry: sync platform rules\n\n${AW_CO_AUTHOR}"`, { cwd: tempDir, stdio: 'pipe' });
151
+ s2.stop('Rules sync prepared');
152
+
153
+ const s3 = fmt.spinner();
154
+ s3.start('Pushing and creating PR...');
155
+
156
+ execSync(`git push -u origin ${branch}`, { cwd: tempDir, stdio: 'pipe' });
157
+
158
+ let prUrl;
159
+ try {
160
+ prUrl = execFileSync('gh', [
161
+ 'pr', 'create',
162
+ '--base', REGISTRY_BASE_BRANCH,
163
+ '--title', prTitle,
164
+ '--body', prBody,
165
+ ], { cwd: tempDir, encoding: 'utf8' }).trim();
166
+ } catch {
167
+ const repoBase = repo.replace(/\.git$/, '');
168
+ prUrl = `https://github.com/${repoBase}/compare/${REGISTRY_BASE_BRANCH}...${branch}?expand=1`;
169
+ }
170
+
171
+ s3.stop('Branch pushed');
172
+ fmt.logSuccess(`PR: ${chalk.cyan(prUrl)}`);
173
+ fmt.outro('Rules push complete');
174
+ } catch (e) {
175
+ fmt.cancel(`Rules push failed: ${e.message}`);
176
+ } finally {
177
+ execSync(`rm -rf "${tempDir}"`, { stdio: 'pipe' });
178
+ }
179
+ }
180
+
181
+ export function pushRulesCommand(args) {
182
+ const input = args._positional?.[0];
183
+ const dryRun = args['--dry-run'] === true;
184
+ const repo = args['--repo'] || REGISTRY_REPO;
185
+ const cwd = process.cwd();
186
+
187
+ fmt.intro('aw push-rules');
188
+
189
+ const resolved = resolveRulesPushSource(input, cwd);
190
+ if (!resolved) {
191
+ fmt.cancel([
192
+ 'Could not find a rules source to push.',
193
+ '',
194
+ ` Checked ${chalk.cyan('.aw_rules/')} and ${chalk.cyan('.aw_registry/.aw_rules/')}.`,
195
+ '',
196
+ ' Use `aw pull platform` first or create a local `.aw_rules/` authoring tree.',
197
+ ].join('\n'));
198
+ }
199
+
200
+ if (resolved.sourceType === 'synced') {
201
+ fmt.logWarn('Pushing from synced `.aw_registry/.aw_rules/`. Local `.aw_rules/` is safer for authoring.');
202
+ }
203
+
204
+ pushRulesTree(resolved.sourceRoot, { repo, dryRun, cwd });
205
+ }
206
+
207
+ export const __test__ = {
208
+ buildRulesPushFile,
209
+ isRulesPushInput,
210
+ resolveRulesPushSource,
211
+ hasRulesChanges,
212
+ };
package/commands/push.mjs CHANGED
@@ -11,7 +11,7 @@ const exec = promisify(execCb);
11
11
  const execFile = promisify(execFileCb);
12
12
  import * as fmt from '../fmt.mjs';
13
13
  import { chalk } from '../fmt.mjs';
14
- 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';
15
15
  import { resolveInput } from '../paths.mjs';
16
16
  import { walkRegistryTree } from '../registry.mjs';
17
17
  import {
@@ -20,11 +20,11 @@ import {
20
20
  createPushBranch,
21
21
  checkoutMain,
22
22
  isValidClone,
23
- findNearestWorktree,
24
23
  getLocalRegistryDir,
25
24
  commitsAheadOfMain,
26
25
  logAheadOfMain,
27
26
  } from '../git.mjs';
27
+ import { hasRulesChanges, isRulesPushInput, pushRulesCommand } from './push-rules.mjs';
28
28
 
29
29
  const __dirname = dirname(fileURLToPath(import.meta.url));
30
30
  const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
@@ -192,7 +192,7 @@ function generateCommitMsg(files) {
192
192
  const countParts = [...addedParts, ...deletedParts];
193
193
 
194
194
  const version = VERSION;
195
- const trailer = `\n\nGenerated-By: aw/${version}`;
195
+ const trailer = `\n\nGenerated-By: aw/${version}\n${AW_CO_AUTHOR}`;
196
196
 
197
197
  if (files.length === 1) {
198
198
  const f = files[0];
@@ -452,14 +452,27 @@ export async function pushCommand(args) {
452
452
 
453
453
  fmt.intro('aw push');
454
454
 
455
+ if (isRulesPushInput(input)) {
456
+ fmt.logInfo('Detected platform rules path — redirecting to `aw push-rules`.');
457
+ pushRulesCommand(args);
458
+ return;
459
+ }
460
+
455
461
  const repoUrl = REGISTRY_URL;
456
462
  if (!isValidClone(awHome, repoUrl)) {
463
+ if (!input && hasRulesChanges(cwd)) {
464
+ fmt.logInfo('Detected changes under platform rules — redirecting to `aw push-rules`.');
465
+ pushRulesCommand(args);
466
+ return;
467
+ }
457
468
  fmt.cancel('Registry not initialized. Run: aw init');
458
469
  return;
459
470
  }
460
471
 
461
472
  // No args = staged files first (git commit behaviour), else auto-detect all changes
462
473
  if (!input) {
474
+ const rulesChanged = hasRulesChanges(cwd);
475
+
463
476
  // Extra paths outside .aw_registry/ that aw also manages: content/ and CODEOWNERS.
464
477
  // Detect staged variants for staged-mode and unstaged variants for auto-mode.
465
478
  const getExtraStagedPaths = async () => {
@@ -498,6 +511,9 @@ export async function pushCommand(args) {
498
511
  };
499
512
  });
500
513
  const totalCount = files.length + extraStaged.length;
514
+ if (rulesChanged) {
515
+ fmt.logWarn('Detected .aw_rules changes — push them separately with `aw push-rules`.');
516
+ }
501
517
  fmt.logInfo(`${chalk.dim('mode:')} staged (${totalCount} file${totalCount > 1 ? 's' : ''})`);
502
518
  await doPush(files, awHome, dryRun, worktreeFlow, true, extraStaged);
503
519
  return;
@@ -519,6 +535,11 @@ export async function pushCommand(args) {
519
535
  }
520
536
 
521
537
  if (allEntries.length === 0 && extraChanged.length === 0) {
538
+ if (rulesChanged) {
539
+ fmt.logInfo('Detected changes under platform rules — redirecting to `aw push-rules`.');
540
+ pushRulesCommand(args);
541
+ return;
542
+ }
522
543
  fmt.cancel('Nothing to push — no staged or modified files.\n\n Stage files in your IDE or use `aw status` to see changes.');
523
544
  return;
524
545
  }
@@ -548,6 +569,9 @@ export async function pushCommand(args) {
548
569
  }
549
570
 
550
571
  const totalCount = files.length + extraChanged.length;
572
+ if (rulesChanged) {
573
+ fmt.logWarn('Detected .aw_rules changes — push them separately with `aw push-rules`.');
574
+ }
551
575
  fmt.logInfo(`${chalk.dim('mode:')} auto (${totalCount} file${totalCount > 1 ? 's' : ''} — stage specific files to push a subset)`);
552
576
  await doPush(files, awHome, dryRun, worktreeFlow, false, extraChanged);
553
577
  return;
@@ -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
+ }
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
@@ -25,5 +25,12 @@ export const DOCS_SOURCE_DIR = 'content';
25
25
  /** Persistent git clone root — ~/.aw/ */
26
26
  export const AW_HOME = join(homedir(), '.aw');
27
27
 
28
+ /** Directory in platform-docs repo containing platform rules (pulled into .aw_registry/.aw_rules/) */
29
+ export const RULES_SOURCE_DIR = '.aw_rules';
28
30
  /** Telemetry endpoint — override with AW_TELEMETRY_URL env var */
29
- export const TELEMETRY_URL = process.env.AW_TELEMETRY_URL || 'https://services.leadconnectorhq.com/v1/events';
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
@@ -9,7 +9,7 @@ import * as fmt from "./fmt.mjs";
9
9
 
10
10
  const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
11
11
  const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
12
- const AW_ECC_TAG = "v1.2.2";
12
+ const AW_ECC_TAG = "v1.2.9";
13
13
 
14
14
  const MARKETPLACE_NAME = "aw-marketplace";
15
15
  const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
@@ -28,10 +28,71 @@ const TARGET_STATE = {
28
28
  codex: { state: ".codex/ecc-install-state.json" },
29
29
  };
30
30
 
31
+ // User-owned config files that must never be clobbered by aw-ecc install.
32
+ // If these files already exist before `aw init`, we preserve them exactly.
33
+ const PROTECTED_CONFIG_BY_TARGET = {
34
+ claude: [".claude/settings.json", ".claude.json"],
35
+ cursor: [".cursor/settings.json", ".cursor/mcp.json"],
36
+ codex: [".codex/config.toml"],
37
+ };
38
+
31
39
  function run(cmd, opts = {}) {
32
40
  return execSync(cmd, { stdio: "pipe", ...opts });
33
41
  }
34
42
 
43
+ function readIfExists(path) {
44
+ try { return existsSync(path) ? readFileSync(path, "utf8") : null; } catch { return null; }
45
+ }
46
+
47
+ function snapshotProtectedConfigs(home, target) {
48
+ const relPaths = PROTECTED_CONFIG_BY_TARGET[target] || [];
49
+ return relPaths.map((relPath) => {
50
+ const absPath = join(home, relPath);
51
+ const content = readIfExists(absPath);
52
+ return {
53
+ relPath,
54
+ absPath,
55
+ existedBeforeInstall: content !== null,
56
+ contentBeforeInstall: content,
57
+ };
58
+ });
59
+ }
60
+
61
+ function restoreProtectedConfigs(snapshot) {
62
+ let restored = 0;
63
+ for (const entry of snapshot) {
64
+ if (entry.existedBeforeInstall) {
65
+ const current = readIfExists(entry.absPath);
66
+ if (current === entry.contentBeforeInstall) continue;
67
+ try {
68
+ mkdirSync(dirname(entry.absPath), { recursive: true });
69
+ writeFileSync(entry.absPath, entry.contentBeforeInstall ?? "");
70
+ restored++;
71
+ } catch { /* best effort */ }
72
+ continue;
73
+ }
74
+
75
+ // If aw-ecc created a protected settings file from scratch, remove it.
76
+ // AW should not own these user config files.
77
+ try {
78
+ if (existsSync(entry.absPath)) {
79
+ rmSync(entry.absPath, { force: true });
80
+ restored++;
81
+ }
82
+ } catch { /* best effort */ }
83
+ }
84
+ return restored;
85
+ }
86
+
87
+ function relProtectedPath(absPath, home) {
88
+ const normalized = String(absPath || "").replace(/\\/g, "/");
89
+ for (const rel of Object.values(PROTECTED_CONFIG_BY_TARGET).flat()) {
90
+ const full = join(home, rel).replace(/\\/g, "/");
91
+ if (normalized === full) return rel;
92
+ }
93
+ return null;
94
+ }
95
+
35
96
  function cloneOrUpdate(tag, dest) {
36
97
  // AW_ECC_CLONE_URL overrides the remote (used in tests to point at a local fake repo)
37
98
  const overrideUrl = process.env.AW_ECC_CLONE_URL;
@@ -132,6 +193,7 @@ export async function installAwEcc(
132
193
  if (!silent) fmt.logStep("Installing aw-ecc engine...");
133
194
 
134
195
  const repoDir = eccDir();
196
+ const home = homedir();
135
197
 
136
198
  try {
137
199
  cloneOrUpdate(AW_ECC_TAG, repoDir);
@@ -153,6 +215,8 @@ export async function installAwEcc(
153
215
  });
154
216
  for (const target of fileCopyTargets) {
155
217
  try {
218
+ const snapshot = snapshotProtectedConfigs(home, target);
219
+
156
220
  // Always use HOME as cwd so files land in ~/.<target>/ globally.
157
221
  const runCwd = homedir();
158
222
  // For claude: skip commands (plugin handles them as /aw:tdd) but
@@ -173,6 +237,10 @@ export async function installAwEcc(
173
237
  if (target === "codex") {
174
238
  syncEccToCodex(repoDir);
175
239
  }
240
+
241
+ // Critical: preserve user-owned config files if they existed before
242
+ // running aw-ecc (aw should only add, never replace user settings).
243
+ restoreProtectedConfigs(snapshot);
176
244
  } catch { /* target not supported — skip */ }
177
245
  }
178
246
  }
@@ -201,10 +269,18 @@ export function uninstallAwEcc({ silent = false } = {}) {
201
269
  try {
202
270
  const data = JSON.parse(readFileSync(statePath, "utf8"));
203
271
  for (const op of data.operations || []) {
204
- if (op.destinationPath && existsSync(op.destinationPath)) {
205
- rmSync(op.destinationPath, { recursive: true, force: true });
272
+ if (!op.destinationPath) continue;
273
+ const absPath = op.destinationPath.startsWith("/") ? op.destinationPath : join(HOME, op.destinationPath);
274
+ const relProtected = relProtectedPath(absPath, HOME);
275
+ if (relProtected) {
276
+ // Never delete user settings files as part of aw-ecc uninstall.
277
+ // AW-specific MCP removal is handled separately in removeMcpConfig().
278
+ continue;
279
+ }
280
+ if (existsSync(absPath)) {
281
+ rmSync(absPath, { recursive: true, force: true });
206
282
  removed++;
207
- pruneEmptyParents(op.destinationPath, join(HOME, cfg.state.split("/")[0]));
283
+ pruneEmptyParents(absPath, join(HOME, cfg.state.split("/")[0]));
208
284
  }
209
285
  }
210
286
  rmSync(statePath, { force: true });
package/file-tree.mjs ADDED
@@ -0,0 +1,76 @@
1
+ import {
2
+ mkdirSync,
3
+ existsSync,
4
+ readdirSync,
5
+ copyFileSync,
6
+ unlinkSync,
7
+ rmdirSync,
8
+ } from 'node:fs';
9
+ import { join, relative, dirname } from 'node:path';
10
+
11
+ /**
12
+ * Collect all file paths (relative) in a directory tree.
13
+ */
14
+ export function collectAllPaths(dir, base) {
15
+ const paths = new Set();
16
+ if (!existsSync(dir)) return paths;
17
+
18
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
19
+ if (entry.name.startsWith('.')) continue;
20
+
21
+ const full = join(dir, entry.name);
22
+ if (entry.isDirectory()) {
23
+ for (const relPath of collectAllPaths(full, base)) paths.add(relPath);
24
+ } else {
25
+ paths.add(relative(base, full));
26
+ }
27
+ }
28
+
29
+ return paths;
30
+ }
31
+
32
+ /**
33
+ * Mirror all files from src to dest.
34
+ * By default, local-only files are removed so dest stays in sync with src.
35
+ */
36
+ export function syncFileTree(src, dest, options = {}) {
37
+ const { deleteMissing = true } = options;
38
+ mkdirSync(dest, { recursive: true });
39
+
40
+ const remotePaths = collectAllPaths(src, src);
41
+ const localPaths = collectAllPaths(dest, dest);
42
+
43
+ for (const relPath of remotePaths) {
44
+ const srcPath = join(src, relPath);
45
+ const destPath = join(dest, relPath);
46
+ mkdirSync(dirname(destPath), { recursive: true });
47
+ copyFileSync(srcPath, destPath);
48
+ }
49
+
50
+ if (deleteMissing) {
51
+ for (const relPath of localPaths) {
52
+ if (!remotePaths.has(relPath)) {
53
+ const destPath = join(dest, relPath);
54
+ try {
55
+ unlinkSync(destPath);
56
+ } catch {
57
+ // best effort
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ function pruneEmpty(dir) {
64
+ if (!existsSync(dir)) return;
65
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
66
+ if (entry.isDirectory()) pruneEmpty(join(dir, entry.name));
67
+ }
68
+ try {
69
+ if (readdirSync(dir).length === 0 && dir !== dest) rmdirSync(dir);
70
+ } catch {
71
+ // best effort
72
+ }
73
+ }
74
+
75
+ pruneEmpty(dest);
76
+ }
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
  }