From 8d5e4d37554b0b6754a614fc756d4516bb889890 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:24:15 +0100 Subject: [PATCH 1/3] chore(agent-setup): centralize shared agent config --- .agents/AGENTS.md | 243 +++++++++++++++++++ .agents/README.md | 127 ++++++++++ .agents/config.json | 44 ++++ .agents/skills/README.md | 31 +++ .claude/settings.json | 11 - .github/workflows/ci.yml | 4 + .gitignore | 10 + AGENTS.md | 1 + CLAUDE.md | 136 +---------- CONTRIBUTING.md | 41 ++++ scripts/agents/sync-agent-shims.py | 364 +++++++++++++++++++++++++++++ scripts/codex/setup.sh | 9 + scripts/postinstall.sh | 11 + tests/test_sync_agent_shims.py | 123 ++++++++++ 14 files changed, 1009 insertions(+), 146 deletions(-) create mode 100644 .agents/AGENTS.md create mode 100644 .agents/README.md create mode 100644 .agents/config.json create mode 100644 .agents/skills/README.md delete mode 100644 .claude/settings.json create mode 120000 AGENTS.md mode change 100644 => 120000 CLAUDE.md create mode 100755 scripts/agents/sync-agent-shims.py create mode 100755 scripts/codex/setup.sh create mode 100755 scripts/postinstall.sh create mode 100644 tests/test_sync_agent_shims.py diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 000000000..618badbba --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,243 @@ +# Codex Guidelines for Langfuse Python + +This is the canonical root agent guide for the repo. The root `AGENTS.md` +should remain only as a discovery symlink so tools that require that filename +continue to work while `.agents/` stays the source of truth. + +Langfuse Python SDK guidance for fast, safe code changes. + +## Maintenance Contract + +- `AGENTS.md` is a living document. +- Update this file in the same PR when repo-level architecture, workflows, + verification requirements, release processes, or agent setup conventions + materially change. +- Update this file when user feedback adds a durable repo-level instruction that + future agents should follow. +- Keep root guidance concise, specific, and easy to verify. If repo-wide + guidance grows large or becomes task-specific, move that detail into shared + skills or future nested `AGENTS.md` files closer to the relevant code. +- If no durable guidance changed, do not edit AGENTS files. + +## Project Overview + +This repository contains the Langfuse Python SDK, a client library for +accessing the Langfuse observability platform. The SDK integrates with +OpenTelemetry for tracing, provides automatic instrumentation for popular LLM +frameworks, and exposes a generated API client for the Langfuse platform. + +## Project Structure + +```text +langfuse-python/ +├─ langfuse/_client/ # Core SDK implementation built on OpenTelemetry +├─ langfuse/api/ # Generated Fern API client (do not hand-edit) +├─ langfuse/_task_manager/ # Background upload and ingestion helpers +├─ langfuse/langchain/ # LangChain integration +├─ tests/ # Test suite +├─ static/ # Test fixtures and sample content +├─ scripts/ # Repo scripts +└─ .agents/ # Canonical shared agent instructions and config +``` + +High-signal entry points: + +- `langfuse/_client/client.py`: core Langfuse client with OTel integration +- `langfuse/_client/span.py`: observation/span abstractions +- `langfuse/_client/observe.py`: decorator-based instrumentation +- `langfuse/openai.py`: OpenAI instrumentation helpers +- `langfuse/langchain/CallbackHandler.py`: LangChain integration +- `langfuse/api/`: generated API surface copied from the main Langfuse repo + +## Instruction Design + +- Root `AGENTS.md` should cover durable repo-wide expectations only: setup, + verification, architecture, security, generated files, and handoff rules. +- Prefer concrete, testable instructions over vague phrasing. Name the exact + command, path, module, or condition whenever possible. +- Keep stable tone/role guidance separate from task-specific examples. For + complex prompts or reusable workflows, place examples in skills or referenced + docs instead of bloating the root guide. +- Add nearby nested guidance only when a subdirectory truly needs different + rules. Put the override as close as possible to the specialized code. +- Use shared skills for recurring task-specific workflows that should not be + loaded into context on every task. + +## Build, Test, and Development Commands + +- Agent environment bootstrap: `bash scripts/codex/setup.sh` +- Install dependencies: `poetry install --all-extras` +- Sync generated agent shims: `python3 scripts/agents/sync-agent-shims.py` +- Verify generated agent shims: `python3 scripts/agents/sync-agent-shims.py --check` +- Install pre-commit hooks: `poetry run pre-commit install` +- Run all tests: `poetry run pytest -s -v --log-cli-level=INFO` +- Run tests in parallel: `poetry run pytest -s -v --log-cli-level=INFO -n auto` +- Run one test: `poetry run pytest -s -v --log-cli-level=INFO tests/test_core_sdk.py::test_flush` +- Format code: `poetry run ruff format .` +- Lint code: `poetry run ruff check .` +- Type-check: `poetry run mypy langfuse --no-error-summary` +- Run pre-commit across the repo: `poetry run pre-commit run --all-files` +- Build package: `poetry build` +- Generate docs: `poetry run pdoc -o docs/ --docformat google --logo "https://langfuse.com/langfuse_logo.svg" langfuse` + +Minimum verification matrix: + +| Change scope | Minimum verification | +| --- | --- | +| `langfuse/_client/**` | `poetry run ruff check .` + `poetry run mypy langfuse --no-error-summary` + targeted pytest coverage | +| `langfuse/api/**` | verify source update path from main repo + `poetry run ruff format .` + targeted API tests | +| Integration modules (`langfuse/openai.py`, `langfuse/langchain/**`) | targeted tests for the touched integration + lint + latest official provider docs review if behavior or API usage changed | +| Test-only changes | targeted pytest coverage for the updated tests | +| Agent setup files (`.agents/**`, `scripts/agents/**`, `scripts/codex/**`) | `python3 scripts/agents/sync-agent-shims.py` + `python3 scripts/agents/sync-agent-shims.py --check` + `poetry run pytest tests/test_sync_agent_shims.py` | + +CI notes: + +- Linting runs via `astral-sh/ruff-action`. +- Type checking runs on Python 3.13 with Poetry, `.venv` caching, and the agent + shim sync/check step. +- The main test matrix runs on Python 3.10 through 3.14. +- Integration CI clones the main `langfuse/langfuse` repo, boots Dockerized + services, seeds the server with `pnpm`, and then runs this SDK's pytest suite + against that local server. +- If a change plausibly depends on server behavior, call out whether it was only + covered by unit tests locally and whether full CI is the real end-to-end + verification path. + +## Architecture + +### Core Components + +- `langfuse/_client/`: main SDK implementation built on OpenTelemetry + - `client.py`: core Langfuse client + - `span.py`: span, generation, and event classes + - `observe.py`: decorator for automatic instrumentation + - `datasets.py`: dataset management functionality +- `langfuse/api/`: auto-generated Fern API client +- `langfuse/_task_manager/`: background processing for uploads and ingestion +- `langfuse/openai.py`: OpenAI instrumentation +- `langfuse/langchain/`: LangChain integration + +### Key Design Patterns + +- The SDK is built on OpenTelemetry for observability. +- Spans are the core tracing primitive. +- Attributes carry trace metadata. See `LangfuseOtelSpanAttributes`. +- The client batches work and flushes asynchronously to the Langfuse API. + +## Generated Files + +- `langfuse/api/**` is generated from the main Langfuse repo. Do not edit it by + hand unless the task is explicitly about generated client updates. +- `docs/` output from `pdoc` is generated. Regenerate it instead of editing + rendered output directly. +- Agent/tool shims at `.mcp.json`, `.claude/settings.json`, `.claude/skills/*`, + `.cursor/mcp.json`, `.cursor/environment.json`, `.vscode/mcp.json`, + `.codex/config.toml`, and `.codex/environments/environment.toml` are local + generated artifacts. Update `.agents/config.json` or `.agents/skills/**` + instead of editing them by hand. +- `AGENTS.md` and `CLAUDE.md` at the repo root are compatibility symlinks. Edit + `.agents/AGENTS.md`, not the symlink target path directly. + +## Configuration + +Environment variables are defined in +`langfuse/_client/environment_variables.py`. + +Common ones: + +- `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY`: API credentials +- `LANGFUSE_HOST`: API endpoint, defaults to `https://cloud.langfuse.com` +- `LANGFUSE_DEBUG`: enable debug logging +- `LANGFUSE_TRACING_ENABLED`: enable or disable tracing +- `LANGFUSE_SAMPLE_RATE`: sampling rate for traces + +Security/config notes: + +- Keep credentials and machine-specific secrets in environment variables or + local untracked files, never in committed agent config. +- The shared Claude settings intentionally deny reading `./.env` and + `./.env.*`. If a task genuinely requires inspecting local env overrides, get + explicit user approval first instead of weakening the default policy. +- For authenticated MCP servers or provider-specific config additions, prefer + secret injection via environment variables rather than committed inline + tokens. + +## Testing Guidelines + +- Keep tests independent and parallel-safe. +- Do not weaken or delete meaningful assertions just to make tests pass. +- When fixing a bug, write or update the regression test first when feasible. +- E2E tests involving external APIs are often skipped in CI. Document when + manual coverage is still needed. +- Use `respx` and `pytest-httpserver` for HTTP mocking when possible. +- Prefer the narrowest useful test invocation first, then widen coverage when a + change touches shared tracing, batching, or provider integrations. + +## API Generation + +The `langfuse/api/` directory is generated from the Langfuse OpenAPI +specification via Fern. + +Update flow: + +1. Generate the Python SDK in the main `langfuse/langfuse` repo. +2. Copy the generated files from `generated/python` into `langfuse/api/`. +3. Run `poetry run ruff format .`. +4. Run targeted verification for any touched endpoints or types. + +## Release Guidelines + +- Releases are automated via GitHub Actions. +- The release workflow updates `pyproject.toml` and `langfuse/version.py`, + builds the package, publishes to PyPI, and creates a GitHub release. +- Do not change release/versioning flow without updating this file and + `CONTRIBUTING.md`. + +## Agent-specific Notes + +- `.agents/AGENTS.md` is the canonical root guide. +- Root `AGENTS.md` is a symlink to `.agents/AGENTS.md`. +- Root `CLAUDE.md` is a compatibility symlink to `AGENTS.md`. +- Shared agent/tool config lives in `.agents/config.json`. +- Shared agent setup documentation lives in `.agents/README.md`. +- Shared skills live under `.agents/skills/`. +- `python3 scripts/agents/sync-agent-shims.py` regenerates tool-specific config + shims for Claude, Cursor, VS Code, Codex, and shared MCP discovery files. +- Tool-specific directories such as `.claude/`, `.cursor/`, `.codex/`, and + `.vscode/` remain because those tools discover project settings from fixed + paths. +- Cursor discovery should continue to work through the generated + `.cursor/environment.json` and `.cursor/mcp.json` shims plus the root + `AGENTS.md` symlink. Do not hand-edit those generated files. +- This file should stay concise. Anthropic recommends keeping persistent project + memory under roughly 200 lines, and both Anthropic and OpenAI guidance favor + specific, well-structured instructions over long prose. +- If future `.cursor/rules/*.mdc` files are added, keep them as thin wrappers + around shared `AGENTS.md` guidance or shared skills instead of making them the + only source of durable repo guidance. +- Shared skill index: [`skills/README.md`](skills/README.md) +- When changing OpenAI or Anthropic integrations, prompts, or documented usage: + check the latest official provider docs first, keep prompts simple and direct, + preserve clear separation between stable instructions and task-specific + examples, and mention any provider-facing verification you did not run. + +Official references to start from: + +- OpenAI AGENTS guide: +- OpenAI prompting guide: +- OpenAI reasoning best practices: +- Anthropic Claude Code memory guide: +- Anthropic Claude Code MCP guide: +- Anthropic prompting best practices: + +## Git Notes + +- Do not use destructive git commands such as `reset --hard` unless explicitly + requested. +- Do not revert unrelated working tree changes. +- Keep commits focused and atomic. + +## Python Code Rules + +- Exception messages must not inline f-string literals directly in the `raise`. + Assign the string to a variable first if formatting is required. diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 000000000..adabb0bb4 --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,127 @@ +# Shared Agent Setup + +This directory is the neutral, repo-owned source of truth for agent behavior in +Langfuse Python. + +Use `.agents/` for configuration and guidance that should apply across tools. +Do not put durable shared guidance only in `.claude/`, `.codex/`, `.cursor/`, +or `.vscode/`. + +## Layout + +- `AGENTS.md`: canonical shared root instructions +- `config.json`: shared bootstrap and MCP configuration used to generate + tool-specific shims +- `skills/`: shared, tool-neutral implementation guidance for recurring + workflows + +## `config.json` + +`.agents/config.json` contains five kinds of data: + +- `shared`: defaults used across tools +- `mcpServers`: project MCP servers and how to connect to them +- `claude`: Claude-specific generated settings inputs +- `codex`: Codex-specific generated settings inputs +- `cursor`: Cursor-specific generated settings inputs + +Current shape: + +```json +{ + "shared": { + "setupScript": "bash scripts/codex/setup.sh", + "devCommand": "poetry run bash", + "devTerminalDescription": "Interactive development shell inside the Poetry environment" + }, + "mcpServers": { + "langfuse-docs": { + "transport": "http", + "url": "https://langfuse.com/api/mcp" + } + }, + "claude": { + "settings": {} + }, + "codex": { + "environment": { + "version": 1, + "name": "langfuse-python" + } + }, + "cursor": { + "environment": { + "agentCanUpdateSnapshot": false + } + } +} +``` + +## How Shims Are Generated + +`scripts/agents/sync-agent-shims.py` reads `.agents/config.json` and writes the +tool discovery files that those products require. + +Generated local artifacts: + +- `.claude/settings.json` +- `.claude/skills/*` +- `.cursor/environment.json` +- `.cursor/mcp.json` +- `.vscode/mcp.json` +- `.mcp.json` +- `.codex/config.toml` +- `.codex/environments/environment.toml` + +The repo root discovery files remain committed as symlinks: + +- `AGENTS.md` -> `.agents/AGENTS.md` +- `CLAUDE.md` -> `AGENTS.md` + +This keeps provider discovery stable while `.agents/` remains the source of +truth. + +## When To Edit `config.json` + +Edit `.agents/config.json` when you need to: + +- add, remove, or update a shared MCP server +- change the shared setup/bootstrap command +- change the default terminal command or terminal label used by generated shims +- adjust generated Claude, Cursor, or Codex settings that are intentionally + modeled in the shared config + +Do not edit generated shim files by hand. Edit the canonical files in +`.agents/` instead. + +## Workflow + +After editing `.agents/config.json` or `.agents/skills/**`: + +1. Run `python3 scripts/agents/sync-agent-shims.py` +2. Run `python3 scripts/agents/sync-agent-shims.py --check` +3. Run `poetry run pytest tests/test_sync_agent_shims.py` +4. Verify you did not stage generated files under `.claude/skills/` or any of + the generated MCP/runtime config paths +5. Update `.agents/AGENTS.md` or `CONTRIBUTING.md` if the shared workflow + materially changed + +`bash scripts/postinstall.sh` runs the sync/check flow as a convenience helper, +and `bash scripts/codex/setup.sh` uses it during agent environment bootstrap. + +## Adding Shared Skills + +Shared skills live under `.agents/skills/`. + +Use them for durable, reusable guidance such as: + +- repeated SDK maintenance workflows +- review checklists that should apply across tools +- repo-specific runbooks that should not live only in a provider-specific folder + +Do not use skills for one-off notes or tool runtime configuration. + +`python3 scripts/agents/sync-agent-shims.py` projects shared skills into +`.claude/skills/` so Claude can discover the same repo-owned skills. + +For the skill authoring workflow, see [skills/README.md](skills/README.md). diff --git a/.agents/config.json b/.agents/config.json new file mode 100644 index 000000000..555570393 --- /dev/null +++ b/.agents/config.json @@ -0,0 +1,44 @@ +{ + "shared": { + "setupScript": "bash scripts/codex/setup.sh", + "devCommand": "poetry run bash", + "devTerminalDescription": "Interactive development shell inside the Poetry environment" + }, + "mcpServers": { + "langfuse-docs": { + "transport": "http", + "url": "https://langfuse.com/api/mcp" + } + }, + "claude": { + "settings": { + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(rg:*)", + "Bash(grep:*)", + "Bash(ls:*)", + "Bash(cat:*)", + "Bash(head:*)", + "Bash(tail:*)" + ], + "deny": [ + "Read(./.env)", + "Read(./.env.*)" + ] + }, + "enableAllProjectMcpServers": true + } + }, + "codex": { + "environment": { + "version": 1, + "name": "langfuse-python" + } + }, + "cursor": { + "environment": { + "agentCanUpdateSnapshot": false + } + } +} diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 000000000..fc665be21 --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,31 @@ +# Shared Skills + +Shared repo skills for any coding agent working in Langfuse Python. + +Use these from `AGENTS.md`. Claude Code reaches the same shared instructions via +the root `CLAUDE.md` compatibility symlink. Shared skills should stay focused on +reusable implementation guidance rather than runtime automation. + +For the shared agent config and generated shim model, start with +[`../README.md`](../README.md). + +Shared skills should use progressive disclosure: + +- keep `SKILL.md` short +- link to focused `references/` docs instead of copying long guidance into one + file +- add helper scripts only when they materially reduce repeated work + +There are no repo-level shared skills yet. Add one when a workflow is repeated +often enough that it should be standardized across tools. + +## Adding a New Shared Skill + +1. Create a folder under `.agents/skills//`. +2. Add a short `SKILL.md` entrypoint. +3. Add `references/` docs or helper scripts only when they are needed. +4. Keep the skill tightly scoped to one domain or workflow. +5. Link the skill from `.agents/AGENTS.md` if it is broadly relevant. +6. Run `python3 scripts/agents/sync-agent-shims.py`. +7. Run `python3 scripts/agents/sync-agent-shims.py --check`. +8. Run `poetry run pytest tests/test_sync_agent_shims.py`. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 43ef52f6b..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(find:*)", - "Bash(rg:*)", - "Bash(grep:*)", - "Bash(ls:*)" - ], - "deny": [] - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 113733a84..5acdb2c7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,10 @@ jobs: uses: actions/setup-python@v4 with: python-version: "3.13" + - name: Verify agent shim generation + run: | + python3 scripts/agents/sync-agent-shims.py + python3 scripts/agents/sync-agent-shims.py --check - name: Install poetry uses: abatilo/actions-poetry@v2 - name: Setup a local virtual environment diff --git a/.gitignore b/.gitignore index bebafdd05..1054e4207 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,13 @@ docs tests/mocks/llama-index-storage *.local.* + +# Agent shims generated from .agents/config.json +.mcp.json +.claude/settings.json +.claude/skills/ +.cursor/mcp.json +.cursor/environment.json +.vscode/mcp.json +.codex/config.toml +.codex/environments/environment.toml diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 000000000..41280d2fa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +.agents/AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 6dc8afbb2..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,135 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -This is the Langfuse Python SDK, a client library for accessing the Langfuse observability platform. The SDK provides integration with OpenTelemetry (OTel) for tracing, automatic instrumentation for popular LLM frameworks (OpenAI, Langchain, etc.), and direct API access to Langfuse's features. - -## Development Commands - -### Setup -```bash -# Install Poetry plugins (one-time setup) -poetry self add poetry-dotenv-plugin - -# Install all dependencies including optional extras -poetry install --all-extras - -# Setup pre-commit hooks -poetry run pre-commit install -``` - -### Testing -```bash -# Run all tests with verbose output -poetry run pytest -s -v --log-cli-level=INFO - -# Run a specific test -poetry run pytest -s -v --log-cli-level=INFO tests/test_core_sdk.py::test_flush - -# Run tests in parallel (faster) -poetry run pytest -s -v --log-cli-level=INFO -n auto -``` - -### Code Quality -```bash -# Format code with Ruff -poetry run ruff format . - -# Run linting (development config) -poetry run ruff check . - -# Run type checking -poetry run mypy . - -# Run pre-commit hooks manually -poetry run pre-commit run --all-files -``` - -### Building and Releasing -```bash -# Build the package locally (for testing) -poetry build - -# Generate documentation -poetry run pdoc -o docs/ --docformat google --logo "https://langfuse.com/langfuse_logo.svg" langfuse -``` - -Releases are automated via GitHub Actions. To release: -1. Go to Actions > "Release Python SDK" workflow -2. Click "Run workflow" -3. Select version bump type (patch/minor/major/prerelease) -4. For prereleases, select the type (alpha/beta/rc) - -The workflow handles versioning, building, PyPI publishing (via OIDC), and GitHub release creation. - -## Architecture - -### Core Components - -- **`langfuse/_client/`**: Main SDK implementation built on OpenTelemetry - - `client.py`: Core Langfuse client with OTel integration - - `span.py`: LangfuseSpan, LangfuseGeneration, LangfuseEvent classes - - `observe.py`: Decorator for automatic instrumentation - - `datasets.py`: Dataset management functionality - -- **`langfuse/api/`**: Auto-generated Fern API client - - Contains all API resources and types - - Generated from OpenAPI spec - do not manually edit these files - -- **`langfuse/_task_manager/`**: Background processing - - Media upload handling and queue management - - Score ingestion consumer - -- **Integration modules**: - - `langfuse/openai.py`: OpenAI instrumentation - - `langfuse/langchain/`: Langchain integration via CallbackHandler - -### Key Design Patterns - -The SDK is built on OpenTelemetry for observability, using: -- Spans for tracing LLM operations -- Attributes for metadata (see `LangfuseOtelSpanAttributes`) -- Resource management for efficient batching and flushing - -The client follows an async-first design with automatic batching of events and background flushing to the Langfuse API. - -## Configuration - -Environment variables (defined in `_client/environment_variables.py`): -- `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY`: API credentials -- `LANGFUSE_HOST`: API endpoint (defaults to https://cloud.langfuse.com) -- `LANGFUSE_DEBUG`: Enable debug logging -- `LANGFUSE_TRACING_ENABLED`: Enable/disable tracing -- `LANGFUSE_SAMPLE_RATE`: Sampling rate for traces - -## Testing Notes - -- Create `.env` file based on `.env.template` for integration tests -- E2E tests with external APIs (OpenAI, SERP) are typically skipped in CI -- Remove `@pytest.mark.skip` decorators in test files to run external API tests -- Tests use `respx` for HTTP mocking and `pytest-httpserver` for test servers - -## Important Files - -- `pyproject.toml`: Poetry configuration, dependencies, and tool settings -- `langfuse/version.py`: Version string (updated by CI release workflow) - -## API Generation - -The `langfuse/api/` directory is auto-generated from the Langfuse OpenAPI specification using Fern. To update: - -1. Generate new SDK in main Langfuse repo -2. Copy generated files from `generated/python` to `langfuse/api/` -3. Run `poetry run ruff format .` to format the generated code - -## Testing Guidelines - -### Approach to Test Changes -- Don't remove functionality from existing unit tests just to make tests pass. Only change the test, if underlying code changes warrant a test change. - -## Python Code Rules - -### Exception Handling -- Exception must not use an f-string literal, assign to variable first diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 14a42a10d..bde0b56ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,47 @@ poetry self add poetry-dotenv-plugin poetry install --all-extras ``` +### Shared Agent Setup + +This repository keeps the shared agent setup in source control so contributors +using Claude, Cursor, Codex, or VS Code can work against the same instructions +and MCP/bootstrap configuration. + +- Canonical shared docs: + - `.agents/AGENTS.md` +- Root discovery symlinks: + - `AGENTS.md` + - `CLAUDE.md` +- Shared agent setup overview: + - `.agents/README.md` +- Shared skills: + - `.agents/skills/` +- Shared tool/bootstrap/MCP config: + - `.agents/config.json` +- Tool-specific MCP configs generated locally from that catalog and not + committed: + - `.mcp.json` + - `.cursor/mcp.json` + - `.vscode/mcp.json` + - `.codex/config.toml` +- Tool-specific runtime shims generated locally from the shared config and not + committed: + - `.claude/settings.json` + - `.cursor/environment.json` + - `.codex/environments/environment.toml` +- Tool-specific skill projections generated locally and not committed: + - `.claude/skills/*` +- Shared bootstrap for agent environments: + - `bash scripts/codex/setup.sh` + +When you change the shared agent setup: + +1. Edit `.agents/config.json` or `.agents/skills/**` +2. Run `python3 scripts/agents/sync-agent-shims.py` +3. Run `python3 scripts/agents/sync-agent-shims.py --check` +4. Run `poetry run pytest tests/test_sync_agent_shims.py` +5. Do not commit the generated MCP config files or runtime shims + ### Add Pre-commit ``` diff --git a/scripts/agents/sync-agent-shims.py b/scripts/agents/sync-agent-shims.py new file mode 100755 index 000000000..5cb1eadd0 --- /dev/null +++ b/scripts/agents/sync-agent-shims.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +from pathlib import Path +from typing import Any + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + parser.add_argument("--repo-root", type=Path) + return parser.parse_args() + + +def load_config(repo_root: Path) -> dict[str, Any]: + source_path = repo_root / ".agents" / "config.json" + return json.loads(source_path.read_text(encoding="utf-8")) + + +def sort_object(value: dict[str, Any]) -> dict[str, Any]: + return {key: value[key] for key in sorted(value)} + + +def format_shared_json_config(servers: dict[str, Any]) -> str: + mcp_servers: dict[str, Any] = {} + + for name, server in sort_object(servers).items(): + if server["transport"] == "stdio": + mcp_servers[name] = { + "command": server["command"], + "args": server.get("args", []), + **({"env": server["env"]} if server.get("env") else {}), + } + else: + mcp_servers[name] = { + "type": "http", + "url": server["url"], + **({"headers": server["headers"]} if server.get("headers") else {}), + } + + return json.dumps({"mcpServers": mcp_servers}, indent=2) + "\n" + + +def format_vscode_config(servers: dict[str, Any]) -> str: + mcp_servers: dict[str, Any] = {} + + for name, server in sort_object(servers).items(): + if server["transport"] == "stdio": + mcp_servers[name] = { + "type": "stdio", + "command": server["command"], + "args": server.get("args", []), + **({"env": server["env"]} if server.get("env") else {}), + } + else: + mcp_servers[name] = { + "type": "http", + "url": server["url"], + **({"headers": server["headers"]} if server.get("headers") else {}), + } + + return json.dumps({"servers": mcp_servers}, indent=2) + "\n" + + +def format_codex_toml(servers: dict[str, Any]) -> str: + lines: list[str] = [] + + for name, server in sort_object(servers).items(): + lines.append(f"[mcp_servers.{name}]") + + if server["transport"] == "stdio": + lines.append(f"command = {json.dumps(server['command'])}") + + args = server.get("args", []) + if args: + lines.append("args = [") + for arg in args: + lines.append(f" {json.dumps(arg)},") + lines.append("]") + else: + lines.append("args = []") + + if server.get("env"): + lines.append(f"[mcp_servers.{name}.env]") + for env_name, env_value in sort_object(server["env"]).items(): + lines.append(f"{env_name} = {json.dumps(env_value)}") + else: + lines.append(f"url = {json.dumps(server['url'])}") + if server.get("headers"): + lines.append(f"[mcp_servers.{name}.headers]") + for header_name, header_value in sort_object(server["headers"]).items(): + lines.append( + f"{json.dumps(header_name)} = {json.dumps(header_value)}" + ) + + lines.append("") + + return "\n".join(lines) + + +def format_claude_settings(config: dict[str, Any]) -> str: + return json.dumps(config["claude"]["settings"], indent=2) + "\n" + + +def format_cursor_environment(config: dict[str, Any]) -> str: + return ( + json.dumps( + { + "agentCanUpdateSnapshot": config["cursor"]["environment"][ + "agentCanUpdateSnapshot" + ], + "install": config["shared"]["setupScript"], + "terminals": [ + { + "name": "Development Terminal", + "command": config["shared"]["devCommand"], + "description": config["shared"]["devTerminalDescription"], + } + ], + }, + indent=2, + ) + + "\n" + ) + + +def format_codex_environment_toml(config: dict[str, Any]) -> str: + lines = [ + "# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY", + f"version = {config['codex']['environment']['version']}", + f"name = {json.dumps(config['codex']['environment']['name'])}", + "", + "[setup]", + f"script = {json.dumps(config['shared']['setupScript'])}", + "", + ] + + return "\n".join(lines) + + +def write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def remove_path(path: Path) -> None: + if not path.exists() and not path.is_symlink(): + return + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + + +def is_matching_symlink(path: Path, target: Path) -> bool: + if not path.is_symlink(): + return False + return path.resolve() == target.resolve() + + +def find_unexpected_children(path: Path, expected_children: set[str]) -> list[Path]: + if not path.exists(): + return [] + return [child for child in path.iterdir() if child.name not in expected_children] + + +def print_error(message: str) -> None: + print(message, file=sys.stderr) + + +def sync_file_outputs( + file_outputs: list[dict[str, Any]], check_mode: bool +) -> tuple[bool, list[str]]: + has_mismatch = False + warnings: list[str] = [] + + for output in file_outputs: + path: Path = output["path"] + content: str = output["content"] + optional: bool = output.get("optional", False) + + if check_mode: + try: + current = path.read_text(encoding="utf-8") + except FileNotFoundError: + if optional: + warnings.append(f"Skipping optional config check: {path}") + continue + has_mismatch = True + print_error( + f'Missing generated config: {path}. Run "python3 scripts/agents/sync-agent-shims.py".' + ) + continue + + if current != content: + has_mismatch = True + print_error(f"Out of sync: {path}") + continue + + try: + write_text(path, content) + print(f"Updated {path}") + except OSError: + if optional: + warnings.append(f"Skipping optional config generation: {path}") + continue + raise + + return has_mismatch, warnings + + +def sync_symlink_outputs( + symlink_outputs: list[dict[str, Path]], check_mode: bool +) -> bool: + has_mismatch = False + + for output in symlink_outputs: + path = output["path"] + target = output["target"] + + if check_mode: + if not is_matching_symlink(path, target): + has_mismatch = True + print_error( + f'Out of sync symlink: {path}. Run "python3 scripts/agents/sync-agent-shims.py".' + ) + continue + + path.parent.mkdir(parents=True, exist_ok=True) + + if is_matching_symlink(path, target): + continue + + remove_path(path) + relative_target = Path(os.path.relpath(target, start=path.parent)) + path.symlink_to(relative_target, target_is_directory=target.is_dir()) + print(f"Linked {path}") + + return has_mismatch + + +def sync_managed_directories( + managed_directories: list[dict[str, Any]], check_mode: bool +) -> bool: + has_mismatch = False + + for directory in managed_directories: + path: Path = directory["path"] + expected_children: set[str] = directory["expected_children"] + unexpected_children = find_unexpected_children(path, expected_children) + + if not unexpected_children: + continue + + if check_mode: + has_mismatch = True + for child in unexpected_children: + print_error(f"Unexpected generated shim: {child}") + continue + + for child in unexpected_children: + remove_path(child) + print(f"Removed stale generated shim {child}") + + return has_mismatch + + +def main() -> int: + args = parse_args() + repo_root = ( + args.repo_root.resolve() + if args.repo_root is not None + else Path(__file__).resolve().parents[2] + ) + + config = load_config(repo_root) + servers = config["mcpServers"] + check_mode = args.check + + file_outputs = [ + { + "path": repo_root / ".claude" / "settings.json", + "content": format_claude_settings(config), + }, + { + "path": repo_root / ".mcp.json", + "content": format_shared_json_config(servers), + }, + { + "path": repo_root / ".codex" / "environments" / "environment.toml", + "content": format_codex_environment_toml(config), + "optional": True, + }, + { + "path": repo_root / ".cursor" / "mcp.json", + "content": format_shared_json_config(servers), + }, + { + "path": repo_root / ".cursor" / "environment.json", + "content": format_cursor_environment(config), + }, + { + "path": repo_root / ".vscode" / "mcp.json", + "content": format_vscode_config(servers), + }, + { + "path": repo_root / ".codex" / "config.toml", + "content": format_codex_toml(servers), + "optional": True, + }, + ] + + skills_root = repo_root / ".agents" / "skills" + shared_skill_names = sorted( + entry.name + for entry in skills_root.iterdir() + if entry.is_dir() and (entry / "SKILL.md").exists() + ) + + symlink_outputs = [ + { + "path": repo_root / "AGENTS.md", + "target": repo_root / ".agents" / "AGENTS.md", + }, + { + "path": repo_root / "CLAUDE.md", + "target": repo_root / "AGENTS.md", + }, + *[ + { + "path": repo_root / ".claude" / "skills" / name, + "target": skills_root / name, + } + for name in shared_skill_names + ], + ] + + managed_directories = [ + { + "path": repo_root / ".claude" / "skills", + "expected_children": set(shared_skill_names), + } + ] + + has_mismatch, warnings = sync_file_outputs(file_outputs, check_mode) + for warning in warnings: + print(warning) + + has_mismatch = sync_symlink_outputs(symlink_outputs, check_mode) or has_mismatch + has_mismatch = ( + sync_managed_directories(managed_directories, check_mode) or has_mismatch + ) + + return 1 if check_mode and has_mismatch else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/codex/setup.sh b/scripts/codex/setup.sh new file mode 100755 index 000000000..c1b00e39f --- /dev/null +++ b/scripts/codex/setup.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -euo pipefail + +poetry config virtualenvs.create true --local +poetry config virtualenvs.in-project true --local +poetry install --all-extras + +bash scripts/postinstall.sh diff --git a/scripts/postinstall.sh b/scripts/postinstall.sh new file mode 100755 index 000000000..9b4a0a089 --- /dev/null +++ b/scripts/postinstall.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ ! -f "scripts/agents/sync-agent-shims.py" ]]; then + echo "Skipping agent shim sync: scripts/agents/sync-agent-shims.py is not present in this install context." + exit 0 +fi + +python3 scripts/agents/sync-agent-shims.py +python3 scripts/agents/sync-agent-shims.py --check diff --git a/tests/test_sync_agent_shims.py b/tests/test_sync_agent_shims.py new file mode 100644 index 000000000..8cf201d34 --- /dev/null +++ b/tests/test_sync_agent_shims.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = REPO_ROOT / "scripts" / "agents" / "sync-agent-shims.py" + + +def write_file(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def make_fixture_repo(tmp_path: Path) -> Path: + repo_root = tmp_path / "repo" + write_file(repo_root / ".agents" / "AGENTS.md", "# Fixture\n") + write_file(repo_root / ".agents" / "skills" / "example" / "SKILL.md", "# Example\n") + write_file( + repo_root / ".agents" / "skills" / "README.md", + "# Skills\n", + ) + write_file( + repo_root / ".agents" / "config.json", + json.dumps( + { + "shared": { + "setupScript": "bash scripts/codex/setup.sh", + "devCommand": "poetry run bash", + "devTerminalDescription": "Interactive development shell", + }, + "mcpServers": { + "docs": { + "transport": "http", + "url": "https://langfuse.com/api/mcp", + }, + "stdio-example": { + "transport": "stdio", + "command": "npx", + "args": ["-y", "example"], + "env": {"EXAMPLE_TOKEN": "test"}, + }, + }, + "claude": { + "settings": { + "permissions": {"allow": ["Bash(rg:*)"], "deny": []}, + "enableAllProjectMcpServers": True, + } + }, + "codex": {"environment": {"version": 1, "name": "fixture"}}, + "cursor": {"environment": {"agentCanUpdateSnapshot": False}}, + }, + indent=2, + ) + + "\n", + ) + write_file(repo_root / ".claude" / "skills" / "stale" / "SKILL.md", "# Stale\n") + write_file(repo_root / ".vscode" / "settings.json", "{\n}\n") + return repo_root + + +def run_script(repo_root: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT_PATH), "--repo-root", str(repo_root), *args], + capture_output=True, + text=True, + check=False, + ) + + +def test_sync_agent_shims_generates_expected_outputs(tmp_path: Path) -> None: + repo_root = make_fixture_repo(tmp_path) + + result = run_script(repo_root) + + assert result.returncode == 0 + assert json.loads((repo_root / ".mcp.json").read_text(encoding="utf-8")) == { + "mcpServers": { + "docs": {"type": "http", "url": "https://langfuse.com/api/mcp"}, + "stdio-example": { + "command": "npx", + "args": ["-y", "example"], + "env": {"EXAMPLE_TOKEN": "test"}, + }, + } + } + assert json.loads( + (repo_root / ".cursor" / "environment.json").read_text(encoding="utf-8") + ) == { + "agentCanUpdateSnapshot": False, + "install": "bash scripts/codex/setup.sh", + "terminals": [ + { + "name": "Development Terminal", + "command": "poetry run bash", + "description": "Interactive development shell", + } + ], + } + assert (repo_root / "AGENTS.md").is_symlink() + assert (repo_root / "AGENTS.md").resolve() == ( + repo_root / ".agents" / "AGENTS.md" + ).resolve() + assert (repo_root / "CLAUDE.md").is_symlink() + assert (repo_root / "CLAUDE.md").resolve() == (repo_root / "AGENTS.md").resolve() + assert (repo_root / ".claude" / "skills" / "example").is_symlink() + assert not (repo_root / ".claude" / "skills" / "stale").exists() + assert (repo_root / ".codex" / "config.toml").exists() + assert (repo_root / ".codex" / "environments" / "environment.toml").exists() + + +def test_sync_agent_shims_check_mode_detects_drift(tmp_path: Path) -> None: + repo_root = make_fixture_repo(tmp_path) + run_script(repo_root) + write_file(repo_root / ".cursor" / "environment.json", "{}\n") + + result = run_script(repo_root, "--check") + + assert result.returncode == 1 + assert "Out of sync" in result.stderr From a7b3e5e401c1477c2ae0e29dd171e5e615032356 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:49:12 +0100 Subject: [PATCH 2/3] fix(agent-setup): address review feedback --- .agents/AGENTS.md | 3 +- .agents/config.json | 10 +----- scripts/agents/sync-agent-shims.py | 45 +++++++++++++++++++---- tests/test_sync_agent_shims.py | 58 +++++++++++++++++++++++++++--- 4 files changed, 96 insertions(+), 20 deletions(-) diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 618badbba..a609600c0 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -156,7 +156,8 @@ Security/config notes: - Keep credentials and machine-specific secrets in environment variables or local untracked files, never in committed agent config. - The shared Claude settings intentionally deny reading `./.env` and - `./.env.*`. If a task genuinely requires inspecting local env overrides, get + `./.env.*`, and they do not auto-approve Bash commands by default. If a task + genuinely requires inspecting local env overrides or shell access, get explicit user approval first instead of weakening the default policy. - For authenticated MCP servers or provider-specific config additions, prefer secret injection via environment variables rather than committed inline diff --git a/.agents/config.json b/.agents/config.json index 555570393..c2d6d62a0 100644 --- a/.agents/config.json +++ b/.agents/config.json @@ -13,15 +13,7 @@ "claude": { "settings": { "permissions": { - "allow": [ - "Bash(find:*)", - "Bash(rg:*)", - "Bash(grep:*)", - "Bash(ls:*)", - "Bash(cat:*)", - "Bash(head:*)", - "Bash(tail:*)" - ], + "allow": [], "deny": [ "Read(./.env)", "Read(./.env.*)" diff --git a/scripts/agents/sync-agent-shims.py b/scripts/agents/sync-agent-shims.py index 5cb1eadd0..1eac6a3ea 100755 --- a/scripts/agents/sync-agent-shims.py +++ b/scripts/agents/sync-agent-shims.py @@ -158,10 +158,22 @@ def remove_path(path: Path) -> None: path.unlink() +def expected_symlink_target(path: Path, target: Path) -> Path: + return Path(os.path.relpath(target, start=path.parent)) + + def is_matching_symlink(path: Path, target: Path) -> bool: if not path.is_symlink(): return False - return path.resolve() == target.resolve() + return Path(os.readlink(path)) == expected_symlink_target(path, target) + + +def is_within_repo(path: Path, repo_root: Path) -> bool: + try: + path.resolve().relative_to(repo_root.resolve()) + return True + except ValueError: + return False def find_unexpected_children(path: Path, expected_children: set[str]) -> list[Path]: @@ -238,21 +250,41 @@ def sync_symlink_outputs( continue remove_path(path) - relative_target = Path(os.path.relpath(target, start=path.parent)) - path.symlink_to(relative_target, target_is_directory=target.is_dir()) + path.symlink_to( + expected_symlink_target(path, target), target_is_directory=target.is_dir() + ) print(f"Linked {path}") return has_mismatch def sync_managed_directories( - managed_directories: list[dict[str, Any]], check_mode: bool + managed_directories: list[dict[str, Any]], repo_root: Path, check_mode: bool ) -> bool: has_mismatch = False for directory in managed_directories: path: Path = directory["path"] expected_children: set[str] = directory["expected_children"] + + if path.is_symlink(): + message = f"Managed generated shim directory must not be a symlink: {path}" + if check_mode: + has_mismatch = True + print_error(message) + continue + raise RuntimeError(message) + + if path.exists() and not is_within_repo(path, repo_root): + message = ( + f"Managed generated shim directory resolves outside the repository: {path}" + ) + if check_mode: + has_mismatch = True + print_error(message) + continue + raise RuntimeError(message) + unexpected_children = find_unexpected_children(path, expected_children) if not unexpected_children: @@ -319,7 +351,7 @@ def main() -> int: skills_root = repo_root / ".agents" / "skills" shared_skill_names = sorted( entry.name - for entry in skills_root.iterdir() + for entry in (skills_root.iterdir() if skills_root.exists() else []) if entry.is_dir() and (entry / "SKILL.md").exists() ) @@ -354,7 +386,8 @@ def main() -> int: has_mismatch = sync_symlink_outputs(symlink_outputs, check_mode) or has_mismatch has_mismatch = ( - sync_managed_directories(managed_directories, check_mode) or has_mismatch + sync_managed_directories(managed_directories, repo_root, check_mode) + or has_mismatch ) return 1 if check_mode and has_mismatch else 0 diff --git a/tests/test_sync_agent_shims.py b/tests/test_sync_agent_shims.py index 8cf201d34..1a7c896d2 100644 --- a/tests/test_sync_agent_shims.py +++ b/tests/test_sync_agent_shims.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -101,11 +102,9 @@ def test_sync_agent_shims_generates_expected_outputs(tmp_path: Path) -> None: ], } assert (repo_root / "AGENTS.md").is_symlink() - assert (repo_root / "AGENTS.md").resolve() == ( - repo_root / ".agents" / "AGENTS.md" - ).resolve() + assert Path(os.readlink(repo_root / "AGENTS.md")) == Path(".agents/AGENTS.md") assert (repo_root / "CLAUDE.md").is_symlink() - assert (repo_root / "CLAUDE.md").resolve() == (repo_root / "AGENTS.md").resolve() + assert Path(os.readlink(repo_root / "CLAUDE.md")) == Path("AGENTS.md") assert (repo_root / ".claude" / "skills" / "example").is_symlink() assert not (repo_root / ".claude" / "skills" / "stale").exists() assert (repo_root / ".codex" / "config.toml").exists() @@ -121,3 +120,54 @@ def test_sync_agent_shims_check_mode_detects_drift(tmp_path: Path) -> None: assert result.returncode == 1 assert "Out of sync" in result.stderr + + +def test_sync_agent_shims_check_mode_detects_symlink_target_drift(tmp_path: Path) -> None: + repo_root = make_fixture_repo(tmp_path) + run_script(repo_root) + (repo_root / "CLAUDE.md").unlink() + (repo_root / "CLAUDE.md").symlink_to(".agents/AGENTS.md") + + result = run_script(repo_root, "--check") + + assert result.returncode == 1 + assert "Out of sync symlink" in result.stderr + + +def test_sync_agent_shims_handles_missing_skills_directory(tmp_path: Path) -> None: + repo_root = make_fixture_repo(tmp_path) + readme_path = repo_root / ".agents" / "skills" / "README.md" + example_path = repo_root / ".agents" / "skills" / "example" + (example_path / "SKILL.md").unlink() + readme_path.unlink() + example_path.rmdir() + (repo_root / ".agents" / "skills").rmdir() + + result = run_script(repo_root) + + assert result.returncode == 0 + managed_dir = repo_root / ".claude" / "skills" + assert managed_dir.exists() + assert list(managed_dir.iterdir()) == [] + + +def test_sync_agent_shims_rejects_symlinked_managed_directories(tmp_path: Path) -> None: + repo_root = make_fixture_repo(tmp_path) + run_script(repo_root) + external_dir = tmp_path / "external-claude-skills" + external_dir.mkdir() + (external_dir / "foreign").mkdir() + managed_dir = repo_root / ".claude" / "skills" + for child in managed_dir.iterdir(): + child.unlink() + managed_dir.rmdir() + managed_dir.symlink_to(external_dir, target_is_directory=True) + + check_result = run_script(repo_root, "--check") + sync_result = run_script(repo_root) + + assert check_result.returncode == 1 + assert "must not be a symlink" in check_result.stderr + assert sync_result.returncode == 1 + assert "must not be a symlink" in sync_result.stderr + assert (external_dir / "foreign").exists() From 5582643e98db9f4101b88c1e0e266b619ae9b745 Mon Sep 17 00:00:00 2001 From: Hassieb Pakzad <68423100+hassiebp@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:59:33 +0100 Subject: [PATCH 3/3] refactor(agent-setup): move tooling into .agents --- .agents/AGENTS.md | 15 +- .agents/README.md | 20 +- .agents/config.json | 2 +- {scripts => .agents/scripts}/codex/setup.sh | 3 +- .agents/scripts/install.sh | 7 + .agents/scripts/postinstall.sh | 11 ++ .../scripts}/sync-agent-shims.py | 12 +- .github/workflows/ci.yml | 8 +- CONTRIBUTING.md | 13 +- scripts/postinstall.sh | 11 -- tests/test_sync_agent_shims.py | 173 ------------------ 11 files changed, 57 insertions(+), 218 deletions(-) rename {scripts => .agents/scripts}/codex/setup.sh (70%) mode change 100755 => 100644 create mode 100644 .agents/scripts/install.sh create mode 100644 .agents/scripts/postinstall.sh rename {scripts/agents => .agents/scripts}/sync-agent-shims.py (97%) mode change 100755 => 100644 delete mode 100755 scripts/postinstall.sh delete mode 100644 tests/test_sync_agent_shims.py diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index a609600c0..3c4ee07b8 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -36,7 +36,6 @@ langfuse-python/ ├─ langfuse/langchain/ # LangChain integration ├─ tests/ # Test suite ├─ static/ # Test fixtures and sample content -├─ scripts/ # Repo scripts └─ .agents/ # Canonical shared agent instructions and config ``` @@ -65,10 +64,10 @@ High-signal entry points: ## Build, Test, and Development Commands -- Agent environment bootstrap: `bash scripts/codex/setup.sh` -- Install dependencies: `poetry install --all-extras` -- Sync generated agent shims: `python3 scripts/agents/sync-agent-shims.py` -- Verify generated agent shims: `python3 scripts/agents/sync-agent-shims.py --check` +- Agent environment bootstrap: `bash .agents/scripts/codex/setup.sh` +- Install dependencies: `bash .agents/scripts/install.sh --all-extras` +- Sync generated agent shims: `python3 .agents/scripts/sync-agent-shims.py` +- Verify generated agent shims: `python3 .agents/scripts/sync-agent-shims.py --check` - Install pre-commit hooks: `poetry run pre-commit install` - Run all tests: `poetry run pytest -s -v --log-cli-level=INFO` - Run tests in parallel: `poetry run pytest -s -v --log-cli-level=INFO -n auto` @@ -88,7 +87,7 @@ Minimum verification matrix: | `langfuse/api/**` | verify source update path from main repo + `poetry run ruff format .` + targeted API tests | | Integration modules (`langfuse/openai.py`, `langfuse/langchain/**`) | targeted tests for the touched integration + lint + latest official provider docs review if behavior or API usage changed | | Test-only changes | targeted pytest coverage for the updated tests | -| Agent setup files (`.agents/**`, `scripts/agents/**`, `scripts/codex/**`) | `python3 scripts/agents/sync-agent-shims.py` + `python3 scripts/agents/sync-agent-shims.py --check` + `poetry run pytest tests/test_sync_agent_shims.py` | +| Agent setup files (`.agents/**`) | `python3 .agents/scripts/sync-agent-shims.py` + `python3 .agents/scripts/sync-agent-shims.py --check` | CI notes: @@ -202,7 +201,9 @@ Update flow: - Shared agent/tool config lives in `.agents/config.json`. - Shared agent setup documentation lives in `.agents/README.md`. - Shared skills live under `.agents/skills/`. -- `python3 scripts/agents/sync-agent-shims.py` regenerates tool-specific config +- `.agents/scripts/` is the home for repo-owned agent bootstrap and sync + tooling. +- `python3 .agents/scripts/sync-agent-shims.py` regenerates tool-specific config shims for Claude, Cursor, VS Code, Codex, and shared MCP discovery files. - Tool-specific directories such as `.claude/`, `.cursor/`, `.codex/`, and `.vscode/` remain because those tools discover project settings from fixed diff --git a/.agents/README.md b/.agents/README.md index adabb0bb4..d6366ed56 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -12,6 +12,7 @@ or `.vscode/`. - `AGENTS.md`: canonical shared root instructions - `config.json`: shared bootstrap and MCP configuration used to generate tool-specific shims +- `scripts/`: shared bootstrap and sync helpers for agent tooling - `skills/`: shared, tool-neutral implementation guidance for recurring workflows @@ -30,7 +31,7 @@ Current shape: ```json { "shared": { - "setupScript": "bash scripts/codex/setup.sh", + "setupScript": "bash .agents/scripts/codex/setup.sh", "devCommand": "poetry run bash", "devTerminalDescription": "Interactive development shell inside the Poetry environment" }, @@ -59,7 +60,7 @@ Current shape: ## How Shims Are Generated -`scripts/agents/sync-agent-shims.py` reads `.agents/config.json` and writes the +`.agents/scripts/sync-agent-shims.py` reads `.agents/config.json` and writes the tool discovery files that those products require. Generated local artifacts: @@ -99,15 +100,16 @@ Do not edit generated shim files by hand. Edit the canonical files in After editing `.agents/config.json` or `.agents/skills/**`: 1. Run `python3 scripts/agents/sync-agent-shims.py` -2. Run `python3 scripts/agents/sync-agent-shims.py --check` -3. Run `poetry run pytest tests/test_sync_agent_shims.py` -4. Verify you did not stage generated files under `.claude/skills/` or any of +2. Run `python3 .agents/scripts/sync-agent-shims.py --check` +3. Verify you did not stage generated files under `.claude/skills/` or any of the generated MCP/runtime config paths -5. Update `.agents/AGENTS.md` or `CONTRIBUTING.md` if the shared workflow +4. Update `.agents/AGENTS.md` or `CONTRIBUTING.md` if the shared workflow materially changed -`bash scripts/postinstall.sh` runs the sync/check flow as a convenience helper, -and `bash scripts/codex/setup.sh` uses it during agent environment bootstrap. +`bash .agents/scripts/install.sh --all-extras` is the canonical repo install +flow and wires `poetry install` to `bash .agents/scripts/postinstall.sh`. +`bash .agents/scripts/codex/setup.sh` uses the same path during agent +environment bootstrap. ## Adding Shared Skills @@ -121,7 +123,7 @@ Use them for durable, reusable guidance such as: Do not use skills for one-off notes or tool runtime configuration. -`python3 scripts/agents/sync-agent-shims.py` projects shared skills into +`python3 .agents/scripts/sync-agent-shims.py` projects shared skills into `.claude/skills/` so Claude can discover the same repo-owned skills. For the skill authoring workflow, see [skills/README.md](skills/README.md). diff --git a/.agents/config.json b/.agents/config.json index c2d6d62a0..132455a83 100644 --- a/.agents/config.json +++ b/.agents/config.json @@ -1,6 +1,6 @@ { "shared": { - "setupScript": "bash scripts/codex/setup.sh", + "setupScript": "bash .agents/scripts/codex/setup.sh", "devCommand": "poetry run bash", "devTerminalDescription": "Interactive development shell inside the Poetry environment" }, diff --git a/scripts/codex/setup.sh b/.agents/scripts/codex/setup.sh old mode 100755 new mode 100644 similarity index 70% rename from scripts/codex/setup.sh rename to .agents/scripts/codex/setup.sh index c1b00e39f..ecb0ed653 --- a/scripts/codex/setup.sh +++ b/.agents/scripts/codex/setup.sh @@ -4,6 +4,5 @@ set -euo pipefail poetry config virtualenvs.create true --local poetry config virtualenvs.in-project true --local -poetry install --all-extras -bash scripts/postinstall.sh +bash .agents/scripts/install.sh --all-extras diff --git a/.agents/scripts/install.sh b/.agents/scripts/install.sh new file mode 100644 index 000000000..e9ec60641 --- /dev/null +++ b/.agents/scripts/install.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +poetry install "$@" + +bash .agents/scripts/postinstall.sh diff --git a/.agents/scripts/postinstall.sh b/.agents/scripts/postinstall.sh new file mode 100644 index 000000000..45ce21602 --- /dev/null +++ b/.agents/scripts/postinstall.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ ! -f ".agents/scripts/sync-agent-shims.py" ]]; then + echo "Skipping agent shim sync: .agents/scripts/sync-agent-shims.py is not present in this install context." + exit 0 +fi + +python3 .agents/scripts/sync-agent-shims.py +python3 .agents/scripts/sync-agent-shims.py --check diff --git a/scripts/agents/sync-agent-shims.py b/.agents/scripts/sync-agent-shims.py old mode 100755 new mode 100644 similarity index 97% rename from scripts/agents/sync-agent-shims.py rename to .agents/scripts/sync-agent-shims.py index 1eac6a3ea..f7df3cd9e --- a/scripts/agents/sync-agent-shims.py +++ b/.agents/scripts/sync-agent-shims.py @@ -186,6 +186,11 @@ def print_error(message: str) -> None: print(message, file=sys.stderr) +def is_writable(path: Path) -> bool: + target = path if path.exists() else path.parent + return os.access(target, os.W_OK) + + def sync_file_outputs( file_outputs: list[dict[str, Any]], check_mode: bool ) -> tuple[bool, list[str]]: @@ -198,6 +203,9 @@ def sync_file_outputs( optional: bool = output.get("optional", False) if check_mode: + if optional and not is_writable(path): + warnings.append(f"Skipping optional config check: {path}") + continue try: current = path.read_text(encoding="utf-8") except FileNotFoundError: @@ -206,7 +214,7 @@ def sync_file_outputs( continue has_mismatch = True print_error( - f'Missing generated config: {path}. Run "python3 scripts/agents/sync-agent-shims.py".' + f'Missing generated config: {path}. Run "python3 .agents/scripts/sync-agent-shims.py".' ) continue @@ -240,7 +248,7 @@ def sync_symlink_outputs( if not is_matching_symlink(path, target): has_mismatch = True print_error( - f'Out of sync symlink: {path}. Run "python3 scripts/agents/sync-agent-shims.py".' + f'Out of sync symlink: {path}. Run "python3 .agents/scripts/sync-agent-shims.py".' ) continue diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5acdb2c7a..0d2753bf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,10 +29,6 @@ jobs: uses: actions/setup-python@v4 with: python-version: "3.13" - - name: Verify agent shim generation - run: | - python3 scripts/agents/sync-agent-shims.py - python3 scripts/agents/sync-agent-shims.py --check - name: Install poetry uses: abatilo/actions-poetry@v2 - name: Setup a local virtual environment @@ -52,7 +48,7 @@ jobs: restore-keys: | mypy- - name: Install dependencies - run: poetry install --only=main,dev + run: bash .agents/scripts/install.sh --only=main,dev - name: Run mypy type checking run: poetry run mypy langfuse --no-error-summary @@ -189,7 +185,7 @@ jobs: venv-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }}-${{ github.sha }} - name: Install the project dependencies - run: poetry install --all-extras + run: bash .agents/scripts/install.sh --all-extras - name: Run the automated tests run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bde0b56ee..f8a57a24c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ poetry self add poetry-dotenv-plugin ### Install dependencies ``` -poetry install --all-extras +bash .agents/scripts/install.sh --all-extras ``` ### Shared Agent Setup @@ -45,15 +45,14 @@ and MCP/bootstrap configuration. - Tool-specific skill projections generated locally and not committed: - `.claude/skills/*` - Shared bootstrap for agent environments: - - `bash scripts/codex/setup.sh` + - `bash .agents/scripts/codex/setup.sh` When you change the shared agent setup: 1. Edit `.agents/config.json` or `.agents/skills/**` -2. Run `python3 scripts/agents/sync-agent-shims.py` -3. Run `python3 scripts/agents/sync-agent-shims.py --check` -4. Run `poetry run pytest tests/test_sync_agent_shims.py` -5. Do not commit the generated MCP config files or runtime shims +2. Run `python3 .agents/scripts/sync-agent-shims.py` +3. Run `python3 .agents/scripts/sync-agent-shims.py --check` +4. Do not commit the generated MCP config files or runtime shims ### Add Pre-commit @@ -125,7 +124,7 @@ Note: The generated SDK reference is currently work in progress. The SDK reference is generated via pdoc. You need to have all extra dependencies installed to generate the reference. ```sh -poetry install --all-extras +bash .agents/scripts/install.sh --all-extras ``` To update the reference, run the following command: diff --git a/scripts/postinstall.sh b/scripts/postinstall.sh deleted file mode 100755 index 9b4a0a089..000000000 --- a/scripts/postinstall.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -if [[ ! -f "scripts/agents/sync-agent-shims.py" ]]; then - echo "Skipping agent shim sync: scripts/agents/sync-agent-shims.py is not present in this install context." - exit 0 -fi - -python3 scripts/agents/sync-agent-shims.py -python3 scripts/agents/sync-agent-shims.py --check diff --git a/tests/test_sync_agent_shims.py b/tests/test_sync_agent_shims.py deleted file mode 100644 index 1a7c896d2..000000000 --- a/tests/test_sync_agent_shims.py +++ /dev/null @@ -1,173 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import sys -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[1] -SCRIPT_PATH = REPO_ROOT / "scripts" / "agents" / "sync-agent-shims.py" - - -def write_file(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - - -def make_fixture_repo(tmp_path: Path) -> Path: - repo_root = tmp_path / "repo" - write_file(repo_root / ".agents" / "AGENTS.md", "# Fixture\n") - write_file(repo_root / ".agents" / "skills" / "example" / "SKILL.md", "# Example\n") - write_file( - repo_root / ".agents" / "skills" / "README.md", - "# Skills\n", - ) - write_file( - repo_root / ".agents" / "config.json", - json.dumps( - { - "shared": { - "setupScript": "bash scripts/codex/setup.sh", - "devCommand": "poetry run bash", - "devTerminalDescription": "Interactive development shell", - }, - "mcpServers": { - "docs": { - "transport": "http", - "url": "https://langfuse.com/api/mcp", - }, - "stdio-example": { - "transport": "stdio", - "command": "npx", - "args": ["-y", "example"], - "env": {"EXAMPLE_TOKEN": "test"}, - }, - }, - "claude": { - "settings": { - "permissions": {"allow": ["Bash(rg:*)"], "deny": []}, - "enableAllProjectMcpServers": True, - } - }, - "codex": {"environment": {"version": 1, "name": "fixture"}}, - "cursor": {"environment": {"agentCanUpdateSnapshot": False}}, - }, - indent=2, - ) - + "\n", - ) - write_file(repo_root / ".claude" / "skills" / "stale" / "SKILL.md", "# Stale\n") - write_file(repo_root / ".vscode" / "settings.json", "{\n}\n") - return repo_root - - -def run_script(repo_root: Path, *args: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [sys.executable, str(SCRIPT_PATH), "--repo-root", str(repo_root), *args], - capture_output=True, - text=True, - check=False, - ) - - -def test_sync_agent_shims_generates_expected_outputs(tmp_path: Path) -> None: - repo_root = make_fixture_repo(tmp_path) - - result = run_script(repo_root) - - assert result.returncode == 0 - assert json.loads((repo_root / ".mcp.json").read_text(encoding="utf-8")) == { - "mcpServers": { - "docs": {"type": "http", "url": "https://langfuse.com/api/mcp"}, - "stdio-example": { - "command": "npx", - "args": ["-y", "example"], - "env": {"EXAMPLE_TOKEN": "test"}, - }, - } - } - assert json.loads( - (repo_root / ".cursor" / "environment.json").read_text(encoding="utf-8") - ) == { - "agentCanUpdateSnapshot": False, - "install": "bash scripts/codex/setup.sh", - "terminals": [ - { - "name": "Development Terminal", - "command": "poetry run bash", - "description": "Interactive development shell", - } - ], - } - assert (repo_root / "AGENTS.md").is_symlink() - assert Path(os.readlink(repo_root / "AGENTS.md")) == Path(".agents/AGENTS.md") - assert (repo_root / "CLAUDE.md").is_symlink() - assert Path(os.readlink(repo_root / "CLAUDE.md")) == Path("AGENTS.md") - assert (repo_root / ".claude" / "skills" / "example").is_symlink() - assert not (repo_root / ".claude" / "skills" / "stale").exists() - assert (repo_root / ".codex" / "config.toml").exists() - assert (repo_root / ".codex" / "environments" / "environment.toml").exists() - - -def test_sync_agent_shims_check_mode_detects_drift(tmp_path: Path) -> None: - repo_root = make_fixture_repo(tmp_path) - run_script(repo_root) - write_file(repo_root / ".cursor" / "environment.json", "{}\n") - - result = run_script(repo_root, "--check") - - assert result.returncode == 1 - assert "Out of sync" in result.stderr - - -def test_sync_agent_shims_check_mode_detects_symlink_target_drift(tmp_path: Path) -> None: - repo_root = make_fixture_repo(tmp_path) - run_script(repo_root) - (repo_root / "CLAUDE.md").unlink() - (repo_root / "CLAUDE.md").symlink_to(".agents/AGENTS.md") - - result = run_script(repo_root, "--check") - - assert result.returncode == 1 - assert "Out of sync symlink" in result.stderr - - -def test_sync_agent_shims_handles_missing_skills_directory(tmp_path: Path) -> None: - repo_root = make_fixture_repo(tmp_path) - readme_path = repo_root / ".agents" / "skills" / "README.md" - example_path = repo_root / ".agents" / "skills" / "example" - (example_path / "SKILL.md").unlink() - readme_path.unlink() - example_path.rmdir() - (repo_root / ".agents" / "skills").rmdir() - - result = run_script(repo_root) - - assert result.returncode == 0 - managed_dir = repo_root / ".claude" / "skills" - assert managed_dir.exists() - assert list(managed_dir.iterdir()) == [] - - -def test_sync_agent_shims_rejects_symlinked_managed_directories(tmp_path: Path) -> None: - repo_root = make_fixture_repo(tmp_path) - run_script(repo_root) - external_dir = tmp_path / "external-claude-skills" - external_dir.mkdir() - (external_dir / "foreign").mkdir() - managed_dir = repo_root / ".claude" / "skills" - for child in managed_dir.iterdir(): - child.unlink() - managed_dir.rmdir() - managed_dir.symlink_to(external_dir, target_is_directory=True) - - check_result = run_script(repo_root, "--check") - sync_result = run_script(repo_root) - - assert check_result.returncode == 1 - assert "must not be a symlink" in check_result.stderr - assert sync_result.returncode == 1 - assert "must not be a symlink" in sync_result.stderr - assert (external_dir / "foreign").exists()