Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 36 additions & 19 deletions plugin/scripts/smart-install.js
Original file line number Diff line number Diff line change
Expand Up @@ -343,54 +343,71 @@ function installUv() {
* Add shell alias for claude-mem command
*/
function installCLI() {
const WORKER_CLI = join(ROOT, 'scripts', 'worker-service.cjs');
const bunPath = getBunPath() || 'bun';
const aliasLine = `alias claude-mem='${bunPath} "${WORKER_CLI}"'`;
const markerPath = join(ROOT, '.cli-installed');

// Skip if already installed
if (existsSync(markerPath)) return;
// Use the stable marketplace path so the alias survives plugin upgrades.
// $HOME is expanded at shell invocation time, keeping the line portable.
const STABLE_WORKER_PATH = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs';
const aliasLine = `alias claude-mem='${bunPath} "${STABLE_WORKER_PATH}"'`;

// Regex anchored to line start (multiline) — ignores commented-out lines.
const ALIAS_RE = /^alias claude-mem=.*$/m;

try {
if (IS_WINDOWS) {
// Windows: Add to PATH via PowerShell profile
// Windows: Add/update PowerShell function in profile
const profilePath = join(process.env.USERPROFILE || homedir(), 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1');
const profileDir = join(process.env.USERPROFILE || homedir(), 'Documents', 'PowerShell');
const functionDef = `function claude-mem { & "${bunPath}" "${WORKER_CLI}" $args }\n`;
const STABLE_WORKER_PATH_WIN = '$HOME\\.claude\\plugins\\marketplaces\\thedotmack\\plugin\\scripts\\worker-service.cjs';
const functionDef = `function claude-mem { & "${bunPath}" "${STABLE_WORKER_PATH_WIN}" $args }`;
const PS_FUNC_RE = /^function claude-mem \{[^}]*\}$/m;

if (!existsSync(profileDir)) {
execSync(`mkdir "${profileDir}"`, { stdio: 'ignore', shell: true });
}

const existingContent = existsSync(profilePath) ? readFileSync(profilePath, 'utf-8') : '';
if (!existingContent.includes('function claude-mem')) {
writeFileSync(profilePath, existingContent + '\n' + functionDef);
const psMatch = existingContent.match(PS_FUNC_RE);

if (!psMatch) {
writeFileSync(profilePath, existingContent.trimEnd() + '\n' + functionDef + '\n');
console.error(`✅ PowerShell function added to profile`);
console.error(' Restart your terminal to use: claude-mem <command>');
} else if (psMatch[0] !== functionDef) {
writeFileSync(profilePath, existingContent.replace(PS_FUNC_RE, functionDef));
console.error(`✅ PowerShell function updated in profile`);
}
} else {
// Unix: Add alias to shell configs
// Unix: Add/update alias in shell configs
const shellConfigs = [
join(homedir(), '.bashrc'),
join(homedir(), '.zshrc')
];

for (const config of shellConfigs) {
if (existsSync(config)) {
const content = readFileSync(config, 'utf-8');
if (!content.includes('alias claude-mem=')) {
writeFileSync(config, content + '\n' + aliasLine + '\n');
console.error(`✅ Alias added to ${config}`);
}
if (!existsSync(config)) continue;
let content = readFileSync(config, 'utf-8');
const match = content.match(ALIAS_RE);

if (!match) {
// Not present — append it.
writeFileSync(config, content.trimEnd() + '\n' + aliasLine + '\n');
console.error(`✅ Alias added to ${config}`);
continue;
}

if (match[0] === aliasLine) continue; // already canonical

// Replace existing (potentially stale) alias line with the canonical one.
content = content.replace(ALIAS_RE, aliasLine);
writeFileSync(config, content);
console.error(`✅ Alias updated in ${config}`);
}
console.error(' Restart your terminal to use: claude-mem <command>');
}

writeFileSync(markerPath, new Date().toISOString());
} catch (error) {
console.error(`⚠️ Could not add shell alias: ${error.message}`);
console.error(` Use directly: ${bunPath} "${WORKER_CLI}" <command>`);
console.error(` Use directly: ${bunPath} "${STABLE_WORKER_PATH}" <command>`);
}
}

Expand Down
107 changes: 107 additions & 0 deletions tests/smart-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,113 @@ describe('smart-install stdout JSON output (#1253)', () => {
});
});

/**
* Tests for installCLI() alias stability (version-pinning fix).
*
* installCLI() is not exported, so these tests verify the source-level
* patterns (stable path, regex guard, update logic) and the runtime
* behaviour via a helper that simulates the alias write/update cycle.
*/
describe('smart-install installCLI alias stability', () => {
const SCRIPT_PATH = join(__dirname, '..', 'plugin', 'scripts', 'smart-install.js');

/** Extract the installCLI() function body from the source file. */
function getInstallCLIBody(): string {
const content = readFileSync(SCRIPT_PATH, 'utf-8');
const funcStart = content.indexOf('function installCLI()');
const funcEnd = content.indexOf('\n/**', funcStart + 1);
return content.slice(funcStart, funcEnd);
}

it('should use the stable marketplace path instead of ROOT for the alias', () => {
const content = readFileSync(SCRIPT_PATH, 'utf-8');
// The alias must point to the stable marketplace path, not a version-pinned ROOT
expect(content).toContain(
"const STABLE_WORKER_PATH = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs'"
);
// The alias line should be composed from STABLE_WORKER_PATH, not WORKER_CLI/ROOT
expect(content).toContain("const aliasLine = `alias claude-mem='${bunPath} \"${STABLE_WORKER_PATH}\"'`");
});

it('should not compose the alias from ROOT (which is version-pinned)', () => {
const funcBody = getInstallCLIBody();
expect(funcBody).not.toContain("join(ROOT, 'scripts', 'worker-service.cjs')");
});

it('should use a regex guard instead of simple includes() for alias detection', () => {
const funcBody = getInstallCLIBody();
expect(funcBody).not.toContain("content.includes('alias claude-mem=')");
expect(funcBody).toContain('ALIAS_RE');
expect(funcBody).toContain('content.match(ALIAS_RE)');
});

it('should have logic to update stale aliases, not just skip them', () => {
const funcBody = getInstallCLIBody();
expect(funcBody).toContain('content.replace(ALIAS_RE, aliasLine)');
expect(funcBody).toContain('Alias updated in');
});

it('should not use .cli-installed marker for early return', () => {
const funcBody = getInstallCLIBody();
expect(funcBody).not.toContain('.cli-installed');
});

it('should use $HOME literal in the alias for portability', () => {
const funcBody = getInstallCLIBody();
expect(funcBody).toContain("'$HOME/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs'");
});

// --- Runtime simulation tests ---
// These simulate the regex-based alias write/update logic
// without calling installCLI() directly.

const ALIAS_RE = /^alias claude-mem=.*$/m;

it('should add alias when none exists', () => {
const canonical = `alias claude-mem='bun "$HOME/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs"'`;
const shellContent = '# some existing config\nexport PATH="/usr/bin:$PATH"\n';

const match = shellContent.match(ALIAS_RE);
expect(match).toBeNull();

// Simulates the append path
const updated = shellContent.trimEnd() + '\n' + canonical + '\n';
expect(updated).toContain(canonical);
});

it('should not modify config when alias is already canonical', () => {
const canonical = `alias claude-mem='bun "$HOME/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs"'`;
const shellContent = `# config\n${canonical}\nexport FOO=bar\n`;

const match = shellContent.match(ALIAS_RE);
expect(match).not.toBeNull();
expect(match![0]).toBe(canonical);
});

it('should replace a stale version-pinned alias with the canonical one', () => {
const canonical = `alias claude-mem='bun "$HOME/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs"'`;
const staleAlias = `alias claude-mem='bun "/Users/someone/.claude/plugins/cache/thedotmack/claude-mem/10.5.2/scripts/worker-service.cjs"'`;
const shellContent = `# config\n${staleAlias}\nexport FOO=bar\n`;

const match = shellContent.match(ALIAS_RE);
expect(match).not.toBeNull();
expect(match![0]).not.toBe(canonical);

const updated = shellContent.replace(ALIAS_RE, canonical);
expect(updated).toContain(canonical);
expect(updated).not.toContain('10.5.2');
expect(updated).toContain('export FOO=bar'); // other content preserved
});

it('should not match commented-out alias lines', () => {
const commentedAlias = `# alias claude-mem='bun "/old/path/worker-service.cjs"'`;
const shellContent = `# config\n${commentedAlias}\nexport FOO=bar\n`;

const match = shellContent.match(ALIAS_RE);
expect(match).toBeNull();
});
});

/**
* Tests for checkBinaryPlatformCompatibility() (#1547).
*
Expand Down