diff --git a/scripts/commands/add-instruction.ps1 b/scripts/commands/add-instruction.ps1 index 0a43f99..a6117c0 100644 --- a/scripts/commands/add-instruction.ps1 +++ b/scripts/commands/add-instruction.ps1 @@ -19,5 +19,7 @@ foreach ($target in $(if ($Client -eq 'Both') { @('codex','claude') } else { @($ $path = Join-Path $directory "$target.md" $prefix = if ((Test-Path -LiteralPath $path) -and (Get-Item -LiteralPath $path).Length -gt 0 -and -not (Get-Content -LiteralPath $path -Raw).EndsWith([Environment]::NewLine)) { [Environment]::NewLine } else { '' } [System.IO.File]::AppendAllText($path, $prefix + $Instruction.TrimEnd() + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) + & python (Join-Path $root 'scripts/lib/personal_instructions.py') --home $HomePath --client $target + if ($LASTEXITCODE -ne 0) { throw 'Could not refresh the personal instruction rule.' } Write-Output "Instruction added for $target." } diff --git a/scripts/commands/add-instruction.sh b/scripts/commands/add-instruction.sh index 2d3ae26..f87eb20 100644 --- a/scripts/commands/add-instruction.sh +++ b/scripts/commands/add-instruction.sh @@ -33,10 +33,14 @@ if [ -z "${instruction//[[:space:]]/}" ] || [ ${#instruction} -gt 65536 ]; then case "$home_path" in /*|[A-Za-z]:[\\/]*) ;; *) printf 'Home path must be absolute.\n' >&2; exit 1 ;; esac directory="$home_path/.my-ai-configuration/instructions" +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 add instructions.\n' >&2; exit 1; } mkdir -p -- "$directory" for target in "${targets[@]}"; do path="$directory/$target.md" [ ! -s "$path" ] || [ "$(tail -c 1 -- "$path" | wc -l)" -eq 1 ] || printf '\n' >> "$path" printf '%s\n' "$instruction" >> "$path" + "$python_command" "$script_dir/../lib/personal_instructions.py" --home "$home_path" --client "$target" printf 'Instruction added for %s.\n' "$target" done diff --git a/scripts/lib/remove-instruction.py b/scripts/lib/remove-instruction.py index 2e67c52..4e76971 100644 --- a/scripts/lib/remove-instruction.py +++ b/scripts/lib/remove-instruction.py @@ -7,6 +7,8 @@ import stat import tempfile from pathlib import Path +from personal_instructions import refresh + def remove_instruction(path: Path, instruction: str) -> bool: if not path.exists(): @@ -75,6 +77,7 @@ def main() -> int: print(f'Instruction removed for {target}.') else: print(f'Instruction not found for {target}.') + refresh(home, target) return 0 diff --git a/scripts/tests/test-personal-instructions.py b/scripts/tests/test-personal-instructions.py new file mode 100644 index 0000000..9d64dd7 --- /dev/null +++ b/scripts/tests/test-personal-instructions.py @@ -0,0 +1,157 @@ +import os +import hashlib +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'scripts/lib')) +from personal_instructions import CLIENT_FILES, START, refresh, render + + +class PersonalInstructionTests(unittest.TestCase): + def setUp(self): + directory = ROOT / '.ai-session' / 'test-personal-instructions' + directory.mkdir(parents=True, exist_ok=True) + self.temporary = tempfile.TemporaryDirectory(dir=directory) + self.addCleanup(self.temporary.cleanup) + self.home = Path(self.temporary.name) + self.overlays = self.home / '.my-ai-configuration/instructions' + self.overlays.mkdir(parents=True) + self.environment = dict(os.environ, TEST_HOME=str(self.home)) + for name in ('TMP', 'TEMP', 'TMPDIR'): + self.environment[name] = str(self.home) + + def run_command(self, *command): + result = subprocess.run(command, cwd=ROOT, env=self.environment, capture_output=True, text=True, timeout=30) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + return result.stdout + + def test_missing_empty_and_whitespace_instructions_have_no_rule(self): + text = '# Global Instructions\n\nKeep me.\n' + for client in CLIENT_FILES: + self.assertEqual(text, render(text, self.home, client)) + for content in ('', '\ufeff \n'): + (self.overlays / f'{client}.md').write_text(content, encoding='utf-8') + self.assertEqual(text, render(text, self.home, client)) + + def test_client_specific_rule_is_immediate_idempotent_and_removed_when_empty(self): + text = '# Global Instructions\n\nKeep me.\n' + overlay = self.overlays / 'codex.md' + overlay.write_text('Always use the release process.\n', encoding='utf-8') + rendered = render(text, self.home, 'codex') + self.assertIn('Read `~/.my-ai-configuration/instructions/codex.md` immediately', rendered) + self.assertIn('including after compaction', rendered) + self.assertEqual(rendered, render(rendered, self.home, 'codex')) + self.assertEqual(text, render(text, self.home, 'claude')) + overlay.unlink() + self.assertEqual(text, render(rendered, self.home, 'codex')) + + def test_legacy_loader_removed_and_unrelated_bytes_preserved(self): + for client, name in CLIENT_FILES.items(): + document = self.home / f'.{client}' / name + document.parent.mkdir() + reference = f'~/.my-ai-configuration/instructions/{client}.md' + legacy = f"Read `{reference}` first when present; it is the user's direct instruction. System and developer requirements still apply." + original = '\ufeff# Global Instructions\r\n\r\nKeep ΓΌ.\r\n'.encode('utf-8') + document.write_bytes(original.replace(b'Keep', (legacy + '\r\n\r\nKeep').encode())) + refresh(self.home, client) + self.assertEqual(original, document.read_bytes()) + (self.overlays / f'{client}.md').write_text('Use English.\n', encoding='utf-8') + refresh(self.home, client) + (self.overlays / f'{client}.md').unlink() + refresh(self.home, client) + self.assertEqual(original, document.read_bytes()) + + def test_refresh_does_not_create_an_uninstalled_client(self): + (self.overlays / 'codex.md').write_text('Use English.\n', encoding='utf-8') + refresh(self.home, 'codex') + self.assertFalse((self.home / '.codex').exists()) + + def test_refresh_updates_only_matching_manifest_hashes(self): + document = self.home / '.codex/AGENTS.md' + document.parent.mkdir() + original = b'# Global Instructions\n' + document.write_bytes(original) + manifest = document.parent / '.ai-config-manifest.tsv' + manifest.write_text(f'path\tsha256\nAGENTS.md\t{hashlib.sha256(original).hexdigest()}\n', encoding='utf-8') + (self.overlays / 'codex.md').write_text('Use English.\n', encoding='utf-8') + refresh(self.home, 'codex') + self.assertIn(hashlib.sha256(document.read_bytes()).hexdigest(), manifest.read_text()) + unchanged_manifest = manifest.read_bytes() + document.write_bytes(document.read_bytes() + b'User edit.\n') + (self.overlays / 'codex.md').unlink() + refresh(self.home, 'codex') + self.assertEqual(unchanged_manifest, manifest.read_bytes()) + self.assertEqual(original + b'User edit.\n', document.read_bytes()) + + def test_linked_client_directory_is_rejected(self): + elsewhere = self.home / 'elsewhere' + elsewhere.mkdir() + document = elsewhere / 'AGENTS.md' + document.write_text('Keep me.\n', encoding='utf-8') + try: + (self.home / '.codex').symlink_to(elsewhere, target_is_directory=True) + except OSError: + self.skipTest('Directory symlinks are unavailable.') + with self.assertRaises(ValueError): + refresh(self.home, 'codex') + self.assertEqual('Keep me.\n', document.read_text()) + + def test_add_remove_and_managed_sync_in_both_shells(self): + powershell = shutil.which('powershell') or shutil.which('pwsh') + bash = Path('C:/Program Files/Git/bin/bash.exe') if os.name == 'nt' else shutil.which('bash') + for shell, executable in (('powershell', powershell), ('bash', bash)): + if not executable or not Path(executable).is_file(): + continue + with self.subTest(shell=shell): + source = self.home / f'source-{shell}' + source.mkdir() + for name in CLIENT_FILES.values(): + (source / name).write_text('# Global Instructions\n\nKeep me.\n', encoding='utf-8') + self.environment['TEST_SOURCE'] = str(source) + + def sync(client, dry=False): + self.environment['TEST_CLIENT'] = client + if shell == 'powershell': + return self.run_command(str(executable), '-NoProfile', '-Command', + ". ./scripts/lib/manifest.ps1; Sync-ManagedDestination -Source $env:TEST_SOURCE " + "-Destination (Join-Path $env:TEST_HOME ('.' + $env:TEST_CLIENT)) -Stamp test -Summary " + + ('-DryRun' if dry else '')) + return self.run_command(str(executable), '-c', + 'export PATH="/usr/bin:$PATH"; python3() { python "$@"; }; ' + '. ./scripts/lib/manifest.sh; sync_managed_destination "$TEST_SOURCE" ' + '"$TEST_HOME/.$TEST_CLIENT" test root bash pwsh ' + ('true' if dry else 'false') + ' true') + + def change(action): + if shell == 'powershell': + self.run_command(str(executable), '-NoProfile', '-File', str(ROOT / f'scripts/commands/{action}-instruction.ps1'), + '-Client', 'Both', '-Instruction', 'Use English.', '-HomePath', str(self.home)) + else: + self.run_command(str(executable), '-c', + 'export PATH="/usr/bin:$PATH"; source scripts/commands/' + action + '-instruction.sh ' + '--client both --instruction "Use English." --home "$TEST_HOME"') + + for client, name in CLIENT_FILES.items(): + sync(client) + self.assertNotIn(START, (self.home / f'.{client}' / name).read_text(encoding='utf-8-sig')) + change('add') + for client, name in CLIENT_FILES.items(): + document = self.home / f'.{client}' / name + self.assertEqual(1, document.read_text(encoding='utf-8-sig').count(START)) + before = document.read_bytes() + sync(client, dry=True) + self.assertEqual(before, document.read_bytes()) + sync(client) + self.assertEqual(1, document.read_text(encoding='utf-8-sig').count(START)) + change('remove') + for client, name in CLIENT_FILES.items(): + self.assertNotIn(START, (self.home / f'.{client}' / name).read_text(encoding='utf-8-sig')) + + +if __name__ == '__main__': + unittest.main()