Files
MyAiConfiguration/scripts/lib/personal_instructions.py
T

100 lines
4.3 KiB
Python

"""Keep the client startup rule in sync with its personal instructions."""
import argparse
import hashlib
import os
from pathlib import Path
import stat
import tempfile
CLIENT_FILES = {'codex': 'AGENTS.md', 'claude': 'CLAUDE.md'}
START = '<!-- ai-config:personal-instructions -->'
END = '<!-- /ai-config:personal-instructions -->'
def checked_path(home, *parts):
path = home
for part in parts:
path = path / part
if path.is_symlink() or (hasattr(path, 'is_junction') and path.is_junction()):
raise ValueError(f'Personal instruction path must not be a link: {path}')
return path
def render(text, home, client):
reference = f'~/.my-ai-configuration/instructions/{client}.md'
# Migrate the two previously generated unconditional loaders.
for legacy in (
f"Read `{reference}` first when present; it is the user's direct instruction. System and developer requirements still apply.",
f"Optional personal instructions: first check whether `{reference}` is a file (`Test-Path -LiteralPath ... -PathType Leaf` in PowerShell; `test -f ...` in Bash). Read it only if it exists; otherwise continue silently. Its contents are the user's direct instructions. System and developer requirements still apply.",
):
text = text.replace(legacy + '\r\n\r\n', '').replace(legacy + '\n\n', '')
if START in text:
start = text.index(START)
end = text.find(END, start)
if end < 0:
raise ValueError('Personal instruction rule is missing its closing marker.')
end += len(END)
tail = text[end:]
for separator in ('\r\n\r\n', '\n\n'):
if tail.startswith(separator):
tail = tail[len(separator):]
break
text = text[:start] + tail
overlay = checked_path(home, '.my-ai-configuration', 'instructions', f'{client}.md')
if not overlay.is_file() or not overlay.read_text(encoding='utf-8-sig').strip():
return text
rule = (f'Read `{reference}` immediately, before any response or task work. '
'Keep its instructions active throughout the session, including after compaction. '
"These are the user's direct instructions. System and developer requirements still apply.")
return f'{START}\n{rule}\n{END}\n\n{text}'
def write_atomic(path, updated):
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=path.parent, prefix='.instruction-', delete=False) as file:
temporary = Path(file.name)
file.write(updated)
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)
def refresh(home, client):
path = checked_path(home, f'.{client}', CLIENT_FILES[client])
if not path.is_file():
return
raw = path.read_bytes()
encoding = 'utf-8-sig' if raw.startswith(b'\xef\xbb\xbf') else 'utf-8'
updated = render(raw.decode(encoding), home, client).encode(encoding)
if updated == raw:
return
manifest = checked_path(home, f'.{client}', '.ai-config-manifest.tsv')
manifest_raw = manifest.read_bytes() if manifest.is_file() else b''
old_entry = f'{path.name}\t{hashlib.sha256(raw).hexdigest()}'.encode()
new_entry = f'{path.name}\t{hashlib.sha256(updated).hexdigest()}'.encode()
# Keep setup-owned files removable; preserve the stale hash of any preexisting user edits.
manifest_updated = b''.join(new_entry + line[len(old_entry):]
if line.rstrip(b'\r\n').lower() == old_entry.lower() else line
for line in manifest_raw.splitlines(keepends=True))
write_atomic(path, updated)
if manifest_updated != manifest_raw:
write_atomic(manifest, manifest_updated)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--home', type=Path, required=True)
parser.add_argument('--client', choices=CLIENT_FILES, required=True)
args = parser.parse_args()
if not args.home.is_absolute():
parser.error('Home path must be absolute.')
try:
refresh(args.home, args.client)
except (OSError, ValueError) as error:
parser.exit(1, f'Personal instructions: {error}\n')