feat(customizations): add removal commands
This commit is contained in:
1 parent
29c0e98272
commit
05663197f7
6 files changed
+333
No files matched your search
@@ -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())."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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.' }
|
||||||
@@ -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"
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user