diff --git a/scripts/commands/remove-credentials.ps1 b/scripts/commands/remove-credentials.ps1 new file mode 100644 index 0000000..92473a0 --- /dev/null +++ b/scripts/commands/remove-credentials.ps1 @@ -0,0 +1,27 @@ +[CmdletBinding()] +param( + [ValidateSet('Codex','Claude','Both')][string]$Client, + [string]$Key +) +$ErrorActionPreference = 'Stop' +$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +. (Join-Path $root 'scripts/lib/install-targets.ps1') +$selectedClient = $Client +$selectedKey = $Key +. (Join-Path $root 'scripts/lib/credentials.ps1') +$Client = $selectedClient +$Key = $selectedKey + +if (-not $Client) { $Client = Read-InstallClient -Client $Client } +if (-not $Key) { $Key = Read-Host 'Credential key to remove' } +Assert-CredentialKey $Key + +foreach ($target in $(if ($Client -eq 'Both') { @('Codex', 'Claude') } else { @($Client) })) { + try { + Remove-ManagedCredential $target $Key + Write-Output "Credential removed for $($target.ToLowerInvariant())." + } catch [ComponentModel.Win32Exception] { + if ($_.Exception.NativeErrorCode -ne 1168) { throw } + Write-Output "Credential not found for $($target.ToLowerInvariant())." + } +} diff --git a/scripts/commands/remove-credentials.sh b/scripts/commands/remove-credentials.sh new file mode 100644 index 0000000..75ebdd9 --- /dev/null +++ b/scripts/commands/remove-credentials.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +client= +key= +while [ $# -gt 0 ]; do + case "$1" in + --client) client=${2:?--client requires a value}; shift 2 ;; + --key) key=${2:?--key requires a value}; shift 2 ;; + *) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;; + esac +done + +[ -n "$client" ] || read -r -p 'Select client (codex, claude, or both) ' client +[ -n "$key" ] || read -r -p 'Credential key to remove ' key +case "$client" in codex|Codex|claude|Claude|both|Both) ;; *) printf 'Unknown --client value: %s\n' "$client" >&2; exit 1 ;; esac +[[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]{0,127}$ ]] || { printf 'Credential key must be an environment-variable-compatible name.\n' >&2; exit 1; } + +powershell_command=$(command -v powershell.exe || command -v powershell || command -v pwsh || true) +[ -n "$powershell_command" ] || { printf 'PowerShell is required to remove credentials from the Windows credential store.\n' >&2; exit 1; } +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +"$powershell_command" -NoProfile -ExecutionPolicy Bypass -File "$script_dir/remove-credentials.ps1" -Client "$client" -Key "$key" diff --git a/scripts/commands/remove-instruction.ps1 b/scripts/commands/remove-instruction.ps1 new file mode 100644 index 0000000..48ce9e5 --- /dev/null +++ b/scripts/commands/remove-instruction.ps1 @@ -0,0 +1,15 @@ +[CmdletBinding()] +param( + [ValidateSet('Codex','Claude','Both')][string]$Client, + [AllowEmptyString()][string]$Instruction, + [string]$HomePath = [Environment]::GetFolderPath('UserProfile') +) +$ErrorActionPreference = 'Stop' +$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +. (Join-Path $root 'scripts/lib/install-targets.ps1') + +if (-not $Client) { $Client = Read-InstallClient -Client $Client } +if (-not $PSBoundParameters.ContainsKey('Instruction')) { $Instruction = Read-Host 'Instruction to remove' } +if ([string]::IsNullOrWhiteSpace($Instruction) -or $Instruction.Length -gt 65536) { throw 'Instruction must contain between 1 and 65536 characters.' } +& python (Join-Path $root 'scripts/lib/remove-instruction.py') --client $Client.ToLowerInvariant() --instruction $Instruction.TrimEnd() --home $HomePath +if ($LASTEXITCODE -ne 0) { throw 'Instruction removal failed.' } diff --git a/scripts/commands/remove-instruction.sh b/scripts/commands/remove-instruction.sh new file mode 100644 index 0000000..ebf2f9d --- /dev/null +++ b/scripts/commands/remove-instruction.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +client= +instruction= +home_path=${HOME:?HOME is required} +while [ $# -gt 0 ]; do + case "$1" in + --client) client=${2:?--client requires a value}; shift 2 ;; + --instruction) instruction=${2:?--instruction requires a value}; shift 2 ;; + --home) home_path=${2:?--home requires a value}; shift 2 ;; + *) printf 'Unknown argument: %s\n' "$1" >&2; exit 1 ;; + esac +done + +[ -n "$client" ] || read -r -p 'Select client (codex, claude, or both) ' client +[ -n "$instruction" ] || read -r -p 'Instruction to remove ' instruction +case "$client" in codex|Codex|claude|Claude|both|Both) ;; *) printf 'Unknown --client value: %s\n' "$client" >&2; exit 1 ;; esac +case "$home_path" in /*|[A-Za-z]:[\\/]*) ;; *) printf 'Home path must be absolute.\n' >&2; exit 1 ;; esac + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +python_command=$(command -v python || command -v python3 || true) +[ -n "$python_command" ] || { printf 'Python is required to remove instructions.\n' >&2; exit 1; } +"$python_command" "$script_dir/../lib/remove-instruction.py" --client "${client,,}" --instruction "$instruction" --home "$home_path" diff --git a/scripts/lib/remove-instruction.py b/scripts/lib/remove-instruction.py new file mode 100644 index 0000000..2e67c52 --- /dev/null +++ b/scripts/lib/remove-instruction.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Remove the last exact instruction block added to a client overlay.""" + +import argparse +import os +import stat +import tempfile +from pathlib import Path + + +def remove_instruction(path: Path, instruction: str) -> bool: + if not path.exists(): + return False + if path.is_symlink() or not path.is_file(): + raise ValueError(f'Instruction path is not a regular file: {path}') + + raw = path.read_bytes() + encoding = 'utf-8-sig' if raw.startswith(b'\xef\xbb\xbf') else 'utf-8' + lines = raw.decode(encoding).splitlines(keepends=True) + wanted = instruction.rstrip('\r\n').splitlines() + values = [line.rstrip('\r\n') for line in lines] + offset = next((i for i in range(len(values) - len(wanted), -1, -1) + if values[i:i + len(wanted)] == wanted), None) + if offset is None: + return False + + updated = ''.join(lines[:offset] + lines[offset + len(wanted):]) + if not updated: + path.unlink() + return True + + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=path.parent, prefix='.instruction-', delete=False) as file: + temporary = Path(file.name) + file.write(updated.encode(encoding)) + os.chmod(temporary, stat.S_IMODE(path.stat().st_mode)) + os.replace(temporary, path) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--client', choices=('codex', 'claude', 'both'), required=True) + parser.add_argument('--instruction', required=True) + parser.add_argument('--home', required=True) + args = parser.parse_args() + if not args.instruction.strip() or len(args.instruction) > 65536: + parser.error('Instruction must contain between 1 and 65536 characters.') + home = Path(args.home) + if not home.is_absolute(): + parser.error('Home path must be absolute.') + directory = home / '.my-ai-configuration' / 'instructions' + if (home / '.my-ai-configuration').is_symlink() or directory.is_symlink(): + parser.error('Instruction directory must not be a link.') + try: + inside_home = os.path.commonpath((home.resolve(), directory.resolve())) == str(home.resolve()) + except ValueError: + inside_home = False + if not inside_home: + parser.error('Instruction directory must remain inside the home directory.') + + targets = ('codex', 'claude') if args.client == 'both' else (args.client,) + paths = [(target, directory / f'{target}.md') for target in targets] + for _, path in paths: + if path.is_symlink(): + parser.error('Instruction path must not be a link.') + if path.exists() and not path.is_file(): + parser.error('Instruction path must be a regular file.') + for target, path in paths: + if remove_instruction(path, args.instruction): + print(f'Instruction removed for {target}.') + else: + print(f'Instruction not found for {target}.') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/tests/test-remove-customizations.py b/scripts/tests/test-remove-customizations.py new file mode 100644 index 0000000..7c00c37 --- /dev/null +++ b/scripts/tests/test-remove-customizations.py @@ -0,0 +1,163 @@ +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +TEST_ROOT = ROOT / '.ai-session' / 'test-remove-customizations' + + +class RemoveCustomizationTests(unittest.TestCase): + def setUp(self): + TEST_ROOT.mkdir(parents=True, exist_ok=True) + self.assertTrue(TEST_ROOT.resolve().is_relative_to(ROOT.resolve())) + self.temporary = tempfile.TemporaryDirectory(dir=TEST_ROOT) + self.addCleanup(self.temporary.cleanup) + self.home = Path(self.temporary.name) + self.environment = os.environ.copy() + for name in ('TEMP', 'TMP', 'TMPDIR'): + self.environment[name] = str(self.home) + + def run_command(self, *arguments, input=None): + return subprocess.run(arguments, input=input, capture_output=True, text=True, + cwd=ROOT, env=self.environment, timeout=20) + + def test_remove_last_exact_instruction_and_keep_other_lines(self): + directory = self.home / '.my-ai-configuration' / 'instructions' + directory.mkdir(parents=True) + path = directory / 'codex.md' + path.write_bytes(b'Before\r\nRemove this\r\nKeep this\r\nRemove this\r\n') + command = (sys.executable, str(ROOT / 'scripts/lib/remove-instruction.py'), + '--client', 'codex', '--instruction', 'Remove this', '--home', str(self.home)) + + first = self.run_command(*command) + self.assertEqual(0, first.returncode, first.stderr) + self.assertEqual(b'Before\r\nRemove this\r\nKeep this\r\n', path.read_bytes()) + second = self.run_command(*command) + self.assertEqual(0, second.returncode, second.stderr) + self.assertEqual(b'Before\r\nKeep this\r\n', path.read_bytes()) + third = self.run_command(*command) + self.assertEqual(0, third.returncode, third.stderr) + self.assertIn('Instruction not found for codex.', third.stdout) + + def test_remove_both(self): + directory = self.home / '.my-ai-configuration' / 'instructions' + directory.mkdir(parents=True) + for client in ('codex', 'claude'): + (directory / f'{client}.md').write_text('Remove me\n', encoding='utf-8') + result = self.run_command(sys.executable, str(ROOT / 'scripts/lib/remove-instruction.py'), + '--client', 'both', '--instruction', 'Remove me', '--home', str(self.home)) + self.assertEqual(0, result.returncode, result.stderr) + self.assertFalse((directory / 'codex.md').exists()) + self.assertFalse((directory / 'claude.md').exists()) + + def test_linked_instruction_directory_is_rejected(self): + parent = self.home / '.my-ai-configuration' + parent.mkdir() + elsewhere = self.home / 'elsewhere' + elsewhere.mkdir() + path = elsewhere / 'codex.md' + path.write_text('Keep this\n', encoding='utf-8') + try: + (parent / 'instructions').symlink_to(elsewhere, target_is_directory=True) + except OSError: + self.skipTest('Directory symlinks are unavailable.') + result = self.run_command(sys.executable, str(ROOT / 'scripts/lib/remove-instruction.py'), + '--client', 'codex', '--instruction', 'Keep this', '--home', str(self.home)) + self.assertNotEqual(0, result.returncode) + self.assertEqual('Keep this\n', path.read_text(encoding='utf-8')) + + def test_powershell_remove_instruction_entry_point(self): + powershell = shutil.which('powershell') or shutil.which('pwsh') + if powershell is None: + self.skipTest('PowerShell is unavailable.') + path = self.home / '.my-ai-configuration' / 'instructions' / 'codex.md' + path.parent.mkdir(parents=True) + path.write_text('Keep this\nRemove this\n', encoding='utf-8') + result = self.run_command(powershell, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', + str(ROOT / 'scripts/commands/remove-instruction.ps1'), + '-Client', 'Codex', '-Instruction', 'Remove this ', '-HomePath', str(self.home)) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual('Keep this\n', path.read_text(encoding='utf-8')) + + bash = Path('C:/Program Files/Git/bin/bash.exe') if os.name == 'nt' else shutil.which('bash') + if bash and Path(bash).is_file(): + path.write_text('Keep this\nRemove from Bash\n', encoding='utf-8') + self.environment['TEST_HOME'] = str(self.home) + if os.name == 'nt': + script = ('python3() { python.exe "$@"; }; ' + 'source scripts/commands/remove-instruction.sh --client codex ' + '--instruction "Remove from Bash" --home "$TEST_HOME"') + removed = self.run_command(str(bash), '-c', script) + else: + removed = self.run_command(str(bash), str(ROOT / 'scripts/commands/remove-instruction.sh'), + '--client', 'codex', '--instruction', 'Remove from Bash', '--home', str(self.home)) + self.assertEqual(0, removed.returncode, removed.stderr) + self.assertEqual('Keep this\n', path.read_text(encoding='utf-8')) + + added = self.run_command(powershell, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', + str(ROOT / 'scripts/commands/add-instruction.ps1'), + '-Client', 'Both', '-Instruction', 'New shared instruction', '-HomePath', str(self.home)) + self.assertEqual(0, added.returncode, added.stderr) + removed = self.run_command(powershell, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', + str(ROOT / 'scripts/commands/remove-instruction.ps1'), + '-Client', 'Both', '-Instruction', 'New shared instruction', '-HomePath', str(self.home)) + self.assertEqual(0, removed.returncode, removed.stderr) + self.assertEqual('Keep this\n', path.read_text(encoding='utf-8')) + self.assertFalse((path.parent / 'claude.md').exists()) + + def test_powershell_remove_credential_scopes_and_missing_key(self): + powershell = shutil.which('powershell') or shutil.which('pwsh') + if powershell is None: + self.skipTest('PowerShell is unavailable.') + commands = self.home / 'fixture' / 'scripts' / 'commands' + library = self.home / 'fixture' / 'scripts' / 'lib' + commands.mkdir(parents=True) + library.mkdir(parents=True) + for name in ('remove-credentials.ps1', 'remove-credentials.sh'): + shutil.copy2(ROOT / 'scripts' / 'commands' / name, commands / name) + shutil.copy2(ROOT / 'scripts/lib/install-targets.ps1', library / 'install-targets.ps1') + (library / 'credentials.ps1').write_text(''' +function Assert-CredentialKey { param([string]$Value) + if ($Value -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,127}$') { throw 'Invalid credential key.' } +} +function Remove-ManagedCredential { param([string]$Client, [string]$Key) + if ($Key -eq 'MISSING') { throw [ComponentModel.Win32Exception]::new(1168) } + [IO.File]::AppendAllText($env:TEST_CREDENTIAL_LOG, "$Client/$Key`n") +} +''', encoding='utf-8') + log = self.home / 'credential-calls.txt' + self.environment['TEST_CREDENTIAL_LOG'] = str(log) + base = (powershell, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', + str(commands / 'remove-credentials.ps1'), '-Client', 'Both', '-Key') + + removed = self.run_command(*base, 'TEST_KEY') + self.assertEqual(0, removed.returncode, removed.stderr) + self.assertEqual(['Codex/TEST_KEY', 'Claude/TEST_KEY'], log.read_text().splitlines()) + missing = self.run_command(*base, 'MISSING') + self.assertEqual(0, missing.returncode, missing.stderr) + self.assertEqual(2, missing.stdout.count('Credential not found')) + rejected = self.run_command(*base, 'BAD-KEY') + self.assertNotEqual(0, rejected.returncode) + self.assertEqual(['Codex/TEST_KEY', 'Claude/TEST_KEY'], log.read_text().splitlines()) + + bash = Path('C:/Program Files/Git/bin/bash.exe') if os.name == 'nt' else shutil.which('bash') + if bash and Path(bash).is_file(): + if os.name == 'nt': + self.environment['TEST_SCRIPT'] = str(commands / 'remove-credentials.sh') + removed = self.run_command(str(bash), '-c', + 'source "$(cygpath -u "$TEST_SCRIPT")" --client codex --key TEST_KEY') + else: + removed = self.run_command(str(bash), str(commands / 'remove-credentials.sh'), + '--client', 'codex', '--key', 'TEST_KEY') + self.assertEqual(0, removed.returncode, removed.stderr) + self.assertEqual(['Codex/TEST_KEY', 'Claude/TEST_KEY', 'codex/TEST_KEY'], + log.read_text().splitlines()) + + +if __name__ == '__main__': + unittest.main()