fix(instructions): add startup rule only for existing custom instructions
This commit is contained in:
1 parent
6a981bd7f2
commit
83091d247a
7 files changed
+114
-6
No files matched your search
@@ -5,6 +5,8 @@ import re
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
from personal_instructions import CLIENT_FILES, render
|
||||
|
||||
|
||||
def is_flashbang(hook):
|
||||
return any(re.search(r'(?:[/\\\s"\x27]|^)flashbang\.(?:ps1|sh)(?:[\s"\x27]|$)', str(hook.get(key, '')), re.I)
|
||||
@@ -229,12 +231,16 @@ if __name__ == '__main__':
|
||||
parser.add_argument('--semantic-retrieval', choices=('true', 'false'), default='true')
|
||||
parser.add_argument('--managed', type=Path)
|
||||
parser.add_argument('--output', type=Path)
|
||||
parser.add_argument('--destination', type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.action == 'filter':
|
||||
result = filter_options(args.path, args.flashbang == 'true', args.statusline == 'true', args.semantic_retrieval == 'true')
|
||||
elif args.action == 'filter-instructions':
|
||||
result = filter_instructions(args.path.read_text(encoding='utf-8-sig'), args.semantic_retrieval == 'true')
|
||||
if args.destination and args.path.name in CLIENT_FILES.values():
|
||||
client = next(client for client, name in CLIENT_FILES.items() if name == args.path.name)
|
||||
result = render(result, args.destination.parent, client)
|
||||
else:
|
||||
managed = args.managed.read_text(encoding='utf-8-sig') if args.managed else sys.stdin.buffer.read().decode('utf-8-sig')
|
||||
current = args.current.read_text(encoding='utf-8-sig')
|
||||
|
||||
@@ -233,9 +233,9 @@ function Invoke-ManagedSync {
|
||||
$statusLineOption = if ($StatusLineEnabled) { 'true' } else { 'false' }
|
||||
$rawContent = Invoke-InstallOptions -Arguments @('filter', '--path', $_.FullName, '--flashbang', $flashbangOption, '--statusline', $statusLineOption, '--semantic-retrieval', $(if ($SemanticRetrievalEnabled) { 'true' } else { 'false' })) -ErrorMessage 'Could not configure install options.'
|
||||
}
|
||||
$filterInstructions = (-not $SemanticRetrievalEnabled) -and $relative -in @('CLAUDE.md', 'AGENTS.md', 'rules/general.md')
|
||||
$filterInstructions = $relative -in @('CLAUDE.md', 'AGENTS.md') -or ((-not $SemanticRetrievalEnabled) -and $relative -eq 'rules/general.md')
|
||||
if ($filterInstructions) {
|
||||
$rawContent = Invoke-InstallOptions -Arguments @('filter-instructions', '--path', $_.FullName, '--semantic-retrieval', 'false') -ErrorMessage 'Could not configure install options.'
|
||||
$rawContent = Invoke-InstallOptions -Arguments @('filter-instructions', '--path', $_.FullName, '--destination', $Destination, '--semantic-retrieval', $(if ($SemanticRetrievalEnabled) { 'true' } else { 'false' })) -ErrorMessage 'Could not configure install options.'
|
||||
}
|
||||
$needsSubstitution = $filterOptions -or $filterInstructions -or $rawContent.Contains('__AI_CONFIG_ROOT__') -or $rawContent.Contains('__HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_HOOK_COMMAND__') -or $rawContent.Contains('__POWERSHELL_COMMAND__') -or $rawContent.Contains('__CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY__')
|
||||
if ($needsSubstitution) {
|
||||
|
||||
@@ -215,8 +215,8 @@ sys.stdout.buffer.write(("\n".join(lines) + "\n").encode("utf-8"))
|
||||
content=${content%$'\034'}
|
||||
needs_sub=true
|
||||
fi
|
||||
if [ "$semantic_retrieval_enabled" = false ] && [[ "$relative" = CLAUDE.md || "$relative" = AGENTS.md || "$relative" = rules/general.md ]]; then
|
||||
content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter-instructions --path "$source_file" --semantic-retrieval false && printf '\034') || return
|
||||
if [[ "$relative" = CLAUDE.md || "$relative" = AGENTS.md ]] || { [ "$semantic_retrieval_enabled" = false ] && [ "$relative" = rules/general.md ]; }; then
|
||||
content=$(python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" filter-instructions --path "$source_file" --destination "$destination" --semantic-retrieval "$semantic_retrieval_enabled" && printf '\034') || return
|
||||
content=${content%$'\034'}
|
||||
needs_sub=true
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""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')
|
||||
@@ -11,6 +11,7 @@ import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT / 'scripts/lib'))
|
||||
SPEC = importlib.util.spec_from_file_location('install_options', ROOT / 'scripts/lib/install-options.py')
|
||||
OPTIONS = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(OPTIONS)
|
||||
|
||||
@@ -21,6 +21,10 @@ class UserCustomizationTests(unittest.TestCase):
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn('with-credential.ps1', (ROOT / 'generated/codex-powershell/AGENTS.md').read_text())
|
||||
self.assertIn('with-credential.sh', (ROOT / 'generated/claude-bash/CLAUDE.md').read_text())
|
||||
for client, name in (('codex', 'AGENTS.md'), ('claude', 'CLAUDE.md')):
|
||||
for shell in ('powershell', 'bash'):
|
||||
self.assertNotIn('.my-ai-configuration/instructions/',
|
||||
(ROOT / 'generated' / f'{client}-{shell}' / name).read_text())
|
||||
|
||||
def test_add_instruction_creates_client_overlays_outside_managed_configuration(self):
|
||||
powershell = shutil.which('powershell') or shutil.which('pwsh')
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Global Instructions
|
||||
|
||||
Optional personal instructions: first check whether `~/.my-ai-configuration/instructions/__CLIENT__.md` 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.
|
||||
|
||||
For credentials, run the child command through `~/.my-ai-configuration/bin/with-credential.__SHELL__` with the client and credential key. Never expose credentials in commands, output, logs, or files.
|
||||
|
||||
## Loading rules
|
||||
|
||||
Reference in new issue
Block a user