Compare commits

..

7 Commits

Author SHA1 Message Date
Jev 0e3440ffce fix: add HOME environment variable to lxc_exec and improve fzf installation test 2026-04-08 07:21:48 +02:00
Jev 6f8b88898f working on tests 2026-04-08 00:07:36 +02:00
Jev f488af4959 add .gitignore to exclude temporary files and Python bytecode 2026-04-07 23:54:23 +02:00
Jev f5811ac2e4 update bootstrap task to include zoxide installation 2026-04-07 22:56:23 +02:00
Jev 02a8ff08f9 update tooling 2026-04-07 22:51:42 +02:00
Jev c50caa2a77 add tasks for installing Claude and GitHub Copilot 2026-04-07 19:20:54 +02:00
Jev 944e7b8538 first commit 2026-04-07 19:15:21 +02:00
37 changed files with 318 additions and 1480 deletions
+4 -44
View File
@@ -1,45 +1,5 @@
# Environment
.env
.env.gpg
temp/
*.pyc
*.pyo
*.pyd
# copied files
docker/python-dev/CLAUDE.md
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.python-version
ai-tools/claude/settings.json
-23
View File
@@ -1,23 +0,0 @@
stages:
- build
build:
stage: build
image: docker:latest
services:
- docker:dind
before_script:
- apk add --no-cache git
- docker version
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
- docker buildx create --name mybuilder --use
- docker buildx inspect --bootstrap
script:
- docker buildx build --platform linux/amd64,linux/arm64 -t $CI_REGISTRY_IMAGE --push ./docker/python-dev
when: manual
only:
changes:
- docker/python-dev/**/*
refs:
- main
-28
View File
@@ -1,28 +0,0 @@
# CLAUDE.md
## Commands
```bash
uv run cli-tools --help # run the CLI
uv sync # install dependencies
uv run ruff check --fix src/ # lint
uv run ruff format src/ # format
```
## Architecture
Typer-based CLI for bootstrapping a dev environment.
- `cli.py` — entry point, composes sub-apps
- `install.py` — install tools (fzf, zoxide, lazygit, eza, docker, uv, claude, helpers)
- `docker.py` — build the `python-dev` Docker image locally
- `credentials.py` — GPG encrypt/decrypt `.env` files
- `helpers.py``run()`, `append_bashrc_section()`, `load_snippet()`
Scripts: `scripts/bash_helpers.sh`, `scripts/aliases.sh` (symlinked to `~/`).
## Adding an install command
1. Add `@app.command()` in `install.py`
2. Use `run()` for shell commands, `append_bashrc_section()` for bashrc config
3. Optionally add to `bootstrap()` for first-time setup
+3 -118
View File
@@ -1,120 +1,5 @@
# CLI Tools
# CLI tools
Scripts and tools to set up and manage the terminal environment across systems.
This folder contains scripts and tools to setup and manage terminal environment across systems
**Entry point:** `cli-tools`
---
## Install
```bash
git clone <repo> && cd cli-tools
source init.sh # installs uv if needed, syncs deps, activates venv
```
```bash
cli-tools install --help # show available commands
```
## Docker
```bash
cli-tools docker build # build python-dev image
cli-tools docker build --tag v1.2 --push # build and push with tag
```
## Credentials
```bash
cli-tools credentials encrypt-env # .env → .env.gpg
cli-tools credentials decrypt-env # .env.gpg → .env
```
---
## Installed Tools
### [eza](https://github.com/eza-community/eza) — modern `ls` replacement
Fast, colorized file listing with Git integration and icons.
| Command | Description |
|--------------------|------------------------------------------|
| `ls` | list files (aliased to `eza`) |
| `ll` | long list with git status (`eza -la --git`) |
| `eza --tree` | tree view of directory |
| `eza -la --sort=modified` | sort by modification time |
---
### [fzf](https://github.com/junegunn/fzf) — fuzzy finder
Interactive fuzzy search for files, history, and more. Integrates with shell key bindings.
| Shortcut / Command | Description |
|-------------------------|------------------------------------------|
| `Ctrl+R` | fuzzy search shell history |
| `Ctrl+T` | fuzzy find file and insert path |
| `Alt+C` | fuzzy cd into a directory |
| `fzf` | pipe any list into interactive selector |
| `vim $(fzf)` | open fuzzy-selected file in editor |
---
### [lazygit](https://github.com/jesseduffield/lazygit) — terminal UI for Git
Full-featured Git UI in the terminal. Run `lazygit` in any repo.
| Key (inside lazygit) | Description |
|----------------------|--------------------------------------|
| `space` | stage / unstage file |
| `c` | commit |
| `P` (cap) | push |
| `P` | pull |
| `?` | help / key bindings |
| `q` | quit |
---
### [zoxide](https://github.com/ajeetdsouza/zoxide) — smarter `cd`
Tracks your most-visited directories and lets you jump to them with partial names.
| Command | Description |
|----------------|--------------------------------------------------|
| `z foo` | jump to the most frecent dir matching `foo` |
| `z foo bar` | match multiple terms |
| `zi` | interactive selection with fzf |
| `z -` | jump to previous directory |
---
## Shell aliases (`aliases.sh`)
| Alias | Expands to |
|------------------|-----------------------------------|
| `reload` | `source ~/.bashrc` |
| `venv` | `source .venv/bin/activate` |
| `uvs` | `uv sync --all-extras` |
| `cat` | `batcat` (syntax-highlighted cat) |
| `bat` | `batcat` |
| `ls` | `eza` |
| `ll` | `eza -la --git` |
| `fd` | `fdfind` |
| `clip` | `xclip -selection clipboard` |
| `open_ports` | `sudo ss -tulwn \| grep LISTEN` |
| `docker_stop_all`| stop all running containers |
| `claude-allow` | claude --dangerously-skip-permissions |
## Shell functions (`bash_helpers.sh`)
| Function | Usage |
|------------------|--------------------------------------------|
| `attach <name>` | exec into a running Docker container |
| `python-dev` | start/attach python-dev container (`-p` pull, `-n` fresh) |
| `mount_ssh <host>`| mount remote dir via SSHFS |
| `canview` | launch CAN bus viewer (uses `$CAN_CHANNEL`) |
| `repl` | connect to serial device via picocom (uses `$AMPY_PORT`) |
| `find_and_cat <file>` | find file by name and print contents |
| `record_window` | record a window to mp4 via ffmpeg |
Main orchestration file: `tasks.py`
-10
View File
@@ -1,10 +0,0 @@
---
name: implementer
description: Implement a feature by writing tests, production code, and committing changes. Delegates from /develop.
model: sonnet
tools: Read, Edit, Write, Bash, Grep, Glob
skills:
- commit
---
Read only the minimum files needed. Write tests first, then production code. Commit atomically as you go using conventional commits. Run tests after each logical step. If stuck after 3 attempts on the same issue, report `[BLOCKED: reason]`.
-32
View File
@@ -1,32 +0,0 @@
## Role
Senior Python developer assistant. Optimize for simple, maintainable code and low-token responses.
## Behavior
- Act on requests by default.
- Ask questions only if ambiguity affects correctness.
- Keep scope tight; do not add unrequested features.
- Propose a short plan (≤5 bullets) only when useful.
- If a request overcomplicates things, call it out and suggest a simpler option.
- Commit only when explicitly asked.
## Principles
- KISS, YAGNI.
- Explicit > implicit; readability counts.
- Flat > nested; avoid deep abstractions.
- Sparse > dense; avoid clever one-liners.
- No speculative patterns or overengineering.
## Coding
- Python 3.10+ with type hints (PEP 604).
- Use `uv`
- Clear names; short docstrings for non-obvious parts.
- No placeholders unless immediately needed.
- Keep files ~300500 lines when practical.
- Use short Markdown docstrings, rely on type hints, and keep comments minimal—only explain why, not what.
- Keep imports at top.
## Sub-agents
- Read minimum required files.
- Return concise summaries, not raw dumps.
- Treat as stateless; dont pass full context unless needed.
- Dont use for simple search or git tasks.
-22
View File
@@ -1,22 +0,0 @@
---
name: architect
description: Create or update docs/architecture.md from user stories and project context.
---
# Architect
Produce `docs/architecture.md`.
## Process
1. Read `docs/user_stories.md` and any existing `docs/architecture.md`.
2. Produce or update `docs/architecture.md` using the template in `assets/template.md`.
## Rules
- Make reasonable assumptions and flag each with `[ASSUMPTION]`.
- Ask only if a missing answer would materially change the architecture and cannot be inferred.
- Keep the document as short as reasonably possible.
- Mermaid diagrams only.
- Be specific: version numbers, concrete patterns, no "best practices" filler.
- If updating an existing document, preserve decisions that are still valid.
@@ -1,33 +0,0 @@
# Architecture: [Project Name]
## Problem and context
What problem this solves and for whom.
## Goals and non-goals
Numbered goals with measurable criteria. Explicit non-goals.
## Repository structure
Directory layout with one-line descriptions.
## System overview
One paragraph, then a Mermaid component diagram showing major components,
responsibilities, and communication paths.
## Technology stack
| Component | Technology | Version | Rationale |
## Module boundaries
For each module: what it owns, its public interface, and what it must NOT do.
Communication patterns between modules (sync/async, events, RPC).
## Key architectural decisions
For each important decision:
- **Decision:** what was chosen
- **Alternatives considered:** what else was evaluated
- **Rationale:** why this option
## Constraints and conventions
Tech stack rules, naming conventions, forbidden libraries, project-wide patterns.
## Open questions
Only unresolved items that must be decided later.
-23
View File
@@ -1,23 +0,0 @@
---
name: commit
description: Stage and commit changes on the active feature branch using atomic conventional commits.
---
# Commit
Create atomic conventional commits on the active feature branch.
## Commit format
- Format: `type(scope): description`
- Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`
- First line: imperative mood, lowercase, concise (e.g., "add login" not "added login").
- Body: only for complex changes; explain why, not what.
- Footer: `BREAKING CHANGE:` if applicable.
## Rules
- Scope is optional but encouraged when a module or file is the clear focus.
- If changes span multiple logical tasks, create separate commits.
- Each commit must be independently reviewable.
- Never combine unrelated changes in a single commit.
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
# Stage specified files and create a commit.
# Usage: bash scripts/commit.sh --message "type(scope): description" [--] file1 file2 ...
set -euo pipefail
usage() {
echo "Usage: bash scripts/commit.sh --message MESSAGE [--] FILE [FILE...]"
echo ""
echo "Options:"
echo " --message MESSAGE Commit message (required)"
echo " --help Show this help"
echo ""
echo "Examples:"
echo " bash scripts/commit.sh --message \"feat(auth): add login endpoint\" src/auth.py tests/test_auth.py"
echo " bash scripts/commit.sh --message \"chore: update deps\" -- requirements.txt"
}
MESSAGE=""
FILES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--help) usage; exit 0 ;;
--message)
[[ -z "${2:-}" ]] && { echo "Error: --message requires a value."; exit 1; }
MESSAGE="$2"; shift 2 ;;
--) shift; FILES+=("$@"); break ;;
-*) echo "Error: unknown option '$1'. Run with --help for usage."; exit 1 ;;
*) FILES+=("$1"); shift ;;
esac
done
CURRENT_BRANCH="$(git branch --show-current)"
if [[ -z "$CURRENT_BRANCH" ]]; then
echo "Error: not on a branch."
exit 1
fi
if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then
echo "Error: refusing to commit directly on '$CURRENT_BRANCH'. Create or switch to a feature branch first."
exit 1
fi
if [[ -z "$MESSAGE" ]]; then
echo "Error: --message is required."
echo ""
usage
exit 1
fi
if [[ ${#FILES[@]} -eq 0 ]]; then
echo "Error: at least one file must be specified."
echo ""
usage
exit 1
fi
git add -- "${FILES[@]}"
git commit --message "$MESSAGE"
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env bash
# Show branch status and full diff for commit planning.
# Usage: bash scripts/status.sh [BASE_BRANCH]
set -euo pipefail
if [[ "${1:-}" == "--help" ]]; then
echo "Usage: bash scripts/status.sh [BASE_BRANCH]"
echo ""
echo "Prints the current branch, git status, and the full diff (staged and unstaged) for commit planning."
exit 0
fi
BASE_BRANCH="${1:-main}"
CURRENT_BRANCH="$(git branch --show-current)"
echo "=== current branch ==="
echo "${CURRENT_BRANCH:-detached HEAD}"
if git rev-parse --verify "$BASE_BRANCH" >/dev/null 2>&1 && [[ -n "$CURRENT_BRANCH" && "$CURRENT_BRANCH" != "$BASE_BRANCH" ]]; then
echo ""
echo "=== commits ahead of $BASE_BRANCH ==="
git --no-pager log --oneline "$BASE_BRANCH..$CURRENT_BRANCH"
fi
echo ""
echo "=== git status ==="
git status --short --branch
echo ""
echo "=== staged diff ==="
git diff --staged
echo ""
echo "=== unstaged diff ==="
git diff
-88
View File
@@ -1,88 +0,0 @@
---
name: develop
description: "Autopilot feature delivery: spec, implement, commit, review — stops for manual testing before merge."
---
# Develop
Deliver a feature end-to-end with minimal human intervention.
## Input
`/develop <description>` — a plain-English feature description or slug name.
If no description is provided:
1. Read `docs/feature_backlog.md` if it exists.
2. Pick the first `## NNN — title` item (lowest `NNN`).
3. Announce: "Next up: **NNN — title** — description. Starting now." then proceed.
4. If no backlog exists, ask: "What should I build?" and wait.
## Resuming an interrupted run
1. Run `git log --oneline` to see commits on the branch.
2. Check `Status` in `docs/features/<NNN-slug>.md`.
3. Skip completed phases, continue from the next one.
## Phase 0 — Setup
1. Derive a slug from the description (e.g. `user-auth`, `csv-export`).
2. Determine the numeric prefix: list files in `docs/features/` matching `[0-9][0-9][0-9]-*.md`, find the highest number, and use one higher. If no files exist, start at `001`. Format: `NNN-<slug>` (e.g. `001-user-auth`, `042-csv-export`).
3. Use the full prefixed slug everywhere: branch name (`feat/NNN-<slug>`), spec file (`docs/features/NNN-<slug>.md`), and commit messages.
4. Run `git checkout -b feat/<NNN-slug>` to create the feature branch. If it already exists, switch to it.
5. If the working tree is dirty, stash changes first with `git stash`, then pop after switching.
## Phase 1 — Spec
1. Read `docs/architecture.md` if it exists (not required).
2. Write a lightweight feature spec to `docs/features/<NNN-slug>.md` using the template in `assets/spec-template.md`.
3. Make reasonable assumptions — flag each with `[ASSUMPTION]` in the spec.
4. Ask only if a missing answer would materially change the result and cannot be inferred. Otherwise choose the simpler option and flag it.
5. Commit the spec: `docs(<NNN-slug>): add feature spec`
## Phase 2 — Implement + Commit
Delegate to the **implementer** subagent (Sonnet). Provide it with:
- The feature spec path
- The architecture doc path (if it exists)
- Instruction to write tests first, then production code
- Instruction to commit atomically as it goes (conventional commits)
- Instruction to run tests after each logical step
- Instruction to read only the minimum files needed
If the implementer reports `[BLOCKED]`, stop and report the blocker to the user.
**Copilot / single-thread mode:** run implementation directly — read the spec, write tests, implement, commit, run tests.
## Phase 3 — Review
1. Run the test suite (`inv test` or project-appropriate test command).
2. Run `git diff main...HEAD` to see all changes on the feature branch.
3. Compare against the feature spec:
- Are all acceptance criteria addressed?
- Do tests pass?
- Any obvious issues (broken imports, missing files, dead code)?
4. Decision:
- **PASS** → update spec status to `approved`. Stop and tell the user: "Feature branch `feat/<NNN-slug>` is ready. Test it, then run `/merge <NNN-slug>` to merge."
- **FIXABLE** → loop back to Phase 2 with specific fix instructions. Maximum **1 retry**.
- **BLOCKED** → update spec status to `blocked`, stop and report to user.
## When to stop
Stop and report to the user ONLY for:
- Contradictions between the feature description and existing architecture
- Test failures that can't be resolved after 3 attempts
- Merge conflicts that can't be auto-resolved
- Missing critical information that genuinely cannot be assumed
Do NOT stop for:
- Choosing between implementation approaches (pick the simpler one, flag with `[ASSUMPTION]`)
- Creating new files or modules (just do it)
- Commit message wording (use conventional commits)
## Rules
- No approval gates.
- Make assumptions and flag them.
- Atomic conventional commits throughout.
- Feature branches are temporary — created and merged automatically.
- If the project has no test infrastructure, skip test-related steps and note it in the review.
@@ -1,23 +0,0 @@
# Feature: <Name>
Status: draft
## Summary
One sentence: what this feature does and why it's needed.
## Acceptance Criteria
- AC-01: ...
## Test Plan
- T-01 (AC-01): given ... / when ... / then ...
## Assumptions
- [ASSUMPTION]: ...
## Notes
Optional: implementation hints, constraints, edge cases.
-23
View File
@@ -1,23 +0,0 @@
---
name: merge
description: "Merge a reviewed feature branch to main and clean up."
---
# Merge
Merge a completed feature branch into `main` and delete it.
## Input
`/merge <NNN-slug>` — the prefixed feature slug (e.g. `042-csv-export`).
If no slug is provided, check the current branch (`git branch --show-current`). If it matches `feat/<NNN-slug>`, derive the slug from it. Otherwise, list feature branches and ask: "Which branch should I merge?"
## Steps
1. Confirm the feature spec at `docs/features/<NNN-slug>.md` has status `approved`. If not, stop and report.
2. `git checkout main`
3. `git merge --no-ff feat/<NNN-slug>`
4. If merge conflicts occur, attempt auto-resolution. If that fails, stop and report the conflicting files.
5. `git branch -d feat/<NNN-slug>`
6. Report summary: feature name, number of commits merged, what was built.
-39
View File
@@ -1,39 +0,0 @@
---
name: review
description: Audit the full codebase as a software architect. Score on 5 KPIs (Maintainability, Extensibility, Testability, Robustness, Clarity) and produce docs/review.md.
---
# Review
Audit the codebase, score each KPI 010, and produce `docs/review.md`.
## Process
1. Run the test suite to verify baseline health and gather coverage stats.
2. Analyze the codebase against the 5 KPIs below.
3. Produce `docs/review.md` using the template in `assets/template.md`.
## KPIs
1. **Maintainability** — How easily can the system be debugged, modified, or understood?
Metrics: modularity, cohesion, coupling, readability, simplicity.
2. **Extensibility** — How easily can new features be added without major refactoring?
Metrics: separation of concerns, dependency injection, use of interfaces/protocols.
3. **Testability** — How easily can components be tested in isolation and as a whole?
Metrics: pure functions, mockability, dependency inversion.
4. **Robustness** — How well does the system handle edge cases, errors, and real-world conditions?
Metrics: state management, predictability, fault tolerance.
5. **Clarity** — How quickly can a new developer understand the system's design and purpose?
Metrics: documentation, consistent naming, clear abstractions.
## Rules
- Be specific — cite `file:line` or `file:function` when pointing out issues.
- No filler — every bullet must be actionable or informative.
- Always include your model/version in the reviewer field.
- Do not modify any code. This is a read-only review.
- Keep the write-up concise.
-30
View File
@@ -1,30 +0,0 @@
# Code Review
| Field | Value |
|---|---|
| Date | YYYY-MM-DD |
| Reviewer | model name and version |
| LOC | total lines of code |
| Tests | count |
| Coverage | percentage (if available) |
## Maintainability — X/10
- ...
## Extensibility — X/10
...
## Testability — X/10
...
## Robustness — X/10
...
## Clarity — X/10
...
## Summary
One short paragraph on the overall state of the codebase.
## Priority recommendations
Top 5 actionable improvements, ordered by impact.
-62
View File
@@ -1,62 +0,0 @@
---
name: wbs
description: Break down architecture.md into a prioritized feature backlog, skipping already-built features.
---
# Plan
Produce or update `docs/feature_backlog.md`.
## Process
1. Read `docs/architecture.md`. This is the source of truth for what needs to be built.
2. Scan `docs/features/` for existing feature spec files (pattern: `[0-9][0-9][0-9]-*.md`). These are already built — do not include them in the backlog.
3. Find the highest `NNN` number across both `docs/features/` files and any existing `docs/feature_backlog.md` entries. New items start from one higher.
4. Produce `docs/feature_backlog.md` with this structure:
```markdown
# Feature Backlog
Completed features are tracked in `docs/features/` and removed from this list.
---
## NNN — slug-style-title
One sentence describing what gets built.
## NNN — next-item
One sentence. Optionally:
depends: NNN
```
## What goes on the backlog
Cover everything in the architecture that is not yet implemented. Group logically — infrastructure first, then features, then integrations.
Do not add items for:
- Work already represented by a file in `docs/features/`
- Documentation, comments, or cleanup unless explicitly in the architecture
- Vague future ideas not grounded in the architecture
## Chunk sizing
Each backlog item will be handed to a coding agent that writes a spec, implements the code, and commits — all in one automated pass with no human in the loop. Size items accordingly:
- **Self-contained**: scope should contain a single functionality that can be implemented and tested.
- **Scoped**: touches at most 2 modules and adds roughly 50200 lines of production code
- **Testable**: success is checkable by running the test suite — no manual inspection required
If an architectural feature is large, split it into ordered items (e.g. `data model`, then `API layer`, then `UI`).
If a feature is trivial (a single function or config value), merge it with a related item.
If an item depends on another, note it on a second line as `depends: NNN`.
## Rules
- Ask only if a trade-off materially changes scope or ordering and cannot be resolved from the architecture.
- Preserve items already in `docs/feature_backlog.md` that are not yet in `docs/features/` — only add, reorder, or remove items, do not rewrite existing descriptions without good reason.
- Each item is a `## NNN — slug-style-title` subheading followed by one sentence (and optionally a `depends:` line).
- Keep descriptions to one sentence — just enough for the coding agent to understand the scope.
- Do not mark any item as done. Completed work is tracked in `docs/features/`, not here.
-2
View File
@@ -1,2 +0,0 @@
aliases.sh
bash_helpers.sh
-109
View File
@@ -1,109 +0,0 @@
FROM python:3.13
ARG UID=1000
ARG GID=1000
# Create user with sudo support
RUN groupadd --gid $GID dev \
&& useradd --uid $UID --gid $GID -m dev \
&& apt-get update \
&& apt-get install -y sudo curl gnupg \
&& echo "dev ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/dev \
&& chmod 0440 /etc/sudoers.d/dev \
&& usermod -a -G dialout dev
# Install system packages
RUN apt-get install -y \
bat \
fd-find \
git \
git-lfs \
graphviz \
iputils-ping \
libgl1 \
locales \
make \
micro \
mosquitto-clients \
net-tools \
picocom \
ripgrep \
rsync \
socat \
tree \
zoxide \
&& rm -rf /var/lib/apt/lists/*
# Install eza
RUN mkdir -p /etc/apt/keyrings \
&& wget -qO- https://raw.githubusercontent.com/eza-community/eza/main/deb.asc \
| gpg --dearmor -o /etc/apt/keyrings/gierens.gpg \
&& echo 'deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main' \
> /etc/apt/sources.list.d/gierens.list \
&& chmod 644 /etc/apt/keyrings/gierens.gpg /etc/apt/sources.list.d/gierens.list \
&& apt-get update && apt-get install -y eza \
&& rm -rf /var/lib/apt/lists/*
# Install lazygit
RUN LAZYGIT_VERSION=$(curl -s https://api.github.com/repos/jesseduffield/lazygit/releases/latest \
| grep '"tag_name"' | cut -d'"' -f4 | sed 's/v//') \
&& curl -Lo /tmp/lazygit.tar.gz \
https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz \
&& tar -xf /tmp/lazygit.tar.gz -C /tmp lazygit \
&& install /tmp/lazygit /usr/local/bin \
&& rm /tmp/lazygit.tar.gz /tmp/lazygit
# Set locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && \
locale-gen
ENV LANG=en_US.UTF-8
ENV LC_ALL=en_US.UTF-8
# Create user directories with proper ownership
RUN mkdir -p /home/dev/.vscode-server/extensions \
&& mkdir -p /home/dev/.local/bin \
&& mkdir -p /home/dev/.claude \
&& mkdir -p /workspace \
&& chown -R dev:dev /home/dev/.vscode-server \
&& chown -R dev:dev /home/dev/.local \
&& chown -R dev:dev /home/dev/.claude \
&& chown -R dev:dev /workspace
# Switch to non-root user (only once, for the rest of the build)
USER dev
# create ssh keys
RUN ssh-keygen -q -t ed25519 -N "" -f /home/dev/.ssh/id_ed25519 && \
printf "Host *\n StrictHostKeyChecking accept-new\n" > /home/dev/.ssh/config && \
chmod 0700 /home/dev/.ssh && \
chmod 0600 /home/dev/.ssh/id_ed25519 /home/dev/.ssh/config && \
chmod 0644 /home/dev/.ssh/id_ed25519.pub
WORKDIR /home/dev
# Add local bin to PATH
ENV PATH="/home/dev/.local/bin:${PATH}"
# Install uv
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
# Install fzf
RUN git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf && ~/.fzf/install --all
# Install AI tools as user (frequently updated)
# copilot cli
RUN curl -fsSL https://gh.io/copilot-install | bash
# claude cli
RUN curl -fsSL https://claude.ai/install.sh | bash
# User config (most frequently changed)
COPY --chown=dev:dev CLAUDE.md /home/dev/.claude/CLAUDE.md
COPY --chown=dev:dev aliases.sh /home/dev/.aliases.sh
COPY --chown=dev:dev bash_helpers.sh /home/dev/.bash_helpers.sh
# Customize bash prompt
RUN echo 'export PS1="${PROJECT_NAME:+\[\e[35m\][$PROJECT_NAME]\[\e[m\] }🐍 \[\e[33m\]\W\[\e[m\] \[\033[1;36m\]# \[\033[0m\]"' >> ~/.bashrc \
&& echo 'source ~/.bash_helpers.sh' >> ~/.bashrc \
&& echo 'eval "$(zoxide init bash)"' >> ~/.bashrc \
&& echo '[[ -f /workspace/init_container.sh ]] && source /workspace/init_container.sh' >> ~/.bashrc
+32
View File
@@ -0,0 +1,32 @@
from pathlib import Path
YELLOW = "\033[33m"
RESET = "\033[0m"
BASHRC = "~/.bashrc"
SNIPPETS_DIR = Path(__file__).parent / "snippets"
def load_snippet(name: str) -> str:
"""Read a shell snippet from the snippets directory."""
return (SNIPPETS_DIR / f"{name}.sh").read_text()
def run(c, cmd: str) -> None:
"""Print command in yellow then execute it with a pty."""
print(f"{YELLOW}$ {cmd}{RESET}")
c.run(cmd, pty=True)
def append_bashrc_section(c, marker: str, content: str) -> None:
"""Idempotently add a named section to ~/.bashrc, replacing it if it already exists."""
import re
begin = f"# BEGIN {marker}"
end = f"# END {marker}"
section = f"\n{begin}\n{content.strip()}\n{end}\n"
bashrc = Path(BASHRC).expanduser()
text = bashrc.read_text()
text = re.sub(rf"\n?{re.escape(begin)}.*?{re.escape(end)}\n?", "", text, flags=re.DOTALL)
bashrc.write_text(text + section)
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
# Must be sourced: source init.sh
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
echo "Error: run as 'source init.sh' or '. init.sh'" >&2
exit 1
fi
uv sync
source .venv/bin/activate
-19
View File
@@ -1,19 +0,0 @@
[project]
name = "cli-tools"
version = "0.1.0"
description = "Dev environment CLI tools"
readme = "README.md"
requires-python = ">=3.10"
dependencies = ["typer"]
[project.scripts]
cli-tools = "cli_tools.cli:app"
[build-system]
requires = ["uv_build"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"ruff>=0.15.9",
]
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
alias venv='source .venv/bin/activate'
alias docker_stop_all='docker stop $(docker ps -a -q)'
alias uvs="uv sync --all-extras"
alias clip='xclip -selection clipboard'
alias open_ports='sudo ss -tulwn | grep LISTEN'
alias bat='batcat'
alias claude-allow="claude --dangerously-skip-permissions"
alias reload='source ~/.bashrc'
alias ls='eza'
alias ll='eza -la --git'
alias fd='fdfind'
alias lg='lazygit'
-160
View File
@@ -1,160 +0,0 @@
#!/bin/bash
# This file contains helper functions for bash scripts.
# Source it in your bash script to use the functions.
#-------------------------------------
# Docker-related functions
# Attach to a running Docker container.
attach(){
docker exec -it "$1" bash
}
# Enter development shell in a Docker container using a Python image.
# Flags: --new/-n remove existing container first.
python-dev() {
local IMG="python-dev"
local NAME="python-dev"
while [[ $# -gt 0 ]]; do
case "$1" in
--help|-h) echo "Usage: python-dev [--new|-n]"; echo " --new, -n Remove existing container before starting"; return 0 ;;
--new|-n) docker rm -f "$NAME" &>/dev/null; shift ;;
*) echo "Unknown option: $1"; return 1 ;;
esac
done
local built
built="$(docker image inspect --format '{{.Created}}' "$IMG" 2>/dev/null)"
[[ -n "$built" ]] && echo "Image built: $(date -d "$built" '+%Y-%m-%d %H:%M:%S')"
local PROJECT_NAME
PROJECT_NAME="$(basename "$(pwd)")"
if docker container inspect "$NAME" &>/dev/null; then
local STATE
STATE="$(docker inspect -f '{{.State.Status}}' "$NAME")"
if [[ "$STATE" == "running" ]]; then
echo "Attaching to existing container '$NAME'"
docker exec -it -e PROJECT_NAME="$PROJECT_NAME" "$NAME" bash
else
echo "Restarting stopped container '$NAME'"
docker start -ai "$NAME"
fi
return $?
fi
echo "Creating new container '$NAME'"
docker run -it \
--name="$NAME" \
--network=host \
-e PROJECT_NAME="$PROJECT_NAME" \
-v "$(pwd):/workspace" \
-v /workspace/.venv \
-v "$HOME/.ssh:/home/dev/.ssh:ro" \
-v "$HOME/.gitconfig:/home/dev/.gitconfig:ro" \
-w /workspace "$IMG" \
bash
}
#-------------------------------------
# CAN and serial functions
# Launch CAN viewer for the given interface (default: slcan0).
canview() {
if [ -z "$CAN_CHANNEL" ]; then
echo "Warning: CAN_CHANNEL not set. Using default can0"
CAN_CHANNEL=can0
fi
if [ -z "$CAN_INTERFACE" ]; then
echo "Warning: CAN_INTERFACE not set. Using default slcan0"
CAN_INTERFACE=socketcan
fi
python3 -m can.viewer -c "$CAN_CHANNEL" -i "$CAN_INTERFACE"
}
# Connect to a serial device using Picocom (default: /dev/ttyACM0).
repl(){
if [ -z "$AMPY_PORT" ]; then
echo "Warning: AMPY_PORT not set. Using default /dev/ttyACM0"
AMPY_PORT=/dev/ttyACM0
fi
echo "Connecting to $AMPY_PORT"
picocom -b 115200 $AMPY_PORT
}
#-------------------------------------
# SSHFS and network functions
# Mount a remote directory over SSHFS. Takes a remote path as an argument.
function mount_ssh() {
if [ -z "$1" ]; then
echo "No argument provided."
return 1
fi
echo "Mounting $1"
mkdir -p "$1"
sshfs -o allow_other "$1:" "$1"
}
#-------------------------------------
# Miscellaneous functions
# Find a file and display its contents.
find_and_cat() {
if [[ -z $1 ]]; then
echo "Usage: find_and_cat <filename>"
return 1
fi
find . -type f -name "$1" -print0 | while IFS= read -r -d '' file; do
echo -e "File Location: $file\n"
echo "File Contents:"
cat "$file"
echo -e "\n---\n"
done
}
# record_window
record_window() {
local outfile="recording_$(date +%F_%H-%M-%S).mp4"
echo "Click on the window you want to record..."
local wininfo=$(xwininfo)
local width=$(echo "$wininfo" | grep "Width:" | awk '{print $2}')
local height=$(echo "$wininfo" | grep "Height:" | awk '{print $2}')
local x=$(echo "$wininfo" | grep "Absolute upper-left X:" | awk '{print $4}')
local y=$(echo "$wininfo" | grep "Absolute upper-left Y:" | awk '{print $4}')
echo "Recording window at ${width}x${height}+${x},${y} to $outfile..."
ffmpeg -video_size ${width}x${height} -framerate 30 -f x11grab -i :0.0+${x},${y} \
-vcodec libx264 -preset fast -pix_fmt yuv420p -movflags +faststart "$outfile"
}
fdcat() {
# 1. Run fdfind with all passed arguments and pipe to fzf
# Usage: fdcat -e py logging
# Usage: fdcat -e js auth
local file=$(fdfind "$@" | fzf --preview 'batcat --color=always --style=numbers {}')
# 2. Copy and print if a selection was made
if [ -n "$file" ]; then
# Adjust 'xclip -sel clip' if you are on WSL (use clip.exe) or Mac (use pbcopy)
echo -n "$file" | xclip -sel clip
echo "✔ Copied: $file"
else
echo "Exit: No file selected."
fi
}
#---- Aliases
source ~/.aliases.sh
+68
View File
@@ -0,0 +1,68 @@
#!/bin/bash
set -euo pipefail
echo "=== Installing LXD on Debian Bookworm ==="
# Check if snapd is installed
if ! command -v snap &> /dev/null; then
echo "Installing snapd..."
sudo apt update
sudo apt install -y snapd
echo "Enabling snapd service..."
sudo systemctl enable --now snapd.socket
# Wait for snapd to be ready
sleep 5
else
echo "snapd already installed"
fi
# Check if LXD is already installed
if snap list lxd &> /dev/null; then
echo "LXD already installed via snap"
else
echo "Installing LXD via snap..."
sudo snap install lxd
fi
# Check if LXD is initialized
if sudo lxd init --dump &> /dev/null; then
echo "LXD already initialized"
else
echo "Initializing LXD with default settings..."
sudo lxd init --auto
fi
echo ""
echo "Configuring LXD network for Docker compatibility..."
lxc network set lxdbr0 ipv4.firewall false 2>/dev/null || true
lxc network set lxdbr0 ipv6.firewall false 2>/dev/null || true
lxc network set lxdbr0 ipv4.nat true 2>/dev/null || true
LXD_SUBNET=$(lxc network get lxdbr0 ipv4.address)
if ! sudo iptables -t nat -C POSTROUTING -s "$LXD_SUBNET" ! -d "$LXD_SUBNET" -j MASQUERADE 2>/dev/null; then
sudo iptables -t nat -I POSTROUTING -s "$LXD_SUBNET" ! -d "$LXD_SUBNET" -j MASQUERADE
fi
if command -v docker &>/dev/null; then
if ! sudo iptables -C DOCKER-USER -i lxdbr0 -j ACCEPT 2>/dev/null; then
sudo iptables -I DOCKER-USER -i lxdbr0 -j ACCEPT
fi
if ! sudo iptables -C DOCKER-USER -o lxdbr0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT 2>/dev/null; then
sudo iptables -I DOCKER-USER -o lxdbr0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
fi
fi
echo "✓ LXD installation complete!"
echo " Default network bridge (lxdbr0) configured for internet access"
# Add user to lxd group for passwordless access
if groups "$USER" | grep -q "\blxd\b"; then
echo " User '$USER' already in lxd group"
else
echo "Adding user '$USER' to lxd group..."
sudo usermod -a -G lxd "$USER"
echo "✓ User added to lxd group"
echo ""
echo "IMPORTANT: You need to log out and back in for group changes to take effect"
echo "Or run: newgrp lxd"
fi
+1
View File
@@ -0,0 +1 @@
#export FZF_DEFAULT_OPTS='--bind ctrl-f:preview-page-down,ctrl-b:preview-page-up --preview "batcat --color=always {}"'
+1
View File
@@ -0,0 +1 @@
eval "$(zoxide init bash)"
View File
-13
View File
@@ -1,13 +0,0 @@
"""CLI entry point."""
import typer
from cli_tools import credentials, docker, install
app = typer.Typer(help="Dev environment CLI tools.", no_args_is_help=True)
app.add_typer(install.app, name="install")
app.add_typer(docker.app, name="docker")
app.add_typer(credentials.app, name="credentials")
if __name__ == "__main__":
app()
-19
View File
@@ -1,19 +0,0 @@
"""Credential management commands."""
import typer
from cli_tools.helpers import run
app = typer.Typer(help="Encrypt and decrypt credentials.", no_args_is_help=True)
@app.command("encrypt-env")
def encrypt_env():
"""Encrypt .env -> .env.gpg."""
run("gpg -c .env")
@app.command("decrypt-env")
def decrypt_env():
"""Decrypt .env.gpg -> .env."""
run("gpg -d .env.gpg > .env")
-51
View File
@@ -1,51 +0,0 @@
"""Docker image commands."""
import os
import subprocess
import typer
from cli_tools.helpers import run
app = typer.Typer(help="Build and manage Docker images.", no_args_is_help=True)
DOCKER_IMAGE = "python-dev"
DOCKER_DIR = "docker/python-dev"
DOCKERHUB_IMAGE = "roxauto/python-dev"
@app.command()
def build(
tag: str = typer.Option("latest", help="Image tag."),
no_cache: bool = typer.Option(False, "--no-cache", help="Build without cache."),
):
"""Build the python-dev Docker image locally."""
uid = os.getuid()
gid = os.getgid()
full_tag = f"{DOCKER_IMAGE}:{tag}"
run(f"cp ai-tools/claude/CLAUDE.md {DOCKER_DIR}/CLAUDE.md")
run(f"cp scripts/aliases.sh {DOCKER_DIR}/aliases.sh")
run(f"cp scripts/bash_helpers.sh {DOCKER_DIR}/bash_helpers.sh")
run(
f"docker build -t {full_tag} "
f"--build-arg UID={uid} --build-arg GID={gid} "
f"{'--no-cache ' if no_cache else ''}"
f"--network=host {DOCKER_DIR}",
)
@app.command()
def push(tag: str = typer.Option("latest", help="Image tag.")):
"""Tag and push the python-dev image to DockerHub."""
token = os.environ.get("DOCKERHUB_TOKEN")
if not token:
typer.echo("DOCKERHUB_TOKEN env variable is not set.", err=True)
raise typer.Exit(1)
subprocess.run(
["docker", "login", "-u", "roxauto", "--password-stdin"],
input=token,
text=True,
check=True,
)
run(f"docker tag {DOCKER_IMAGE}:{tag} {DOCKERHUB_IMAGE}:{tag}")
run(f"docker push {DOCKERHUB_IMAGE}:{tag}")
-35
View File
@@ -1,35 +0,0 @@
"""Shared helpers for CLI commands."""
import re
import subprocess
from pathlib import Path
YELLOW = "\033[33m"
RESET = "\033[0m"
BASHRC = Path("~/.bashrc").expanduser()
SNIPPETS_DIR = Path(__file__).parent.parent.parent / "snippets"
def load_snippet(name: str) -> str:
"""Read a shell snippet from the snippets directory."""
return (SNIPPETS_DIR / f"{name}.sh").read_text()
def run(cmd: str, pty: bool = False) -> None:
"""Print command in yellow then execute it."""
print(f"{YELLOW}$ {cmd}{RESET}")
subprocess.run(cmd, shell=True, check=True)
def append_bashrc_section(marker: str, content: str) -> None:
"""Idempotently add a named section to ~/.bashrc."""
begin = f"# BEGIN {marker}"
end = f"# END {marker}"
section = f"\n{begin}\n{content.strip()}\n{end}\n"
text = BASHRC.read_text()
text = re.sub(
rf"\n?{re.escape(begin)}.*?{re.escape(end)}\n?", "", text, flags=re.DOTALL
)
BASHRC.write_text(text + section)
-209
View File
@@ -1,209 +0,0 @@
"""Install commands."""
import shlex
from pathlib import Path
import typer
from cli_tools.helpers import append_bashrc_section, run
app = typer.Typer(
help="Install tools and configure the environment.", no_args_is_help=True
)
APT_PACKAGES = [
"git",
"curl",
"micro",
"mc",
"detox",
"tree",
"ripgrep",
"fd-find",
"btop",
]
PYTHON_TOOLS = [
"cruft",
"invoke",
]
@app.command()
def core():
"""Install essential tools."""
apt_packages()
docker()
uv()
uv_tools()
fzf()
zoxide()
lazygit()
eza()
helpers()
@app.command()
def ai_tools():
"""Install AI tools and configure skills."""
claude()
copilot()
ai_skills()
ccusage()
@app.command()
def claude():
"""Install Claude Code via the official install script."""
run("curl -fsSL https://claude.ai/install.sh | bash")
@app.command()
def copilot():
"""Install GitHub Copilot via the official install script."""
run("curl -fsSL https://gh.io/copilot-install | bash")
@app.command("apt-packages")
def apt_packages():
"""Update apt cache and install base packages."""
run("sudo apt-get update")
run(f"sudo apt-get install -y {' '.join(APT_PACKAGES)}")
@app.command()
def docker():
"""Install Docker and add current user to docker group."""
import getpass
run(
"command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sudo sh"
)
run(f"sudo usermod -aG docker {getpass.getuser()}")
@app.command()
def uv():
"""Install uv for the current user."""
run("test -f ~/.local/bin/uv || curl -LsSf https://astral.sh/uv/install.sh | sh")
@app.command()
def uv_tools():
"""Install Python tools using uv."""
for tool in PYTHON_TOOLS:
run(f"uv tool install --force {tool}")
@app.command()
def ccusage():
"""Install ccusage for monitoring code complexity."""
run("npm install -g ccusage")
@app.command()
def fzf():
"""Install fzf from git and bat for preview."""
run("sudo apt-get remove -y fzf 2>/dev/null || true")
run("sudo apt-get install -y bat")
run(
"test -d ~/.fzf || git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf"
)
run("~/.fzf/install --all")
@app.command()
def zoxide():
"""Install zoxide and configure .bashrc."""
run("sudo apt install -y zoxide")
append_bashrc_section("zoxide", 'eval "$(zoxide init bash)"')
@app.command()
def helpers():
"""Symlink bash_helpers.sh and aliases.sh into home directory."""
repo = Path(__file__).resolve().parents[2]
bash_helpers = repo / "scripts" / "bash_helpers.sh"
aliases = repo / "scripts" / "aliases.sh"
if not bash_helpers.is_file() or not aliases.is_file():
typer.secho(
"This command must be run from the checked-out cli-tools repo "
"(for example with `uv run cli-tools install helpers`).",
fg=typer.colors.RED,
err=True,
)
raise typer.Exit(code=1)
run(f"ln -sf {shlex.quote(str(bash_helpers))} ~/.bash_helpers.sh")
run(f"ln -sf {shlex.quote(str(aliases))} ~/.aliases.sh")
append_bashrc_section("bash_helpers", "source ~/.bash_helpers.sh")
@app.command()
def lazygit():
"""Install lazygit (terminal UI for git)."""
run(
"command -v lazygit >/dev/null 2>&1 || ("
"LAZYGIT_VERSION=$(curl -s https://api.github.com/repos/jesseduffield/lazygit/releases/latest"
" | grep '\"tag_name\"' | cut -d'\"' -f4 | sed 's/v//') && "
"curl -Lo /tmp/lazygit.tar.gz"
" https://github.com/jesseduffield/lazygit/releases/latest/download/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz && "
"tar -xf /tmp/lazygit.tar.gz -C /tmp lazygit && "
"sudo install /tmp/lazygit /usr/local/bin)"
)
@app.command()
def eza():
"""Install eza (modern ls replacement) via official deb repo."""
run("sudo mkdir -p /etc/apt/keyrings")
run(
"wget -qO- https://raw.githubusercontent.com/eza-community/eza/main/deb.asc"
" | sudo gpg --dearmor -o /etc/apt/keyrings/gierens.gpg"
)
run(
"echo 'deb [signed-by=/etc/apt/keyrings/gierens.gpg] http://deb.gierens.de stable main'"
" | sudo tee /etc/apt/sources.list.d/gierens.list"
)
run(
"sudo chmod 644 /etc/apt/keyrings/gierens.gpg /etc/apt/sources.list.d/gierens.list"
)
run("sudo apt update && sudo apt install -y eza")
@app.command("ai-skills")
def ai_skills():
"""Symlink Claude Code config, skills, agents, and Copilot skills globally."""
repo = Path(__file__).resolve().parents[2]
claude_src = repo / "ai-tools" / "claude"
skills_src = repo / "ai-tools" / "skills"
agents_src = repo / "ai-tools" / "agents"
claude_home = Path.home() / ".claude"
claude_skills = Path.home() / ".claude" / "skills"
claude_agents = Path.home() / ".claude" / "agents"
github_skills = Path.home() / ".github" / "skills"
claude_home.mkdir(parents=True, exist_ok=True)
for src in sorted(claude_src.iterdir()):
dest = claude_home / src.name
run(f"ln -sfn {shlex.quote(str(src))} {shlex.quote(str(dest))}")
claude_skills.mkdir(parents=True, exist_ok=True)
for skill_dir in sorted(skills_src.iterdir()):
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
dest = claude_skills / skill_dir.name
run(f"ln -sfn {shlex.quote(str(skill_dir))} {shlex.quote(str(dest))}")
claude_agents.mkdir(parents=True, exist_ok=True)
for agent_file in sorted(agents_src.glob("*.md")):
dest = claude_agents / agent_file.name
run(f"ln -sf {shlex.quote(str(agent_file))} {shlex.quote(str(dest))}")
github_skills.mkdir(parents=True, exist_ok=True)
for skill_dir in sorted(claude_skills.iterdir()):
if skill_dir.is_dir():
dest = github_skills / skill_dir.name
run(f"ln -sfn {shlex.quote(str(skill_dir))} {shlex.quote(str(dest))}")
+79
View File
@@ -0,0 +1,79 @@
import getpass
from invoke import task
from helpers import append_bashrc_section, load_snippet, run
APT_PACKAGES = [
"git",
"curl",
"micro",
"mc",
"detox",
"tree",
]
@task
def install_claude(c):
"""Install Claude Code via the official install script."""
run(c, "curl -fsSL https://claude.ai/install.sh | bash")
@task
def install_copilot(c):
"""Install GitHub Copilot via the official install script."""
run(c, "curl -fsSL https://gh.io/copilot-install | bash")
@task
def install_apt_packages(c):
"""Update apt cache and install base packages."""
run(c, "sudo apt-get update")
run(c, f"sudo apt-get install -y {' '.join(APT_PACKAGES)}")
@task
def install_docker(c):
"""Install Docker if not already installed and add current user to docker group."""
run(
c,
"command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sudo sh",
)
run(c, f"sudo usermod -aG docker {getpass.getuser()}")
@task
def install_uv(c):
"""Install uv for the current user if not already installed."""
run(c, "test -f ~/.local/bin/uv || curl -LsSf https://astral.sh/uv/install.sh | sh")
@task
def install_fzf(c):
"""Install fzf from git, bat for preview, and configure .bashrc."""
run(c, "sudo apt-get install -y bat")
run(
c,
"test -d ~/.fzf || git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf",
)
run(c, "~/.fzf/install --all")
append_bashrc_section(c, "fzf", load_snippet("fzf"))
@task
def install_zoxide(c):
"""Install zoxide, configure .bashrc, """
run(c, "sudo apt install -y zoxide fzf")
append_bashrc_section(c, "zoxide", load_snippet("zoxide"))
@task(install_apt_packages, install_docker, install_uv, install_claude, install_fzf, install_zoxide)
def bootstrap(c):
"""Install all base tools."""
@task
def test(c):
"""Run tasks in an ephemeral LXD container."""
run(c, "pytest tests/ -v -s")
+84
View File
@@ -0,0 +1,84 @@
import subprocess
import time
from collections.abc import Generator
import pytest
CONTAINER = "cli-tools-test"
IMAGE = "images:debian/bookworm"
REPO_ROOT = "/home/jev/projects/cli-tools"
def _lxc(*args: str, capture: bool = False) -> subprocess.CompletedProcess:
return subprocess.run(
["lxc", *args],
check=True,
capture_output=capture,
text=capture,
)
def _wait_for_network(container: str, retries: int = 20, delay: float = 2.0) -> None:
print("[container] Waiting for network...", flush=True)
for _ in range(retries):
result = subprocess.run(
["lxc", "exec", container, "--", "bash", "-c", "ping -c1 8.8.8.8 &>/dev/null"],
capture_output=True,
)
if result.returncode == 0:
print("[container] Network ready", flush=True)
return
time.sleep(delay)
raise RuntimeError(f"Container {container!r} did not get network access")
@pytest.fixture(scope="session")
def container() -> Generator[str, None, None]:
# Clean up any stale container from a previous run
subprocess.run(["lxc", "delete", "--force", CONTAINER], capture_output=True)
print(f"\n[container] Launching {CONTAINER}...", flush=True)
_lxc("launch", IMAGE, CONTAINER)
time.sleep(3)
_wait_for_network(CONTAINER)
print("[container] Installing sudo and python3-pip...", flush=True)
_lxc(
"exec", CONTAINER, "--",
"bash", "-c",
"""
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo python3-pip
""",
)
print("[container] Creating jev user...", flush=True)
_lxc(
"exec", CONTAINER, "--",
"bash", "-c",
"""
useradd -m -s /bin/bash -u 1000 jev
usermod -aG sudo jev
echo 'jev ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/jev
chmod 440 /etc/sudoers.d/jev
""",
)
print("[container] Installing invoke...", flush=True)
_lxc(
"exec", CONTAINER, "--",
"bash", "-c",
"pip3 install invoke --quiet --break-system-packages",
)
print("[container] Mounting repo...", flush=True)
_lxc("config", "device", "add", CONTAINER, "repo", "disk",
f"source={REPO_ROOT}", "path=/home/jev/cli-tools")
print("[container] Ready\n", flush=True)
yield CONTAINER
print(f"\n[container] Destroying {CONTAINER}...", flush=True)
_lxc("delete", "--force", CONTAINER)
+46
View File
@@ -0,0 +1,46 @@
import subprocess
def lxc_exec(container: str, cmd: str, check: bool = True) -> subprocess.CompletedProcess:
"""Run a command in the container as the jev user."""
return subprocess.run(
["lxc", "exec", container, "--user", "1000", "--env", "HOME=/home/jev", "--", "bash", "-lc", cmd],
check=check,
)
def run_task(container: str, task: str) -> None:
lxc_exec(container, f"cd /home/jev/cli-tools && invoke {task.replace('_', '-')}")
def test_install_apt_packages(container: str) -> None:
run_task(container, "install_apt_packages")
for binary in ["git", "micro", "tree", "detox"]:
result = lxc_exec(container, f"which {binary}", check=False)
assert result.returncode == 0, f"{binary!r} not found after install_apt_packages"
def test_install_docker(container: str) -> None:
run_task(container, "install_docker")
result = lxc_exec(container, "docker --version", check=False)
assert result.returncode == 0, "docker not found after install_docker"
def test_install_uv(container: str) -> None:
run_task(container, "install_uv")
result = lxc_exec(container, "~/.local/bin/uv --version", check=False)
assert result.returncode == 0, "uv not found after install_uv"
def test_install_fzf(container: str) -> None:
run_task(container, "install_fzf")
result = lxc_exec(container, "test -f ~/.fzf/bin/fzf", check=False)
assert result.returncode == 0, "fzf binary not found at ~/.fzf/bin/fzf after install_fzf"
result = lxc_exec(container, "which batcat", check=False)
assert result.returncode == 0, "batcat not found after install_fzf"
def test_install_zoxide(container: str) -> None:
run_task(container, "install_zoxide")
result = lxc_exec(container, "which zoxide", check=False)
assert result.returncode == 0, "zoxide not found after install_zoxide"
Generated
-144
View File
@@ -1,144 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "cli-tools"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "typer" },
]
[package.dev-dependencies]
dev = [
{ name = "ruff" },
]
[package.metadata]
requires-dist = [{ name = "typer" }]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.9" }]
[[package]]
name = "click"
version = "8.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "ruff"
version = "0.15.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" },
{ url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" },
{ url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" },
{ url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" },
{ url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" },
{ url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" },
{ url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" },
{ url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" },
{ url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" },
{ url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" },
{ url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" },
{ url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" },
{ url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" },
{ url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "typer"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
]