Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .claude/skills/famstack-lsp/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
10 changes: 10 additions & 0 deletions .claude/skills/famstack-lsp/.lsp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"basedpyright": {
"command": "basedpyright-langserver",
"args": ["--stdio"],
"extensionToLanguage": {
".py": "python",
".pyi": "python"
}
}
}
21 changes: 21 additions & 0 deletions .claude/skills/famstack-lsp/README.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 9 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand Down
72 changes: 72 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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 <path> # 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.
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 <paths>` 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
Expand All @@ -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
39 changes: 39 additions & 0 deletions docs/agent/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <paths> # 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 <paths>` 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`.
Expand Down
Loading
Loading