"""Merge portable Laya review hooks into a local Claude settings file.""" import argparse,datetime,json,os,shutil,tempfile from pathlib import Path from urllib.parse import urlsplit HERE=Path(__file__).resolve().parent def main(): p=argparse.ArgumentParser();p.add_argument('--claude-dir',type=Path,default=Path.home()/'.claude');p.add_argument('--api-key-env');p.add_argument('--remove-foreign-linux-hooks',action='store_true');a=p.parse_args() root=a.claude_dir.expanduser().resolve();settings=root/'settings.json';before=settings.read_bytes();data=json.loads(before) base=data.get('env',{}).get('ANTHROPIC_BASE_URL','');u=urlsplit(base) if u.scheme not in ('http','https') or not u.hostname or u.username or u.password or u.query or u.fragment:raise SystemExit('Expected existing LiteLLM ANTHROPIC_BASE_URL without embedded credentials') folder=root/'laya';folder.mkdir(exist_ok=True,mode=0o700);folder.chmod(0o700) for name in ('hook.py','global_hook.py','doctor.py'):shutil.copy2(HERE/name,folder/name) shutil.copy2(HERE.parents[1]/'questions.json',folder/'questions.json') credential_env=a.api_key_env or next((name for name in ('ANTHROPIC_AUTH_TOKEN','ANTHROPIC_API_KEY','LITELLM_API_KEY') if data.get('env',{}).get(name)),'ANTHROPIC_AUTH_TOKEN') import re if not re.fullmatch(r'[A-Z_][A-Z0-9_]*',credential_env):raise SystemExit('Invalid credential variable name') config={'endpoint':u.scheme+'://'+u.netloc+'/v1/systemone','model':'laya-devops-decisions','api_key_env':credential_env,'environment':'stage','minimum_allow_probability':.9,'timeout_seconds':8,'state_dir':'~/.claude/laya/state','allow_behavior':'keep_native_claude_permissions'} # Runtime location remains portable between the Mac and /home/... on the VM. if root!=Path.home()/'.claude':config['state_dir']=str(folder/'state') target=folder/'config.json' if target.exists(): shutil.copy2(target,folder/('config.backup-'+datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f')+'.json')) target.write_text(json.dumps(config,indent=2)+'\n');target.chmod(0o600) command='python3 "$HOME/.claude/laya/global_hook.py" || { echo "Laya hook failed; action blocked." >&2; exit 2; }' if root!=Path.home()/'.claude': import shlex command='python3 '+shlex.quote(str(folder/'global_hook.py'))+' || exit 2' hooks=data.setdefault('hooks',{});removed=[] if a.remove_foreign_linux_hooks: import sys if sys.platform!='darwin':raise SystemExit('Linux hook cleanup is only intended for this Mac') for event,groups in list(hooks.items()): keep=[] for group in groups: entries=[] for h in group.get('hooks',[]): existing_command=h.get('command','') if any('/home/example-user/.claude/'+name in existing_command for name in ('claude-signal.py','claude-signal-reply.py','notify.js')):removed.append({'event':event,'command':existing_command}) else:entries.append(h) if entries:keep.append({**group,'hooks':entries}) if keep:hooks[event]=keep else:hooks.pop(event) for event,matcher in [('UserPromptSubmit',None),('PreToolUse','Bash')]: groups=hooks.setdefault(event,[]) if any('laya/global_hook.py' in h.get('command','') for g in groups for h in g.get('hooks',[])):continue group={'hooks':[{'type':'command','command':command,'timeout':15}]} if matcher:group['matcher']=matcher groups.append(group) if settings.read_bytes()!=before:raise SystemExit('Settings changed concurrently; no overwrite') backup=root/('settings.backup-before-laya-'+datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f')+'.json') fd=os.open(backup,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) with os.fdopen(fd,'wb') as f:f.write(before) fd,temp=tempfile.mkstemp(dir=root,prefix='.settings-laya-',suffix='.tmp') with os.fdopen(fd,'w') as f:json.dump(data,f,ensure_ascii=False,indent=2);f.write('\n') os.replace(temp,settings) print(json.dumps({'settings':str(settings),'backup':str(backup),'runtime':str(folder),'endpoint':config['endpoint'],'model':config['model'],'credentials_copied':False,'removed_foreign_linux_hooks':len(removed)})) if __name__=='__main__':main()