diff --git a/.claude/skills/famstack-lsp/.claude-plugin/plugin.json b/.claude/skills/famstack-lsp/.claude-plugin/plugin.json new file mode 100644 index 0000000..20fe6bd --- /dev/null +++ b/.claude/skills/famstack-lsp/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "famstack-lsp", + "description": "Python language server (basedpyright) for the famstack repo, so agents get diagnostics and code navigation instead of grepping.", + "version": "0.1.0", + "author": { + "name": "famstack", + "url": "https://github.com/famstack-dev/famstack" + }, + "license": "AGPL-3.0-or-later", + "lspServers": "./.lsp.json" +} diff --git a/.claude/skills/famstack-lsp/.lsp.json b/.claude/skills/famstack-lsp/.lsp.json new file mode 100644 index 0000000..0101352 --- /dev/null +++ b/.claude/skills/famstack-lsp/.lsp.json @@ -0,0 +1,10 @@ +{ + "basedpyright": { + "command": "basedpyright-langserver", + "args": ["--stdio"], + "extensionToLanguage": { + ".py": "python", + ".pyi": "python" + } + } +} diff --git a/.claude/skills/famstack-lsp/README.md b/.claude/skills/famstack-lsp/README.md new file mode 100644 index 0000000..76768a6 --- /dev/null +++ b/.claude/skills/famstack-lsp/README.md @@ -0,0 +1,21 @@ +# famstack-lsp + +A skills-directory plugin: Claude Code discovers it in place as `famstack-lsp@skills-dir` on the next session, with no marketplace and no install step. It only starts once you have accepted the workspace trust dialog for this repo. + +It wires one thing: `basedpyright-langserver` over stdio for `.py` and `.pyi`. That gives an agent working in this repo live diagnostics after each edit, plus go-to-definition and find-references, instead of grepping for a symbol and hoping. + +## Requirement + +The binary is not bundled. Install it once: + +```bash +uv tool install basedpyright # provides basedpyright and basedpyright-langserver +``` + +If `/plugin` shows `Executable not found in $PATH`, that install is missing or `~/.local/bin` is not on your `PATH`. + +## Configuration + +There is none here on purpose. The language server reads `[tool.basedpyright]` in the repo's `pyproject.toml`, the same table the `uvx basedpyright` CLI reads, so the two never disagree. That table is where the import roots live: this repo has no installed package, and every stacklet bootstraps its own directory onto `sys.path` at import time, so each one needs an execution environment or its imports go dark. + +See [CONTRIBUTING.md](../../../CONTRIBUTING.md) for the full toolchain. diff --git a/.gitignore b/.gitignore index fc9a1bd..b1e76ed 100644 --- a/.gitignore +++ b/.gitignore @@ -46,8 +46,15 @@ htmlcov/ # scorecards) — written by `stacktests eval`, never committed. tests/integration/eval/runs/ -# AI assistant -.claude/ +# AI assistant. Local settings and transcripts stay out, but the LSP plugin +# is repo configuration: it makes agents read this codebase with a language +# server instead of grep, so every clone should get it. +# Git cannot re-include a file whose parent directory is excluded, so these +# ignore directory *contents* and re-open the one path down to the plugin. +.claude/* +!.claude/skills/ +.claude/skills/* +!.claude/skills/famstack-lsp/ # Internal development notes impl/ diff --git a/AGENTS.md b/AGENTS.md index 2c6ee81..e523146 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,10 +18,12 @@ A single-file Python CLI (`./stack`) orchestrates Docker-based services | You are a... | Load this | Use it to... | |---|---|---| | **Operator** (running famstack on a Mac) | [docs/agent/ops.md](docs/agent/ops.md) | install, start/stop, troubleshoot, back up | -| **Engineer** (changing famstack code) | [docs/agent/dev.md](docs/agent/dev.md) | write stacklets, hooks, CLI plugins, tests, commits | +| **Engineer** (changing famstack code) | [docs/agent/dev.md](docs/agent/dev.md) | write stacklets, hooks, CLI plugins, tests, commits, static checks | If you might do both, load both. They are short on purpose. +The type checker is worth one look before you start, whatever your harness: the tree carries a standing error count, so a bare run tells you nothing about your own change. The "Static checks" section of the engineer file has the two commands that separate the two, and works from any agent that can run a shell. + ## Approach (universal) Six principles. The first four are distilled from [Andrej Karpathy's observations on LLM coding pitfalls](https://github.com/multica-ai/andrej-karpathy-skills); the fifth is classic separation of concerns; the sixth is what we test and why. Apply to every change, every role. **Tradeoff:** these bias toward caution over speed. For trivial tasks (typos, obvious one-liners), use judgment. @@ -93,6 +95,7 @@ Apply to every role, every session. | Doc | Purpose | |---|---| | [README.md](README.md) | Intro + quickstart | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Dev toolchain: setup, lint, type checking, language server | | [docs/admin-guide.md](docs/admin-guide.md) | Full operator manual (prose) | | [docs/user-guide.md](docs/user-guide.md) | Family-facing chat usage guide | | [docs/stack-reference.md](docs/stack-reference.md) | Framework reference: manifest, hooks, env, lifecycle | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..535394d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing + +This is the developer toolchain: what to install, and how the static checks are wired. For how to write code here (stacklets, hooks, tests, commits) read [AGENTS.md](AGENTS.md) and [docs/agent/dev.md](docs/agent/dev.md). For running famstack rather than changing it, read the [README](README.md). + +famstack targets Apple Silicon Macs only. Intel macOS, Linux and Windows are not targets. + +## Setup + +One entry point, [Homebrew](https://brew.sh), which brings the rest: + +```bash +brew install uv +uv sync --extra test +``` + +`uv sync` creates `.venv` in the repo root. Nothing needs activating: `uv run` and `uvx` find it, and the type checker is pointed at it by `pyproject.toml`. The `test` extra declares every dependency the test suite needs, including the bot runtime libraries that tests import directly. + +The CLI itself depends only on the standard library. Stacklet containers carry their own runtime dependencies, which is why `.venv` will not contain all of them: `nanobot` and `fastapi`, for example, exist only inside their images. + +## Static checks + +```bash +uvx ruff check . # undefined names, unused imports, syntax-level mistakes +make typecheck # types across the shipped code +uvx basedpyright # types in just the files you touched +``` + +Ruff is the fast pass. [basedpyright](https://docs.basedpyright.com) is the layer above it: wrong argument types, attribute access on a value that can be `None`, variables possibly unbound on one branch. A full run takes a few seconds. + +Neither is a merge gate, and the type checker is not clean today. Run it on what you changed and read what it says about that. Treating a 198-error baseline as a wall to bring to zero is not the job. + +## Language server + +Agents working in this repo get a language server, so they can ask for a definition or every reference to a symbol instead of grepping for a name and hoping it is unique. Install the binary once: + +```bash +uv tool install basedpyright +``` + +The wiring is checked in at [.claude/skills/famstack-lsp/](.claude/skills/famstack-lsp/), which Claude Code discovers in place as a skills-directory plugin. There is no install step and no marketplace; it starts after you accept the workspace trust dialog. Editors other than Claude Code point at the same `basedpyright-langserver` binary through their own LSP configuration. + +That trust dialog is the one thing likely to bite you, because it can fail to appear. Trust is recorded per directory, but permissions inherit from a trusted parent, so opening this repo under an already-trusted parent directory gets you a session with no dialog, no error, and no language server. Nothing reports it except `claude plugin list`, which names the suppressed directory outright. If the LSP is missing, run that first; the fix is to accept trust for this directory and then `/reload-plugins`. + +The `lspServers` field is a Claude Code CLI feature. Agent harnesses built on the Claude Agent SDK read the rest of a plugin but do not start its language servers, so an agent running under one has no navigation tools and should say so rather than pretend. The `uvx basedpyright` CLI is the fallback for the diagnostics half, and it is the same engine reading the same config. + +Because the language server and the `basedpyright` CLI are the same engine reading the same `[tool.basedpyright]` table, they cannot drift apart on what the config means. They can still disagree about which config they are on: the server reads `pyproject.toml` at startup and does not watch it, so after editing that table the CLI is current and the server is not. `/reload-plugins` resyncs it. + +One trap while editing the table: a `typeCheckingMode` inside an `executionEnvironments` entry is accepted and then ignored, with no warning that it did nothing. Per-environment diagnostic overrides are not the lever they look like. Verify a config change by the error count it produces, not by whether the file parsed. + +## Import roots, and why the type config is long + +This repo has no installed package. The framework lives in `lib/`, and every stacklet bootstraps its own directory onto `sys.path` at import time, in more than two hundred places. A checker that knows only about the repo root therefore reads `import stack.config`, `from microbot import ...` and `import memory.lib` as unresolved, and the noise buries every real finding. + +So `[tool.basedpyright]` in `pyproject.toml` declares one execution environment per stacklet, each naming the roots that stacklet actually puts on the path. They cannot be collapsed into a single global `extraPaths`, because `hooks/`, `cli/` and `bot/` exist under several stacklets and would resolve to whichever came first. + +**If you add a stacklet, add its execution environment.** Otherwise its imports go dark and it silently stops being checked at all. + +Three roots recur, and are worth recognising when you read that table: + +| Root | Why | +|---|---| +| `lib` | the framework, `stack.*` | +| `stacklets/core/bot-runner` | every bot imports `microbot` from there | +| `stacklets` | cross-stacklet reads such as `memory.lib` | + +A bare `make typecheck` reports on `lib`, `stacklets`, `tools` and `hooks`. `tests` is in `include` as well, but listed under `ignore`, which is a different thing from being left out: the files are still analysed, so the language server resolves them and find-references reaches test callers, while their diagnostics are suppressed. Fixtures pass deliberately loose dicts into typed SDK calls, and their several hundred complaints would bury the roughly two hundred about shipped code. + +`exclude` would have been the wrong tool for that. It drops files from the index and takes navigation down with them, which is the thing worth having. Note that `ignore` beats a filename passed on the command line, so `uvx basedpyright tests/...` reports nothing: to check a test file, comment the `ignore` line out. + +## Tests, style, commits + +All three live in [docs/agent/dev.md](docs/agent/dev.md), which is the canonical reference and stays shorter than a duplicate here would. The short version: `make test-unit` before every commit, module tests over unit tests, semantic commit prefixes, feature branches only, never push without asking. diff --git a/Makefile b/Makefile index 0de8ba1..7b765e2 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,11 @@ PYTEST = uv run --extra test pytest +# Type check the shipped code. Not a gate: read what it says about the files +# you touched. `uvx basedpyright ` narrows it further. +typecheck types: + -uvx basedpyright + # Fast unit tests: no Docker. Run before every commit. test-unit unit fast test: $(PYTEST) tests/framework tests/stacklets -v --ignore=tests/framework/test_config_to_container.py @@ -26,4 +31,4 @@ test-smoke smoke: test-all: test-lifecycle test-integration: test-e2e -.PHONY: test-unit unit fast test test-demo demo-rig demo test-lifecycle container-lifecycle lifecycle test-e2e container-e2e e2e test-smoke smoke test-all test-integration +.PHONY: typecheck types test-unit unit fast test test-demo demo-rig demo test-lifecycle container-lifecycle lifecycle test-e2e container-e2e e2e test-smoke smoke test-all test-integration diff --git a/docs/agent/dev.md b/docs/agent/dev.md index 734308d..542532e 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -163,6 +163,45 @@ Use only the documented template variables in `[env.defaults]`. Adding a new one Most-used: `{data_dir}`, `{domain}`, `{ip}`, `{language}`, `{timezone}`, `{shared_bucket}`, `{stacklet_id}`, `{admin_username}`, `{admin_email}`, `{admin_password}`, `{ai_openai_url}`, `{ai_openai_url_docker}`, `{ai_default_model}`, `{messages_server_name}`. +## Static checks + +```bash +uvx ruff check . # undefined names, unused imports +make typecheck # types across lib, stacklets, tools, hooks +uvx basedpyright # types in just what you touched +``` + +A language server (basedpyright) is wired up for this repo. **Check your own tool list before relying on it:** the plugin is read by the Claude Code CLI, and harnesses built on the Agent SDK do not start it. With no LSP tools in hand, use `uvx basedpyright ` for diagnostics (same config, so it says what the server would) and grep for navigation. There is no MCP fallback and none is planned: basedpyright ships a CLI and a language server, nothing else, so the CLI is the whole story for an agent without LSP. With them, prefer asking for a definition or the references to a symbol over grepping for the name. Setup and the reasoning behind the type config live in [../../CONTRIBUTING.md](../../CONTRIBUTING.md) - do not duplicate here, it drifts. + +Two rules when reading its output: + +- **It is not a gate.** The tree is not clean and getting it to zero is not the job. Read what it says about the files you changed. +- **If you add a stacklet, add its execution environment** to `[tool.basedpyright]`. Otherwise its imports go dark and nothing in it is checked. Container-only deps (`nanobot`, `fastapi`) stay unresolved on purpose. + +And three ways the server answers wrongly rather than saying it cannot answer. All three were hit in one session: + +- **A cold server under-reports.** It answers while still indexing instead of waiting, so the first find-references of a session can come back with just the definition. One symbol went 1, then 3, then 9, then 12 for the same query as more of the tree got parsed. An empty result is not evidence of no callers. +- **Ask from both ends when the answer decides an edit.** For an attribute reached through `self`, asking at the definition missed every same-file use (5 results where the truth was 40); asking at a call site returned a superset that swept in unrelated symbols of the same name (106). Neither number was right. Two queries that agree are worth more than one that looks tidy. +- **It reads `pyproject.toml` once, at startup.** Change the type config and the server keeps answering from the old view while the CLI already has the new one, which is the one way those two can disagree. `/reload-plugins` resyncs it. + +`tests/` is indexed but silent by design: navigation reaches test callers, and the CLI never reports test diagnostics. To type check a test file, comment out `ignore` in `[tool.basedpyright]` - naming the file on the command line does not override it. + +### Agents without LSP tools + +Navigation stays with grep, and none of the caveats above apply to you. But the CLI has one thing the language server does not, and it is the one that matters most here: a baseline, so you read the error you just wrote instead of the couple of hundred that were already there. + +```bash +# once, before you touch anything +uvx basedpyright --writebaseline --baselinefile /tmp/famstack-baseline.json + +# after each change: only what you introduced +uvx basedpyright --baselinefile /tmp/famstack-baseline.json +``` + +Exit codes are the contract, so this works as a check and not just as reading material: **0** when you added nothing new, **1** when you did. A bare run without the baseline is always 1 while the tree is unclean, which is why it cannot tell you anything on its own. Add `--outputjson` to parse rather than read. + +Keep that file outside the repo. A committed baseline would quietly turn the type checker into the merge gate this repo says it is not, and that is a decision to take deliberately, not a side effect of where a file landed. + ## Testing Test runner: **`uv run --extra test pytest`**. The `test` extra in `pyproject.toml` declares every dep. Do NOT re-spell with `uvx --with`. diff --git a/pyproject.toml b/pyproject.toml index c50a80d..0eaeed6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,3 +80,113 @@ select = ["E4", "E7", "E9", "F"] # The top-level CLI wires up ~30 argparse subcommands with deliberate one-line # `p = sub.add_parser(...); p.add_argument(...)` pairs — compact by design. "lib/stack/cli.py" = ["E702"] + +# --------------------------------------------------------------------------- +# Type checking (basedpyright) +# --------------------------------------------------------------------------- +# Ruff catches undefined names; this catches the layer above — wrong argument +# types, attribute access on Optional, unbound branches. Run it on the files +# you touched, not the tree: `uvx basedpyright `. +# +# The interesting part is the import roots. There is no installed package +# here: the framework lives in `lib/`, and every stacklet bootstraps its own +# directory onto `sys.path` at import time. A checker that only knows about +# the repo root sees `import stack.config` and `from bot import ...` as +# unresolved, and the noise buries the real findings. So each stacklet gets an +# execution environment naming its own root. They cannot be merged into one +# global `extraPaths` — `hooks/`, `cli/` and `bot/` exist in several stacklets +# and would resolve to whichever came first. +# +# Three roots recur: `stacklets/core/bot-runner` (every bot imports +# `microbot` from there), `stacklets` itself (cross-stacklet reads like +# `memory.lib`), and the stacklet's own `bot/` directory (bot modules import +# their siblings by bare name). +# +# What stays unresolved is what genuinely is not here: `nanobot`, `fastapi` +# and friends live only inside their containers. Install them into `.venv` +# only if you want those files checked too. + +[tool.basedpyright] +# "standard" is pyright's own default. basedpyright's stricter default flags +# every bare `dict`/`list` annotation, which is style, not correctness. +typeCheckingMode = "standard" +pythonVersion = "3.11" +venvPath = "." +venv = ".venv" +extraPaths = ["lib"] +# `tests` is here so the language server can index it: without it, "find +# every caller" silently skips the test suite, which is where much of the +# calling happens. It reports nothing, though — see the tests execution +# environment at the bottom. Test fixtures pass deliberately loose dicts +# into typed SDK calls, and those 800-odd complaints would bury the ~200 +# real ones in shipped code. `ignore` wins even over a filename passed on +# the command line, so to check a test file you comment that line out. +include = ["lib", "stacklets", "tools", "hooks", "tests"] +exclude = [ + "**/__pycache__", + "**/node_modules", + ".venv", + ".stack", + "lib/simple_term_menu.py", # vendored third-party file +] +# Analysed but silent. `exclude` would drop these files from the index and +# take navigation with them; `ignore` keeps them resolved and suppresses +# their diagnostics. That is what buys find-references over the test suite +# without the fixture noise. +ignore = ["tests"] + +[[tool.basedpyright.executionEnvironments]] +root = "lib" +extraPaths = ["lib", "stacklets/ai", "stacklets/core/bot-runner"] + +[[tool.basedpyright.executionEnvironments]] +root = "stacklets/agent" +extraPaths = ["lib", "stacklets/agent", "stacklets/agent/bot", "stacklets/core/bot-runner", "stacklets"] + +[[tool.basedpyright.executionEnvironments]] +root = "stacklets/ai" +extraPaths = ["lib", "stacklets/ai", "stacklets/core/bot-runner", "stacklets"] + +[[tool.basedpyright.executionEnvironments]] +root = "stacklets/backup" +# on_install.py bootstraps the engine directory itself, not its parent, to +# import `sync` for the canary string. +extraPaths = ["lib", "stacklets/backup", "stacklets/backup/engines", "stacklets/backup/engines/external-disk", "stacklets/core/bot-runner", "stacklets"] + +[[tool.basedpyright.executionEnvironments]] +root = "stacklets/chatai" +extraPaths = ["lib", "stacklets/chatai", "stacklets/core/bot-runner", "stacklets"] + +[[tool.basedpyright.executionEnvironments]] +root = "stacklets/code" +extraPaths = ["lib", "stacklets/code", "stacklets/core/bot-runner", "stacklets"] + +[[tool.basedpyright.executionEnvironments]] +root = "stacklets/core" +extraPaths = ["lib", "stacklets/core", "stacklets/core/bot", "stacklets/core/bot-runner", "stacklets"] + +[[tool.basedpyright.executionEnvironments]] +root = "stacklets/docs" +# git_mirror.py reaches across to the memory stacklet's CLI for `todo_list`. +extraPaths = ["lib", "stacklets/docs", "stacklets/docs/bot", "stacklets/memory/bot/cli", "stacklets/core/bot-runner", "stacklets"] + +[[tool.basedpyright.executionEnvironments]] +root = "stacklets/infra" +extraPaths = ["lib", "stacklets/infra", "stacklets/core/bot-runner", "stacklets"] + +[[tool.basedpyright.executionEnvironments]] +root = "stacklets/memory" +# `wiki` and `todo_list` live one level deeper, under bot/cli. +extraPaths = ["lib", "stacklets/memory", "stacklets/memory/bot", "stacklets/memory/bot/cli", "stacklets/core/bot-runner", "stacklets"] + +[[tool.basedpyright.executionEnvironments]] +root = "stacklets/messages" +extraPaths = ["lib", "stacklets/messages", "stacklets/messages/bot", "stacklets/core/bot-runner", "stacklets"] + +[[tool.basedpyright.executionEnvironments]] +root = "stacklets/photos" +extraPaths = ["lib", "stacklets/photos", "stacklets/core/bot-runner", "stacklets"] + +[[tool.basedpyright.executionEnvironments]] +root = "tests" +extraPaths = ["lib", ".", "stacklets", "stacklets/core/bot-runner", "stacklets/agent/bot", "stacklets/core/bot", "stacklets/core/tools-server", "stacklets/docs/bot", "stacklets/memory/bot", "stacklets/memory/bot/cli", "stacklets/messages/bot"]