Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bba76aa4d | ||
|
|
9b85a95552 | ||
|
|
2213251756 | ||
|
|
a057cdf488 | ||
|
|
251b2729c2 | ||
|
|
06c1a3a1be | ||
|
|
74bf6eef04 | ||
|
|
28e8028a02 | ||
|
|
71e5198d4c | ||
|
|
ef38f3be4c | ||
|
|
db9643eb58 | ||
|
|
193336967b | ||
|
|
f6d31285ea | ||
|
|
d4b265ad45 |
@@ -44,6 +44,11 @@
|
||||
"matcher": "",
|
||||
"max_per_session": 10
|
||||
},
|
||||
"feedback_pulse": {
|
||||
"enabled": false,
|
||||
"handler": "aipass.hooks.apps.handlers.prompt.feedback_pulse.handle",
|
||||
"matcher": ""
|
||||
},
|
||||
"auto_process": {
|
||||
"enabled": true,
|
||||
"handler": "aipass.hooks.apps.handlers.lifecycle.auto_process.handle",
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
"cli": {
|
||||
"claude": {
|
||||
"hooks": [
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:presence_gate", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:persistent_alert", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:tier0_kernel", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:navmap", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:branch_prompt", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:identity_injector", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:email_notification", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:compass_recall", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:auto_process", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:user_message_relay", "event": "UserPromptSubmit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse", "event": "PreToolUse", "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PostToolUse", "event": "PostToolUse", "matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"},
|
||||
{"command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SubagentStop", "event": "SubagentStop"},
|
||||
|
||||
@@ -41,6 +41,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
- uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
languages: python
|
||||
- uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
|
||||
@@ -124,6 +124,12 @@ src/aipass/*/apps/integrations/**
|
||||
src/aipass/hooks/tools/*
|
||||
!src/aipass/hooks/tools/install_boot_shim.sh
|
||||
|
||||
# User/sample projects — each is its own git repo (git init on create).
|
||||
# Contents never belong to the AIPass repo; only the catalog README is tracked
|
||||
# so the public repo can point at the standalone project repos.
|
||||
projects/*
|
||||
!projects/README.md
|
||||
|
||||
# CI artifacts
|
||||
windows-pytest-results/
|
||||
|
||||
|
||||
+122
@@ -9,8 +9,130 @@ PyPI version — not the changelog header.
|
||||
|
||||
---
|
||||
|
||||
## [2026-07-18]
|
||||
|
||||
**docs** — root README v3 restructure (DPLAN-0249): single-funnel story with
|
||||
zero duplicated commands (install, `aipass new`/`init run`, trees, drone
|
||||
examples each taught exactly once), hero link line to aipass.ai/PyPI/r/AIPass,
|
||||
three reserved gif slots. Positioning ruling: the README tells only the
|
||||
Claude Code on Linux/WSL story — Codex/macOS/Windows mentions and the Roadmap
|
||||
section removed (code support unchanged; Docker distribution will serve those
|
||||
users later). Earlier same day: stale demo.gif embed dropped (#701) and
|
||||
aipass.ai realigned to the v2.7.3 front door.
|
||||
|
||||
**v2.7.3** — the onboarding chain: from `git clone` to a conversation with an
|
||||
agent that remembers you. Install's three dead-ends are gone — the default
|
||||
`init` path, headless runs, and `aipass new` all now end where they should:
|
||||
`install` chains through the guided init and **opens a live conversation with
|
||||
the AIPass concierge**, first prompt authored with the install report in its
|
||||
context. The concierge's Welcome Mode (research-backed opener, one name-ask,
|
||||
deferred setup triage, hooks-first health check via a real @hooks dispatch,
|
||||
every suggestion with its exact command) was proven in a live multi-turn
|
||||
door-test — including the second-session payoff: relaunch, and it picks up
|
||||
mid-task where you left off. Plus `aipass new` and the front-door overhaul below.
|
||||
|
||||
### Added (onboarding chain — TDPLAN-0014)
|
||||
|
||||
- **Install→chat handoff**: after init returns, install `launch_inline`s the
|
||||
concierge with an authored first prompt (fresh-install recognition + binary
|
||||
report). TTY-only; headless returns cleanly.
|
||||
- **Welcome Mode** in the concierge branch prompt: capability opener with 3–5
|
||||
concrete starters, single graceful name-ask, ~turn-5 setup push ("every
|
||||
machine is different"), hooks-first verification incl. trust-registry
|
||||
enrollment, setup plan seeded from the cross-OS checklist, Windows→WSL
|
||||
recommendation, prax-monitor + hooksound tips, exact copy-paste command with
|
||||
every suggestion.
|
||||
- **Feedback pulse** (@hooks): one ignorable line every ~10 turns with the repo
|
||||
feedback link — `aipass feedback on/off` (alias for `drone @hooks feedback`)
|
||||
turns it off. Registered disabled for the AIPass host itself. 25 tests.
|
||||
- **Dead-end kills**: empty-template init now runs handoff + report stages
|
||||
(default path ends in the conversation); non-interactive installs complete
|
||||
with defaults and exit 0 (headless stage 9 prints the launch command instead
|
||||
of spawning); `aipass new` auto-launches into the new project's manager agent
|
||||
on a TTY with a printed fallback and Ctrl-C escape line.
|
||||
- **Unified handoff prompt**: one `INIT_PROMPT` constant (was two drifting
|
||||
strings in init_flow vs handoff).
|
||||
|
||||
### Fixed (onboarding chain)
|
||||
|
||||
- Non-TTY `aipass init run` crashed with EOFError at the first prompt (caught
|
||||
in a live door-test after unit suites ran green — the prompt layer now
|
||||
auto-detects non-TTY and takes defaults).
|
||||
- `aipass new` outside an AIPass environment now exits 1 instead of 0.
|
||||
- Empty-template handoff messaging no longer claims an agent exists
|
||||
("Your project is ready", resolved absolute path instead of `cd .`).
|
||||
- Stage numbering shows a skip notice instead of silently jumping 5→8.
|
||||
|
||||
## [2026-07-17]
|
||||
|
||||
**v2.7.3 (first pass)** — `aipass new` and the front-door overhaul. The `projects/`
|
||||
directory is now a first-class playground: `aipass new <name>` creates a fully
|
||||
isolated project — own registry, own git repo with a birth commit, born
|
||||
deployable — with a full framework resident agent that answers
|
||||
`drone @<name>` from inside the project while staying invisible to the host
|
||||
roster. ai_mail enforces the project boundary (cross-project mail is refused
|
||||
with a pointer to the feedback channel). A live door-test of the aipass CLI
|
||||
exposed a blind spot in the audit — perfect structural scores on an unusable
|
||||
front door — so seedgo grew two user-facing-quality standards (`cli_ux`,
|
||||
`readme_quality`) and a fleet-100 campaign brought every branch's help output
|
||||
and README to the house pattern: 17/17 branches at 100%.
|
||||
|
||||
### Added
|
||||
|
||||
- **`aipass new <name>`** (module + handler): creates `projects/<name>` with
|
||||
registry-first credential linkage (`registry.metadata.id ==
|
||||
passport.citizenship.registry_id`), empty/python templates, interactive
|
||||
template + agent prompts (flags for scripted use), a full framework agent
|
||||
(entry point, modules/handlers skeleton, trinity set, mailbox, tier files),
|
||||
git init + birth commit, and next-step output. 49 tests.
|
||||
- **seedgo standards 41–42**: `cli_ux` (8 AST checks — two-tier help, Rich
|
||||
console, styled title, purpose line, --help pointer, Usage, Examples, no
|
||||
exposed internal plumbing) and `readme_quality` (Quick Start with runnable
|
||||
code block, stranger accessibility, invoke/entry-point match, early
|
||||
what-description). 36 tests + case-resolution regression tests.
|
||||
- **ai_mail cross-project boundary**: sender and recipient project roots
|
||||
compared on delivery; cross-project sends refused with a feedback-channel
|
||||
pointer. Fail-open for internal/unregistered sends. 13 tests.
|
||||
- **Root `.gitignore`**: `projects/*` ignored (each project is its own repo);
|
||||
only the future catalog README stays trackable.
|
||||
|
||||
### Added (second pass — the agent becomes a real citizen)
|
||||
|
||||
- **`aipass new` agents are now spawn-issued full citizens** (FPLAN-0334): the
|
||||
hand-rolled scaffold in the new_project handler is retired for a
|
||||
`spawn_agent()` call against @spawn's new `project_agent` template — branch
|
||||
prompt, structured mailbox, birth certificate, trinity trio, dashboard,
|
||||
house-pattern entry point, and a branch-style README. One authority issues
|
||||
citizens; project agents inherit template evolution for free.
|
||||
- **Agent home = `src/<project>/<agent>/`**, mirroring the host's
|
||||
`src/aipass/<branch>` layout (door-test ruling: the project root is never an
|
||||
agent home). Seat paths are relative like host seats; the registry walk stops
|
||||
at the first project registry. The first agent is the project's **manager**
|
||||
(`citizen_class: manager` — its devpulse), named after the project.
|
||||
- **Birth-commit hygiene**: the `.venv` symlink (absolute host path) and the
|
||||
registry lock file are no longer tracked in new projects' birth commits.
|
||||
- **Boundary verified live in all four directions**: host↔project email and
|
||||
dispatch all refused — project→host lands on the ai_mail cross-project check
|
||||
with its feedback-channel pointer, closing the leak found in the S319
|
||||
prototype probes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **aipass front door rebuilt**: `--help` now follows the house pattern with a
|
||||
curated command list, usage, and examples (internal plumbing hidden —
|
||||
`doctor_fix`/`doctor_wire` renamed underscore-private); `aipass help` shows
|
||||
the Q&A screen instead of falling through to the module dump; README
|
||||
rewritten to pass the stranger test with a Quick Start and the correct
|
||||
invocation.
|
||||
- **Fleet-100 sweep**: 15 branches gained Quick Start READMEs and/or
|
||||
Usage/Examples help sections — each owner fixed their own front against the
|
||||
new gate.
|
||||
- **Debug_Print detector hardened**: the regex-based checker matched `print(`
|
||||
inside string literals (flagging cli_ux_check's own error messages — the
|
||||
auditor was the last branch under 100%). String content is now stripped
|
||||
before matching, with a regression test; plus a depth-5 nesting refactor in
|
||||
the same file.
|
||||
|
||||
**v2.7.2** — everything merged since v2.7.1, headlined by the compass decision
|
||||
engine v2: curation with supersedes links + write-time conflict advisories
|
||||
(Track 1), and ambient recall — rated past decisions now surface verbatim into
|
||||
|
||||
@@ -12,8 +12,15 @@
|
||||
</p>
|
||||
<p align="center"><strong>Persistent Agent Workspace</strong></p>
|
||||
<p align="center"><em>AI agents that remember, collaborate, and never start from zero.</em></p>
|
||||
<p align="center">
|
||||
<a href="https://aipass.ai">aipass.ai</a> ·
|
||||
<a href="https://pypi.org/project/aipass/">PyPI</a> ·
|
||||
<a href="https://reddit.com/r/AIPass">r/AIPass</a> ·
|
||||
<a href="https://github.com/AIOSAI/AIPass/discussions">Discussions</a>
|
||||
</p>
|
||||
|
||||

|
||||
<!-- GIF SLOT 1 — hero (~20s): clone → ./aipass install → live conversation with the concierge.
|
||||
 -->
|
||||
|
||||
---
|
||||
|
||||
@@ -27,51 +34,15 @@ That's not a team. That's a room full of people wearing headphones.
|
||||
|
||||
## What AIPass Does
|
||||
|
||||
AIPass is a CLI-native scaffold that adds **persistent memory, identity, and coordination** to your AI agents. You bring your project — AIPass adds the agent layer on top. No UI, no dashboard. You work in your terminal.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass
|
||||
./aipass install # installs everything, then walks you into your first project
|
||||
```
|
||||
|
||||
One command does it all: builds the environment, puts `aipass` + `drone` on your PATH, then chains straight into a guided init that creates your project, your first agent, and opens a terminal where that agent is already running. Say "hi" — it knows who it is. Come back tomorrow — it remembers.
|
||||
|
||||
This is the base framework. It gives your agents the infrastructure to persist, communicate, and organize — everything else you build on top.
|
||||
|
||||
Here's what lands in your project:
|
||||
|
||||
```
|
||||
my-project/
|
||||
├── .aipass/ # Project config + prompts
|
||||
├── .claude/ # Hooks (injected automatically)
|
||||
├── src/my_project/
|
||||
│ └── my_agent/
|
||||
│ ├── .trinity/ # Identity + memory (3 JSON files)
|
||||
│ ├── .ai_mail.local/ # Local mailbox
|
||||
│ ├── apps/ # Your agent's code
|
||||
│ └── README.md # Domain knowledge
|
||||
├── CLAUDE.md # Project instructions
|
||||
└── MY-PROJECT_REGISTRY.json
|
||||
```
|
||||
|
||||
Everything is plain files. No daemon, no hidden state. Delete the directory and it's gone.
|
||||
|
||||
**Start with one agent.** Add more when you need them:
|
||||
|
||||
```bash
|
||||
aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
||||
```
|
||||
|
||||
**What makes this different:**
|
||||
AIPass is a CLI-native scaffold that adds **persistent memory, identity, and coordination** to your AI agents. You bring your project — AIPass adds the agent layer on top. No UI, no dashboard, no cloud. Everything is plain files on your machine; delete the directory and it's gone.
|
||||
|
||||
- **Agents are persistent.** They remember across sessions. Expertise develops over time. Nobody starts from zero.
|
||||
- **Bring your own project.** AIPass adds agent infrastructure to whatever you're building. It's a scaffold, not a product — you shape it.
|
||||
- **Everything is local.** Memory is JSON files. Communication is local mailbox files. No cloud, no external APIs.
|
||||
- **Shared workspace.** All agents work on the same filesystem, same project, same time. No sandboxes.
|
||||
- **One command for everything.** AIPass ships with `drone`, a CLI router — `drone @agent command` reaches any agent. Learn it once, use it everywhere.
|
||||
- **One command for everything.** `drone @agent command` reaches any agent. Learn it once, use it everywhere.
|
||||
|
||||
**Runs on your existing CLI subscription.** Claude Pro/Max or Codex — AIPass uses the same CLI binary you already run. No extra API keys, no extra costs for core functionality.
|
||||
**Runs on your existing Claude subscription.** AIPass drives the same [Claude Code](https://code.claude.com/docs) binary you already run — Pro or Max. No extra API keys, no extra costs for core functionality.
|
||||
|
||||
---
|
||||
|
||||
@@ -82,31 +53,47 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity
|
||||
```bash
|
||||
git clone https://github.com/AIOSAI/AIPass.git
|
||||
cd AIPass
|
||||
./aipass install # Creates venv, installs, puts `aipass` + `drone` on your PATH, bootstraps 17 agents
|
||||
./aipass install
|
||||
```
|
||||
|
||||
On an interactive terminal, install ends by chaining into `aipass init run` — one command takes you from clone to a working first project. Pass `--no-init` to skip the chain, `--project <dir>` to pick where the project lands (CI and piped shells skip automatically). `./aipass` is a thin repo-root launcher over `setup.sh`; after setup it simply forwards to the installed `aipass` binary.
|
||||
One command does it all: builds the environment, puts `aipass` + `drone` on your PATH, bootstraps the 17-agent reference fleet, then walks you through a guided init — and ends **in a conversation**. The AIPass concierge opens right in your terminal with your install report in hand: it welcomes you, asks your name once, shows you around, and checks what your machine still needs — every machine is different.
|
||||
|
||||
Come back tomorrow, say "hi", and it picks up exactly where you left off. That's the whole interface.
|
||||
|
||||
<!-- GIF SLOT 2 — memory payoff (~15s): close the terminal, reopen, "hi", the agent recalls yesterday.
|
||||
 -->
|
||||
|
||||
Options: `--no-init` skips the guided chain, `--project <dir>` picks where your project lands. Non-interactive shells (CI, pipes) complete with defaults and exit 0 — no prompts, no spawned sessions; the handoff prints as a next-step command instead. The installer wires Claude Code hooks automatically — merging with any hooks you've already configured, never overwriting them. `./aipass` is a thin repo-root launcher over `setup.sh`; after setup it forwards to the installed `aipass` binary.
|
||||
|
||||
### 2. Your own project (if you skipped the chain)
|
||||
|
||||
Two ways in. From anywhere inside your AIPass environment, `aipass new` builds a complete project around a resident manager agent:
|
||||
|
||||
```bash
|
||||
cd ~ && mkdir my-project && cd my-project
|
||||
aipass init run # Guided setup — project, first agent, terminal handoff
|
||||
aipass new my-project --template python # Project + resident manager agent + git birth commit
|
||||
```
|
||||
|
||||
That's it. Your agent has identity, memory, a mailbox, and access to every AIPass service — planning, quality audits, dispatch, real-time monitoring. All through `drone @branch command`.
|
||||
It mints the project registry, spawns a full citizen (identity, memory, mailbox, birth certificate) at `src/my_project/my_project`, makes the first commit — and drops you straight into a conversation with your new manager.
|
||||
|
||||
Or bring your own directory, anywhere on disk:
|
||||
|
||||
```bash
|
||||
cd ~ && mkdir my-project && cd my-project
|
||||
aipass init run # Guided setup — project, first agent, ends in the conversation
|
||||
```
|
||||
|
||||
Either way your agent has identity, memory, a mailbox, and access to every AIPass service — planning, quality audits, dispatch, real-time monitoring.
|
||||
|
||||
```bash
|
||||
aipass init # Just the scaffold (no guided setup)
|
||||
aipass init agent my_agent # Add another agent
|
||||
aipass doctor # Check system health
|
||||
aipass feedback off # Silence the occasional how-are-we-doing ask
|
||||
```
|
||||
|
||||
> **Need help?** [Ask in Discussions](https://github.com/AIOSAI/AIPass/discussions) or [file feedback](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml) — both take 30 seconds.
|
||||
### 3. Meet the fleet
|
||||
|
||||
### 3. Explore the full framework
|
||||
|
||||
The clone above already includes all 17 agents working together — the reference implementation:
|
||||
The clone already includes all 17 agents working together — the reference implementation that maintains AIPass itself:
|
||||
|
||||
```bash
|
||||
cd src/aipass/devpulse
|
||||
@@ -114,45 +101,33 @@ claude # Talk to the orchestrator
|
||||
```
|
||||
|
||||
```bash
|
||||
# Things you can do:
|
||||
aipass doctor # Check system health
|
||||
drone @seedgo audit aipass # Run automated quality checks across all agents
|
||||
drone @flow create . "Add user auth" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Sub" "Body" # Send task + wake an agent
|
||||
drone @seedgo audit aipass # Quality checks across all agents
|
||||
drone @flow create . "Add user auth" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Subject" "Body" # Send a task + wake an agent
|
||||
```
|
||||
|
||||
> **Need help?** [Ask in Discussions](https://github.com/AIOSAI/AIPass/discussions) or [file feedback](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml) — both take 30 seconds.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
**One agent:** Run `aipass init run` and in 5 minutes you have a project with an agent that reads `.trinity/` on startup and picks up where it left off. Memory starts as plain JSON files — no setup required. When they fill up, older entries automatically archive into ChromaDB for long-term search. Nothing is lost.
|
||||
**Memory.** Every agent owns a `.trinity/` directory — identity, session history, learnings — read on startup, updated as it works. Memory starts as plain JSON, no setup required. When files fill up, older entries automatically archive into ChromaDB for long-term semantic search. Nothing is lost.
|
||||
|
||||
**A team:** When one agent isn't enough, every agent shares the same structure:
|
||||
**One structure.** Every agent — yours and the reference fleet — shares the same layout. If you know one agent, you know all of them:
|
||||
|
||||
```
|
||||
src/my-project/<agent>/
|
||||
src/my_project/<agent>/
|
||||
├── .trinity/ # Identity + memory (persists across sessions)
|
||||
├── .ai_mail.local/ # Mailbox (receives tasks, sends results)
|
||||
├── apps/ # Entry point → modules → handlers
|
||||
└── README.md # Domain knowledge (the agent reads this on startup)
|
||||
└── README.md # Domain knowledge (read on startup)
|
||||
```
|
||||
|
||||
Identical layout everywhere. If you know one agent, you know all of them. `drone` is the single command that routes to any agent:
|
||||
**One router.** `drone @branch command [args]` reaches any agent — routing, access tiers, and @agent resolution handled for you. Agents use the same commands to reach each other: they dispatch work, share findings, and wake whoever they're waiting on.
|
||||
|
||||
```bash
|
||||
drone @branch command [args] # Every agent, every task. Drone handles routing.
|
||||
```
|
||||
|
||||
```bash
|
||||
drone @seedgo audit aipass # Run quality checks on everything
|
||||
drone @flow create . "Refactor auth module" # Create a work plan
|
||||
drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than 30 days"
|
||||
```
|
||||
|
||||
**Two ways to use AIPass:**
|
||||
|
||||
- **Your own project:** `aipass init run` sets up a new project with your first agent. Add more agents as you need them. Your first agent is the orchestrator — it coordinates the others.
|
||||
- **The full framework:** Clone the repo to work with all 17 core agents. Talk to `devpulse` (the orchestrator), dispatch work across specialists. Agents work in parallel and report back.
|
||||
<!-- GIF SLOT 3 — team (~20s): dispatch a task to an agent, watchdog wake-back, result lands.
|
||||
 -->
|
||||
|
||||
---
|
||||
|
||||
@@ -180,8 +155,6 @@ devpulse (orchestrator)
|
||||
└── commons — the social space — post, comment, vote, gather
|
||||
```
|
||||
|
||||
These agents work on the **same filesystem, same project, same time** — no sandboxes, no worktrees. This is the pattern your projects inherit.
|
||||
|
||||
<details>
|
||||
<summary>Agent details</summary>
|
||||
|
||||
@@ -222,19 +195,6 @@ These agents work on the **same filesystem, same project, same time** — no san
|
||||
|
||||
---
|
||||
|
||||
## CLI Support
|
||||
|
||||
AIPass is built and tested with **Claude Code** on Linux/WSL.
|
||||
|
||||
| CLI | Autonomous Mode | Status |
|
||||
|-----|----------------|--------|
|
||||
| [Claude Code](https://code.claude.com/docs) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested |
|
||||
| [Codex](https://github.com/openai/codex) | `codex exec "prompt" --dangerously-bypass-approvals-and-sandbox` | Experimental |
|
||||
|
||||
The installer (`./aipass install`, powered by setup.sh) auto-detects which CLIs are installed and configures hooks for each — merging with any hooks you've already wired, never overwriting them.
|
||||
|
||||
---
|
||||
|
||||
## Project Status
|
||||
|
||||
**Beta.** Actively developed by a solo developer working with the AI agents themselves — every PR, every test, every fix is human-AI collaboration.
|
||||
@@ -249,25 +209,14 @@ The installer (`./aipass install`, powered by setup.sh) auto-detects which CLIs
|
||||
|
||||
Each agent documents its own operational status in its branch README — what works, what doesn't, and why.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- [Claude Code](https://code.claude.com/docs)
|
||||
- Linux, macOS, or WSL (all CI-tested)
|
||||
- Linux or WSL
|
||||
- `sudo` access optional (for `/usr/local/bin` symlinks — falls back to `~/.local/bin` without sudo)
|
||||
- API keys optional (OpenRouter/OpenAI — for optional add-on agents)
|
||||
|
||||
## Roadmap
|
||||
|
||||
These items have partial work done and are under ongoing testing:
|
||||
|
||||
- **macOS support** — CI green, full test suite passing ([#360](https://github.com/AIOSAI/AIPass/issues/360))
|
||||
- **Windows native** — CI green, full test suite passing
|
||||
- **Codex CLI** — hooks and AGENTS.md wired, needs end-to-end testing
|
||||
- **Fork contributor workflow** — improved error handling for fork-based PRs ([#329](https://github.com/AIOSAI/AIPass/issues/329))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
@@ -305,9 +254,9 @@ This archives the agent's directory and removes it from the registry.
|
||||
|
||||
### Use your existing subscription
|
||||
|
||||
AIPass runs on your **existing CLI subscription** — Claude Pro/Max or Codex. No API keys required for core functionality. No extra costs beyond your existing subscription.
|
||||
AIPass runs on your **existing Claude subscription** — Pro or Max. No API keys required for core functionality. No extra costs beyond your existing subscription.
|
||||
|
||||
This works because AIPass runs each CLI as an **official subprocess** — the same binary you'd run yourself in a terminal. It doesn't extract credentials, proxy API calls, or intercept tokens. Your subscription stays within the provider's infrastructure at all times.
|
||||
This works because AIPass runs Claude Code as an **official subprocess** — the same binary you'd run yourself in a terminal. It doesn't extract credentials, proxy API calls, or intercept tokens. Your subscription stays within the provider's infrastructure at all times.
|
||||
|
||||
### What AIPass does NOT do
|
||||
|
||||
@@ -316,7 +265,7 @@ This works because AIPass runs each CLI as an **official subprocess** — the sa
|
||||
- Bypass rate limits or prompt caching
|
||||
- Impersonate official CLI clients
|
||||
|
||||
Claude Code is proprietary but officially supports hooks and subprocess usage. Codex CLI is open source (Apache 2.0).
|
||||
Claude Code is proprietary but officially supports hooks and subprocess usage.
|
||||
|
||||
> API keys are only needed for optional add-on agents (OpenRouter/OpenAI). For server/automated deployments, API key authentication is recommended per [Anthropic's guidance](https://code.claude.com/docs/en/legal-and-compliance).
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "aipass"
|
||||
version = "2.7.2"
|
||||
version = "2.7.3"
|
||||
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
|
||||
"""
|
||||
|
||||
__version__ = "2.7.2"
|
||||
__version__ = "2.7.3"
|
||||
|
||||
@@ -11,6 +11,25 @@
|
||||
|
||||
**Status:** Operational | **Seedgo:** 100% (34/34) | **Tests:** 712 pass | **Battle Tested:** S62
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Check your inbox
|
||||
drone @ai_mail inbox
|
||||
|
||||
# View a message
|
||||
drone @ai_mail view <id>
|
||||
|
||||
# Reply and close
|
||||
drone @ai_mail reply <id> "your message"
|
||||
|
||||
# Send mail to another branch
|
||||
drone @ai_mail email @target "Subject" "Body"
|
||||
|
||||
# Dispatch (send + wake target agent)
|
||||
drone @ai_mail dispatch @target "Subject" "Body"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -22,7 +22,7 @@ from typing import Dict, Tuple, List, Optional, Callable
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root
|
||||
from aipass.ai_mail.apps.handlers.paths import find_repo_root, find_project_root
|
||||
from aipass.ai_mail.apps.handlers.registry.read import get_all_branches
|
||||
|
||||
if sys.platform == "win32":
|
||||
@@ -213,6 +213,44 @@ def _resolve_reply_path() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _check_cross_project_boundary(recipient_path: Path, sender_email: str) -> Tuple[bool, str]:
|
||||
"""Refuse mail when sender and recipient are in different projects.
|
||||
|
||||
Compares project roots (first *_REGISTRY.json found walking up) for the
|
||||
sender (from AIPASS_CALLER_CWD) and recipient (from resolved branch path).
|
||||
Same-project and host-to-host mail passes through unchanged.
|
||||
|
||||
Returns:
|
||||
(True, error_message) to refuse, (False, "") to allow.
|
||||
"""
|
||||
caller_cwd = os.environ.get("AIPASS_CALLER_CWD", "")
|
||||
if not caller_cwd:
|
||||
return False, ""
|
||||
|
||||
sender_root = find_project_root(Path(caller_cwd))
|
||||
if sender_root is None:
|
||||
return False, ""
|
||||
|
||||
recipient_root = find_project_root(recipient_path)
|
||||
if recipient_root is None:
|
||||
return False, ""
|
||||
|
||||
if sender_root == recipient_root:
|
||||
return False, ""
|
||||
|
||||
sender_name = sender_email or os.environ.get("AIPASS_CALLER_BRANCH", "unknown")
|
||||
logger.warning(
|
||||
"[delivery] cross-project mail refused: sender root %s != recipient root %s",
|
||||
sender_root,
|
||||
recipient_root,
|
||||
)
|
||||
return True, (
|
||||
f"Cross-project mail refused: {sender_name} (project: {sender_root.name}) "
|
||||
f"cannot send to this branch (project: {recipient_root.name}). "
|
||||
f"Use the feedback channel for cross-project communication."
|
||||
)
|
||||
|
||||
|
||||
def deliver_email_to_branch(
|
||||
to_branch: str, email_data: Dict, on_delivered: Optional[Callable] = None
|
||||
) -> Tuple[bool, str]:
|
||||
@@ -283,6 +321,11 @@ def deliver_email_to_branch(
|
||||
if not branch_path.is_absolute():
|
||||
branch_path = (_REPO_ROOT / branch_path).resolve()
|
||||
|
||||
# Cross-project boundary: refuse mail when sender and recipient are in different projects
|
||||
refused, refusal_msg = _check_cross_project_boundary(branch_path, sender_email)
|
||||
if refused:
|
||||
return False, refusal_msg
|
||||
|
||||
# Find the branch's .ai_mail.local/inbox.json file
|
||||
if branch_path == Path("/") or branch_path == _REPO_ROOT:
|
||||
inbox_file = _REPO_ROOT / ".ai_mail.local" / "inbox.json"
|
||||
|
||||
@@ -16,7 +16,9 @@ Consolidated from 8 identical copies per DPLAN-0036 audit.
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.ai_mail.apps.handlers.json import json_handler
|
||||
|
||||
if sys.platform == "win32":
|
||||
@@ -36,6 +38,23 @@ def find_repo_root() -> Path:
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def find_project_root(start: Path) -> Optional[Path]:
|
||||
"""Walk up from *start* to find the first *_REGISTRY.json (project root).
|
||||
|
||||
Returns the directory containing the registry, or None if not found.
|
||||
Stops at filesystem root.
|
||||
"""
|
||||
current = start.resolve()
|
||||
for candidate in [current] + list(current.parents):
|
||||
try:
|
||||
if any(candidate.glob("*_REGISTRY.json")):
|
||||
return candidate
|
||||
except OSError as exc:
|
||||
logger.warning("[paths] find_project_root: glob failed at %s: %s", candidate, exc)
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from aipass.cli.apps.modules import console
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from unittest.mock import patch, MagicMock
|
||||
|
||||
import aipass.ai_mail.apps.handlers.email.delivery as delivery_mod
|
||||
from aipass.ai_mail.apps.handlers.email.delivery import (
|
||||
_check_cross_project_boundary,
|
||||
_migrate_inbox_format,
|
||||
_is_private_branch_email,
|
||||
_resolve_reply_path,
|
||||
@@ -493,3 +494,121 @@ def test_deliver_stores_reply_path_from_env(tmp_path, repo_root, noop_inbox_lock
|
||||
msg = inbox["messages"][0]
|
||||
assert "reply_path" in msg
|
||||
assert msg["reply_path"] == str(inbox_file)
|
||||
|
||||
|
||||
# ---- _check_cross_project_boundary() tests ------------------------------
|
||||
|
||||
|
||||
def test_cross_project_no_caller_cwd_allows(tmp_path, monkeypatch):
|
||||
"""No AIPASS_CALLER_CWD → host-internal, always allowed."""
|
||||
monkeypatch.delenv("AIPASS_CALLER_CWD", raising=False)
|
||||
refused, _ = _check_cross_project_boundary(tmp_path, "@sender")
|
||||
assert refused is False
|
||||
|
||||
|
||||
def test_cross_project_same_root_allows(tmp_path, monkeypatch):
|
||||
"""Sender and recipient in the same project → allowed."""
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
sender_dir = tmp_path / "src" / "branch_a"
|
||||
sender_dir.mkdir(parents=True)
|
||||
recipient_dir = tmp_path / "src" / "branch_b"
|
||||
recipient_dir.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_dir))
|
||||
refused, _ = _check_cross_project_boundary(recipient_dir, "@branch_b")
|
||||
assert refused is False
|
||||
|
||||
|
||||
def test_cross_project_different_roots_refuses(tmp_path, monkeypatch):
|
||||
"""Sender in nested project, recipient in host → refused."""
|
||||
host = tmp_path / "host"
|
||||
host.mkdir()
|
||||
(host / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
recipient_dir = host / "src" / "devpulse"
|
||||
recipient_dir.mkdir(parents=True)
|
||||
|
||||
project = host / "projects" / "myproj"
|
||||
project.mkdir(parents=True)
|
||||
(project / "MYPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
sender_dir = project / "src"
|
||||
sender_dir.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_dir))
|
||||
refused, msg = _check_cross_project_boundary(recipient_dir, "@proj_agent")
|
||||
assert refused is True
|
||||
assert "Cross-project mail refused" in msg
|
||||
assert "feedback channel" in msg
|
||||
|
||||
|
||||
def test_cross_project_sender_no_registry_allows(tmp_path, monkeypatch):
|
||||
"""Sender in dir with no registry → cannot determine boundary, allow."""
|
||||
isolated = tmp_path / "nowhere"
|
||||
isolated.mkdir()
|
||||
monkeypatch.setenv("AIPASS_CALLER_CWD", str(isolated))
|
||||
|
||||
recipient = tmp_path / "host" / "branch"
|
||||
recipient.mkdir(parents=True)
|
||||
(tmp_path / "host" / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
refused, _ = _check_cross_project_boundary(recipient, "@branch")
|
||||
assert refused is False
|
||||
|
||||
|
||||
def test_cross_project_recipient_no_registry_allows(tmp_path, monkeypatch):
|
||||
"""Recipient in dir with no registry → cannot determine boundary, allow."""
|
||||
sender_dir = tmp_path / "host" / "src"
|
||||
sender_dir.mkdir(parents=True)
|
||||
(tmp_path / "host" / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_dir))
|
||||
|
||||
recipient = tmp_path / "orphan"
|
||||
recipient.mkdir()
|
||||
|
||||
refused, _ = _check_cross_project_boundary(recipient, "@orphan")
|
||||
assert refused is False
|
||||
|
||||
|
||||
def test_cross_project_delivery_e2e_refused(tmp_path, repo_root, noop_inbox_lock, monkeypatch):
|
||||
"""End-to-end: delivery from nested project to host branch is refused."""
|
||||
host_root = repo_root
|
||||
(host_root / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
branches = _setup_branch(tmp_path)
|
||||
|
||||
project = host_root / "projects" / "testproj"
|
||||
project.mkdir(parents=True)
|
||||
(project / "TESTPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
sender_cwd = project / "src"
|
||||
sender_cwd.mkdir()
|
||||
|
||||
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_cwd))
|
||||
|
||||
with patch.object(delivery_mod, "get_all_branches", return_value=branches):
|
||||
success, error = deliver_email_to_branch(
|
||||
"@target",
|
||||
_make_email_data(sender="@testproj"),
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert "Cross-project mail refused" in error
|
||||
|
||||
|
||||
def test_cross_project_delivery_same_project_allowed(tmp_path, repo_root, noop_inbox_lock, monkeypatch):
|
||||
"""End-to-end: delivery within the same project is allowed."""
|
||||
(repo_root / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
branches = _setup_branch(tmp_path)
|
||||
|
||||
sender_cwd = tmp_path / "src" / "other_branch"
|
||||
sender_cwd.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setenv("AIPASS_CALLER_CWD", str(sender_cwd))
|
||||
|
||||
with patch.object(delivery_mod, "get_all_branches", return_value=branches):
|
||||
success, error = deliver_email_to_branch(
|
||||
"@target",
|
||||
_make_email_data(),
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert error == ""
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
# Modified: 2026-04-03
|
||||
# =============================================
|
||||
|
||||
"""Tests for paths module -- repo root discovery."""
|
||||
"""Tests for paths module -- repo root discovery and project root resolution."""
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import aipass.ai_mail.apps.handlers.paths as mod
|
||||
from aipass.ai_mail.apps.handlers.paths import find_project_root
|
||||
|
||||
|
||||
# --- Fixtures --------------------------------------------------------
|
||||
@@ -84,3 +85,53 @@ def test_find_repo_root_finds_registry_in_same_dir(tmp_path, monkeypatch):
|
||||
|
||||
result = mod.find_repo_root()
|
||||
assert result == tmp_path
|
||||
|
||||
|
||||
# --- find_project_root tests --------------------------------------------
|
||||
|
||||
|
||||
def test_find_project_root_finds_registry(tmp_path):
|
||||
"""Returns directory containing *_REGISTRY.json."""
|
||||
project = tmp_path / "projects" / "myproj"
|
||||
deep = project / "src" / "pkg"
|
||||
deep.mkdir(parents=True)
|
||||
(project / "MYPROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert find_project_root(deep) == project
|
||||
|
||||
|
||||
def test_find_project_root_finds_host_registry(tmp_path):
|
||||
"""Returns host repo root when AIPASS_REGISTRY.json is the first hit."""
|
||||
host = tmp_path / "repo"
|
||||
branch = host / "src" / "aipass" / "branch"
|
||||
branch.mkdir(parents=True)
|
||||
(host / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert find_project_root(branch) == host
|
||||
|
||||
|
||||
def test_find_project_root_stops_at_first_registry(tmp_path):
|
||||
"""Nested project registry is found before the host registry."""
|
||||
host = tmp_path / "repo"
|
||||
project = host / "projects" / "inner"
|
||||
deep = project / "src"
|
||||
deep.mkdir(parents=True)
|
||||
(host / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
(project / "INNER_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert find_project_root(deep) == project
|
||||
|
||||
|
||||
def test_find_project_root_none_when_no_registry(tmp_path):
|
||||
"""Returns None when no *_REGISTRY.json is found anywhere."""
|
||||
deep = tmp_path / "a" / "b" / "c"
|
||||
deep.mkdir(parents=True)
|
||||
|
||||
assert find_project_root(deep) is None
|
||||
|
||||
|
||||
def test_find_project_root_at_start_dir(tmp_path):
|
||||
"""Returns start dir itself when it contains the registry."""
|
||||
(tmp_path / "PROJ_REGISTRY.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert find_project_root(tmp_path) == tmp_path
|
||||
|
||||
@@ -77,8 +77,33 @@ apps/
|
||||
- **Never pretend.** Don't know → say so, offer find out or ask branch expert.
|
||||
- **Clean handoffs.** Every init stage saves `setup_progress` `.trinity/local.json` — resume works.
|
||||
|
||||
## Welcome Mode — Fresh Install
|
||||
|
||||
Trigger: first message mentions "Fresh AIPass install" or you detect a fresh install context.
|
||||
|
||||
**Opening — three jobs in one tight block:**
|
||||
1. Say who you are and what you know: "I'm the AIPass concierge — I know this framework, every agent in it, and I'll remember what we set up."
|
||||
2. Show 3-5 concrete starters with exact commands:
|
||||
- `drone systems` — see every agent in the ecosystem
|
||||
- `drone @prax monitor run` — watch the system work live (leave this running in another terminal)
|
||||
- `aipass doctor` — check what's healthy and what needs wiring
|
||||
- `aipass help "how does memory work?"` — ask me anything about the framework
|
||||
- `drone @hooks hooksound` — toggle sound notifications (hear hooks firing as you work, or mute if distracting)
|
||||
3. Ask their name ONCE: "What should I call you? I'll remember it — next time you open this, I'll know who you are. Skip if you'd rather not." Accept skip gracefully. Never re-ask.
|
||||
|
||||
**Deferred triage (~turn 5):** After rapport is built, suggest completing setup. Frame it as "every machine is different — let's see what yours needs" rather than dumping a checklist.
|
||||
|
||||
**Hooks-first verification:** The first real setup task. Dispatch @hooks to investigate and report: `drone @ai_mail dispatch @hooks "Hooks health check" "Check if hooks are wired correctly for this installation. Include trust-registry enrollment status. Report what's green and what needs wiring."` Then check your inbox conversationally: `drone @ai_mail inbox`
|
||||
|
||||
**Setup DPLAN:** When the user is ready for the full setup pass, create a setup plan seeded from the cross-OS checklist: `drone @flow create . "Machine setup — post-install verification"` and reference `aipass doctor --cross-os` for the machine-specific gaps.
|
||||
|
||||
**Windows detected:** If system detection shows Windows (not WSL), recommend WSL: "AIPass works best on Linux/macOS or WSL. Want me to walk you through setting up WSL?" Offer a playbook.
|
||||
|
||||
**Feedback pulse — mention once:** "How's the experience so far? Your feedback is hugely appreciated — this is an open-source project and fresh-machine experience is the data we can't get any other way. https://github.com/AIOSAI/AIPass/issues — or turn reminders off anytime: `aipass feedback off`"
|
||||
|
||||
**Every suggestion ships its exact command.** Never say "you can check the agents" — say "run `drone systems` to see every agent."
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
- **Status: under construction (DPLAN-0136).** Don't PR / reveal this branch until Phase 8 — that's a *policy*, NOT a gitignore. Only the usual runtime/memory layer is ignored (`.trinity/`, plan files, `*.local`, logs) same as every branch; my code (init_flow, cross_os, tests, README) IS trackable. Committed-or-not = git's call, devpulse's lane.
|
||||
- **`aipass` binary currently `cli` branch's `aipass init`** — project bootstrap, not citizen creation. Eventually this CLI entry moves here. Until then, use `drone @spawn create` citizen creation.
|
||||
- **`aipass` binary is THIS branch's CLI** — installed on PATH, ships publicly (post-FPLAN-0333). init/install/new/doctor/help/profile/trust/feedback all route here. Citizen creation inside the host framework is still `drone @spawn create`.
|
||||
- **Test-convention tokens need buy-in.** Core agents don't yet recognize `[AIPASS-TEST — ...]`. Coordinating @ai_mail before pinging anyone.
|
||||
|
||||
@@ -31,20 +31,10 @@
|
||||
"standard": "cli",
|
||||
"reason": "Session info must print immediately after tmux spawn — returning data to module layer would lose the timing context. User needs attach/kill instructions right when the session starts."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "cli",
|
||||
"reason": "Thin command router — discovers and routes to modules, which own the CLI service layer. Adding console/header imports here couples the bootstrap entry point to Rich for 4 status lines."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "debug_print",
|
||||
"reason": "Thin command router uses bare print() for version output and help banner (4 calls). These run before module discovery — importing Rich console for bootstrap output adds startup overhead for minimal benefit."
|
||||
},
|
||||
{
|
||||
"file": "apps/aipass.py",
|
||||
"standard": "introspection",
|
||||
"reason": "Thin command router, not a module — it has no domain to introspect. Modules handle their own introspection via --info. No print_introspection() needed."
|
||||
"reason": "Entry point router — introspection is in print_introspection() called from main() on no-args/--help. Not a module with handle_command()."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/doctor.py",
|
||||
@@ -257,14 +247,34 @@
|
||||
"reason": "aipass is binary-invoked: aipass doctor runs the command; introspection via --info"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/doctor_fix.py",
|
||||
"file": "apps/modules/_doctor_fix.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: bare invocation shows usage; introspection via --info"
|
||||
"reason": "Private helper module for doctor.py — not directly invokable, no introspection needed."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/doctor_wire.py",
|
||||
"file": "apps/modules/_doctor_wire.py",
|
||||
"standard": "introspection",
|
||||
"reason": "aipass is binary-invoked: bare invocation shows usage; introspection via --info"
|
||||
"reason": "Private helper module for doctor.py — not directly invokable, no introspection needed."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/_doctor_fix.py",
|
||||
"standard": "naming",
|
||||
"reason": "Leading underscore is intentional — hides from module discovery to pass cli_ux no_internal_modules check."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/_doctor_wire.py",
|
||||
"standard": "naming",
|
||||
"reason": "Leading underscore is intentional — hides from module discovery to pass cli_ux no_internal_modules check."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/_doctor_fix.py",
|
||||
"standard": "meta",
|
||||
"reason": "Private helper module for doctor.py — meta name matches actual filename _doctor_fix.py."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/_doctor_wire.py",
|
||||
"standard": "meta",
|
||||
"reason": "Private helper module for doctor.py — meta name matches actual filename _doctor_wire.py."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/handoff.py",
|
||||
|
||||
+32
-14
@@ -1,11 +1,22 @@
|
||||
# AIPASS
|
||||
|
||||
Concierge and librarian for AIPass. Greets new users, walks them through setup, answers how-things-work questions, hands off to their chosen CLI.
|
||||
The friendly front door for AIPass. Walks new users through setup, runs system diagnostics, answers documentation questions, and creates projects inside the AIPass environment.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
aipass # Show available commands
|
||||
aipass doctor # Check system health
|
||||
aipass help what does drone do # Search branch documentation
|
||||
aipass new myapp --template python # Create a new project
|
||||
aipass init # Guided setup (10 stages, resumable)
|
||||
```
|
||||
|
||||
## Invoke
|
||||
|
||||
```
|
||||
drone @aipass <command>
|
||||
aipass <command> [options]
|
||||
aipass <command> --help
|
||||
```
|
||||
|
||||
## Architecture
|
||||
@@ -16,27 +27,30 @@ aipass/
|
||||
│ ├── aipass.py # Entry point — subcommand dispatch
|
||||
│ ├── modules/
|
||||
│ │ ├── doctor.py # System health aggregation + cross-OS pre-flight (--cross-os)
|
||||
│ │ ├── doctor_fix.py # Remediation report (--fix, --json)
|
||||
│ │ ├── doctor_wire.py # Auto-wire provider settings + stale-deny re-export
|
||||
│ │ ├── _doctor_fix.py # Remediation report (--fix, --json) [internal]
|
||||
│ │ ├── _doctor_wire.py # Auto-wire provider settings + stale-deny re-export [internal]
|
||||
│ │ ├── handoff.py # CLI handoff (placeholder)
|
||||
│ │ ├── help_chat.py # README-backed Q&A (reads via readme_map handler)
|
||||
│ │ ├── init_flow.py # 10-stage guided setup
|
||||
│ │ ├── install.py # aipass install — one-command bootstrap (clone + setup + init)
|
||||
│ │ ├── new_project.py # aipass new — create projects inside the installation
|
||||
│ │ ├── profile.py # User profile read/write
|
||||
│ │ └── trust.py # Trust registry — aipass trust / aipass revoke
|
||||
│ │ ├── trust.py # Trust registry — aipass trust / aipass revoke
|
||||
│ │ └── feedback.py # Feedback pulse toggle — aipass feedback on/off
|
||||
│ ├── handlers/
|
||||
│ │ ├── cross_os/ # Cross-OS pre-flight: gap_registry, preflight, run_record
|
||||
│ │ ├── handoff_platform/ # Platform-specific handoff detection
|
||||
│ │ ├── init/ # bootstrap.py, scaffold_content.py
|
||||
│ │ ├── new_project/ # Project creation logic (registry, template, scaffold, git init)
|
||||
│ │ ├── json/ # JSON read/write utilities
|
||||
│ │ ├── ping_sweep/ # Branch reachability verification
|
||||
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
|
||||
│ │ ├── provider_reconcile.py # Stale deny-rule detection + fix
|
||||
│ │ ├── readme_map/ # Live file reads + branch routing
|
||||
│ │ ├── structure_scan/ # Agent placement + pollution detection
|
||||
│ │ ├── system_detect/ # OS, shell, Python, RAM, CPU
|
||||
│ │ └── ui/ # Progress bars, menus, banners
|
||||
│ └── plugins/
|
||||
├── tests/ # 609 passing
|
||||
├── tests/ # 756 passing
|
||||
├── requirements.project.txt # Project-specific Python dependencies
|
||||
├── .trinity/ # Identity + session history + observations
|
||||
└── README.md
|
||||
@@ -46,19 +60,23 @@ aipass/
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `aipass` | Help banner |
|
||||
| `aipass` | Show available commands |
|
||||
| `aipass help [Q]` | README-backed Q&A with branch routing |
|
||||
| `aipass doctor` | System health — structure, registry, hooks, pytest |
|
||||
| `aipass doctor --fix` | Remediation report with `drone @spawn repair` commands |
|
||||
| `aipass doctor --json` | JSON output for structure scan results |
|
||||
| `aipass doctor --cross-os` | Cross-OS pre-flight (Layer-3-lite, machine) — OS-gap cross-ref + routing/versions/hookstatus |
|
||||
| `aipass doctor --cross-os --e2e` | ...also runs the real Layer-2 e2e wiring suite (heavy, opt-in) |
|
||||
| `aipass doctor --cross-os --record [PATH]` | Write a machine-filled Run Record for the human Layer-3 acceptance pass |
|
||||
| `aipass doctor --cross-os` | Cross-OS pre-flight — OS-gap cross-ref + routing/versions/hookstatus |
|
||||
| `aipass doctor --cross-os --e2e` | ...also runs the real e2e wiring suite (heavy, opt-in) |
|
||||
| `aipass doctor --cross-os --record [PATH]` | Write a machine-filled Run Record for the human acceptance pass |
|
||||
| `aipass init` | 10-stage guided setup (resumable) |
|
||||
| `aipass install` | One-command bootstrap — clone + setup.sh + hooks, then hand off to init (`--no-init`/`--with-init`/`--path`/`--here`) |
|
||||
| `aipass install` | One-command bootstrap — clone + setup.sh + hooks, then hand off to init |
|
||||
| `aipass profile` | Show/edit user profile |
|
||||
| `aipass new <name>` | Create a project in projects/ — own git repo, AIPass scaffold, resident agent |
|
||||
| `aipass new <name> --template python` | Create with Python template (pyproject + src/) |
|
||||
| `aipass new <name> --no-agent` | Create without resident agent |
|
||||
| `aipass trust [path]` | Show enrolled projects or enroll a project in the trust registry |
|
||||
| `aipass revoke <path>` | Remove a project from the trust registry |
|
||||
| `aipass feedback on/off` | Toggle the feedback reminder pulse (delegates to @hooks) |
|
||||
| `aipass --version` | Version |
|
||||
|
||||
## Integration Points
|
||||
@@ -79,7 +97,7 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Tests
|
||||
|
||||
609 passing — `pytest src/aipass/aipass/tests/`
|
||||
723 passing — `pytest src/aipass/aipass/tests/`
|
||||
|
||||
## Known Issues
|
||||
|
||||
@@ -87,4 +105,4 @@ Humans only. Nothing in AIPass depends on this branch.
|
||||
|
||||
## Last Updated
|
||||
|
||||
Last Updated: 2026-07-05
|
||||
Last Updated: 2026-07-17
|
||||
|
||||
@@ -35,8 +35,24 @@ if sys.platform == "win32":
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.cli.apps.modules import console, error
|
||||
from aipass.prax import logger
|
||||
|
||||
# =============================================================================
|
||||
# COMMANDS — public-facing labels and descriptions
|
||||
# =============================================================================
|
||||
|
||||
_PUBLIC_COMMANDS = {
|
||||
"doctor": "System health — structure, registry, hooks, tests",
|
||||
"help": "README-backed Q&A — ask about any branch",
|
||||
"init": "Guided setup for new users (10 stages, resumable)",
|
||||
"install": "One-command bootstrap — clone + setup + init",
|
||||
"new": "Create a project inside AIPass",
|
||||
"profile": "Show/edit user profile",
|
||||
"trust": "Trust registry — enroll/revoke projects",
|
||||
"feedback": "Toggle the feedback reminder on/off",
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# MODULE DISCOVERY
|
||||
# =============================================================================
|
||||
@@ -72,6 +88,81 @@ def discover_modules() -> List[Any]:
|
||||
return modules
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HELP OUTPUT — house pattern (cli_ux)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def print_introspection(modules: List[Any] | None = None) -> None:
|
||||
"""Bare invocation — title, purpose, public commands, --help pointer."""
|
||||
console.print()
|
||||
console.print("[bold cyan]AIPASS — Concierge & Setup[/bold cyan]")
|
||||
console.print("[dim]The friendly front door for AIPass. Setup, diagnostics, documentation, project creation.[/dim]")
|
||||
console.print()
|
||||
|
||||
if modules is None:
|
||||
modules = discover_modules()
|
||||
|
||||
commands = []
|
||||
for module in modules:
|
||||
name = getattr(module, "COMMAND", None)
|
||||
if name and name in _PUBLIC_COMMANDS:
|
||||
commands.append((name, _PUBLIC_COMMANDS[name]))
|
||||
commands.sort()
|
||||
|
||||
if commands:
|
||||
console.print("[yellow]Commands:[/yellow]")
|
||||
for name, desc in commands:
|
||||
console.print(f" [green]{name:16}[/green] [dim]{desc}[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[dim]Run 'aipass --help' for usage and examples[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help(modules: List[Any] | None = None) -> None:
|
||||
"""Full help — usage, commands, examples."""
|
||||
console.print()
|
||||
console.print("[bold cyan]AIPASS — Concierge & Setup[/bold cyan]")
|
||||
console.print("[dim]The friendly front door for AIPass. Setup, diagnostics, documentation, project creation.[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print(" [green]aipass[/green] [dim]<command>[/dim] [dim][options][/dim]")
|
||||
console.print(" [green]aipass[/green] [dim]Show commands[/dim]")
|
||||
console.print(" [green]aipass[/green] [dim]<command>[/dim] [dim]--help[/dim] [dim]Help for a command[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Commands:[/yellow]")
|
||||
console.print(
|
||||
" [green]doctor[/green] [dim]System health — structure, registry, hooks, tests[/dim]"
|
||||
)
|
||||
console.print(" [green]doctor --fix[/green] [dim]Remediation report with repair commands[/dim]")
|
||||
console.print(" [green]doctor --json[/green] [dim]JSON output for structure scan[/dim]")
|
||||
console.print(" [green]doctor --cross-os[/green] [dim]Cross-OS pre-flight check[/dim]")
|
||||
console.print(" [green]help <question>[/green] [dim]Search branch documentation (Q&A)[/dim]")
|
||||
console.print(
|
||||
" [green]init[/green] [dim]Guided setup for new users (10 stages, resumable)[/dim]"
|
||||
)
|
||||
console.print(
|
||||
" [green]install[/green] [dim]One-command bootstrap — clone + setup.sh + hooks[/dim]"
|
||||
)
|
||||
console.print(" [green]new <name>[/green] [dim]Create a project inside AIPass[/dim]")
|
||||
console.print(" [green]profile[/green] [dim]Show/edit user profile[/dim]")
|
||||
console.print(
|
||||
" [green]trust[/green] [dim][path][/dim] [dim]Trust registry — enroll/revoke projects[/dim]"
|
||||
)
|
||||
console.print(" [green]--version[/green] [dim]Show version[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
console.print(" [green]aipass doctor[/green] [dim]Check system health[/dim]")
|
||||
console.print(" [green]aipass help what does drone do[/green] [dim]Search documentation[/dim]")
|
||||
console.print(" [green]aipass new myapp --template python[/green] [dim]Create a Python project[/dim]")
|
||||
console.print(" [green]aipass init[/green] [dim]Start guided setup[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
"""Route command to appropriate module.
|
||||
|
||||
@@ -105,17 +196,15 @@ def main():
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
logger.info("[AIPASS] Package metadata not found, version unknown")
|
||||
version = "unknown"
|
||||
print(f"aipass {version}")
|
||||
console.print(f"aipass {version}")
|
||||
return 0
|
||||
|
||||
show_root_help = len(args) == 0 or args[0] in ["--help", "-h"] or (args[0] == "help" and len(args) == 1)
|
||||
if show_root_help:
|
||||
print(f"AIPASS - {len(modules)} modules discovered")
|
||||
for module in modules:
|
||||
stem = module.__name__.split(".")[-1]
|
||||
name = getattr(module, "COMMAND", stem)
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
print(f" {name:20} {desc}")
|
||||
if not args:
|
||||
print_introspection(modules)
|
||||
return 0
|
||||
|
||||
if args[0] in ("--help", "-h"):
|
||||
print_help(modules)
|
||||
return 0
|
||||
|
||||
command = args[0]
|
||||
@@ -126,31 +215,31 @@ def main():
|
||||
for module in modules:
|
||||
if module.handle_command(command, ["--help"]):
|
||||
return 0
|
||||
print(f"Unknown command: {command}")
|
||||
console.print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error: '{command}' crashed: {e}")
|
||||
error(f"'{command}' crashed: {e}")
|
||||
logger.error(f"[AIPASS] '{command}' traceback", exc_info=True)
|
||||
return 1
|
||||
|
||||
if command.startswith("@"):
|
||||
print(f"{command} is a drone routing target, not an aipass command.")
|
||||
print("aipass is your front-door CLI; drone is the agent router — two separate tools.")
|
||||
print()
|
||||
print(f" Reach an agent: drone {command} ... · drone systems")
|
||||
print(" aipass commands: aipass --help")
|
||||
console.print(f"{command} is a drone routing target, not an aipass command.")
|
||||
console.print("aipass is your front-door CLI; drone is the agent router — two separate tools.")
|
||||
console.print()
|
||||
console.print(f" Reach an agent: drone {command} ... · drone systems")
|
||||
console.print(" aipass commands: aipass --help")
|
||||
return 1
|
||||
|
||||
for stem, err in _import_failures.items():
|
||||
if command in (stem, stem.replace("_", "")):
|
||||
print(f"Error: '{command}' failed to load: {err}")
|
||||
error(f"'{command}' failed to load: {err}")
|
||||
return 1
|
||||
|
||||
print(f"Unknown command: {command}")
|
||||
console.print(f"Unknown command: {command}")
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||
_sanitize_name,
|
||||
init_project,
|
||||
is_projects_child,
|
||||
update_project,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.init.scaffold_content import (
|
||||
@@ -25,6 +26,7 @@ __all__ = [
|
||||
"global_prompt_md",
|
||||
"inbox_json",
|
||||
"init_project",
|
||||
"is_projects_child",
|
||||
"prep_md",
|
||||
"update_project",
|
||||
"with_source",
|
||||
|
||||
@@ -262,9 +262,30 @@ def _enroll_project(target: Path) -> None:
|
||||
logger.info("Trust registry unavailable, skipping enrollment: %s", exc)
|
||||
|
||||
|
||||
def _guard_init(target: Path) -> None:
|
||||
def is_projects_child(target: Path) -> bool:
|
||||
"""True if *target* is ``<host>/projects/<name>`` — a valid nested project path.
|
||||
|
||||
The host is identified by having a ``*_REGISTRY.json`` in the grandparent
|
||||
of target (i.e. target's parent is named ``projects``).
|
||||
"""
|
||||
resolved = target.resolve()
|
||||
if resolved.parent.name != "projects":
|
||||
return False
|
||||
host = resolved.parent.parent
|
||||
try:
|
||||
return any(f.is_file() and f.name.endswith("_REGISTRY.json") for f in host.iterdir())
|
||||
except OSError as exc:
|
||||
logger.info("is_projects_child: could not read host dir %s: %s", host, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _guard_init(target: Path, *, allow_projects_child: bool = False) -> None:
|
||||
"""Block init if target is inside an agent branch or existing project.
|
||||
|
||||
When *allow_projects_child* is True, the nested-project checks are
|
||||
skipped for targets that are ``<host>/projects/<name>``. This is used
|
||||
by ``aipass new`` to create projects inside the installation.
|
||||
|
||||
Raises RuntimeError with explanation if init should not proceed.
|
||||
"""
|
||||
target = target.resolve()
|
||||
@@ -286,6 +307,8 @@ def _guard_init(target: Path) -> None:
|
||||
# Block: target already has a registry (is already a project)
|
||||
for f in target.iterdir() if target.is_dir() else []:
|
||||
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||
if allow_projects_child and is_projects_child(target):
|
||||
break
|
||||
raise RuntimeError(
|
||||
f"BLOCKED: '{target}' is already an AIPass project (has {f.name}). "
|
||||
"Use 'aipass init update' to upgrade an existing project."
|
||||
@@ -296,6 +319,8 @@ def _guard_init(target: Path) -> None:
|
||||
continue
|
||||
for f in parent.iterdir():
|
||||
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||
if allow_projects_child and is_projects_child(target):
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"BLOCKED: '{target}' is inside AIPass project at '{parent}' (has {f.name}). "
|
||||
"Cannot create a nested project."
|
||||
@@ -304,12 +329,18 @@ def _guard_init(target: Path) -> None:
|
||||
break
|
||||
|
||||
|
||||
def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
def init_project(
|
||||
target: Path,
|
||||
project_name: str | None = None,
|
||||
*,
|
||||
allow_projects_child: bool = False,
|
||||
) -> dict:
|
||||
"""Initialize an AIPass project in the target directory.
|
||||
|
||||
Args:
|
||||
target: Directory to initialize
|
||||
project_name: Name for the registry (defaults to directory name)
|
||||
allow_projects_child: When True, allow init inside ``<host>/projects/<name>``.
|
||||
|
||||
Returns:
|
||||
dict with registry_id, registry_file, project_name, target, created_files
|
||||
@@ -319,7 +350,7 @@ def init_project(target: Path, project_name: str | None = None) -> dict:
|
||||
RuntimeError: If target is inside an agent branch or existing project
|
||||
"""
|
||||
target = target.resolve()
|
||||
_guard_init(target)
|
||||
_guard_init(target, allow_projects_child=allow_projects_child)
|
||||
if not target.exists():
|
||||
target.mkdir(parents=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: __init__.py
|
||||
# Description: New project handler — create projects inside AIPass
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-17
|
||||
# Modified: 2026-07-17
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
New Project Handler — creates projects inside AIPass installations.
|
||||
|
||||
Business logic for `aipass new`. Creates a project at <host>/projects/<name>
|
||||
with its own git repo, registry, and optional AIPass agent.
|
||||
|
||||
Flow: find host -> validate -> mkdir -> mint registry (FIRST) ->
|
||||
write template -> scaffold AIPass files -> git init -> optional agent.
|
||||
|
||||
RULES:
|
||||
- Registry MUST be minted before any spawn call
|
||||
- git init via subprocess (hooks git gate only intercepts agent Bash)
|
||||
- Cleanup on failure: partial project is worse than no project
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.spawn import spawn_agent
|
||||
|
||||
TEMPLATES = ("empty", "python")
|
||||
|
||||
|
||||
def find_host_root(start: Path) -> Path | None:
|
||||
"""Walk up from *start* to find the AIPass host installation root.
|
||||
|
||||
Returns the directory containing ``*_REGISTRY.json``, or ``None``.
|
||||
"""
|
||||
for p in [start, *start.parents]:
|
||||
try:
|
||||
entries = list(p.iterdir())
|
||||
except OSError:
|
||||
continue
|
||||
for f in entries:
|
||||
try:
|
||||
if f.is_file() and f.name.endswith("_REGISTRY.json"):
|
||||
return p
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _validate_name(name: str) -> str:
|
||||
if not name:
|
||||
raise ValueError("Project name cannot be empty")
|
||||
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
|
||||
raise ValueError(
|
||||
f"Invalid project name '{name}'. "
|
||||
"Must start with a letter, contain only letters, digits, hyphens, underscores."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _registry_name(name: str) -> str:
|
||||
return re.sub(r"[^A-Z0-9_-]", "_", name.upper()).strip("_")
|
||||
|
||||
|
||||
def _git(args: list[str], cwd: Path) -> str:
|
||||
r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args)}: {r.stderr.strip()}")
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
# ── registry ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_registry(target: Path, name: str) -> tuple[str, str]:
|
||||
"""Mint the project registry. Returns ``(registry_id, filename)``."""
|
||||
registry_id = str(uuid.uuid4())
|
||||
today = date.today().isoformat()
|
||||
reg = _registry_name(name)
|
||||
filename = f"{reg}_REGISTRY.json"
|
||||
|
||||
data = {
|
||||
"metadata": {
|
||||
"id": registry_id,
|
||||
"name": reg,
|
||||
"version": "1.0.0",
|
||||
"created": today,
|
||||
"last_updated": today,
|
||||
"total_branches": 0,
|
||||
},
|
||||
"branches": [],
|
||||
}
|
||||
(target / filename).write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return registry_id, filename
|
||||
|
||||
|
||||
# ── templates ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_template(target: Path, name: str, template: str) -> list[str]:
|
||||
"""Write template-specific files. Returns relative paths created."""
|
||||
created: list[str] = []
|
||||
|
||||
(target / "README.md").write_text(
|
||||
f"# {name}\n\nCreated with `aipass new`. Template: {template}.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append("README.md")
|
||||
|
||||
(target / ".gitignore").write_text(
|
||||
"__pycache__/\n*.pyc\n.venv\n.trinity/\n.ai_mail.local/\n*.local.json\n*.local/\nlogs/\n.*_REGISTRY.lock\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append(".gitignore")
|
||||
|
||||
if template == "python":
|
||||
pkg = name.replace("-", "_").lower()
|
||||
(target / "pyproject.toml").write_text(
|
||||
"[build-system]\n"
|
||||
'requires = ["setuptools>=61.0"]\n'
|
||||
'build-backend = "setuptools.build_meta"\n\n'
|
||||
"[project]\n"
|
||||
f'name = "{name}"\n'
|
||||
'version = "0.1.0"\n'
|
||||
f'description = "{name} — born deployable"\n'
|
||||
'requires-python = ">=3.10"\n\n'
|
||||
"[tool.setuptools.packages.find]\n"
|
||||
'where = ["src"]\n\n'
|
||||
"[tool.pytest.ini_options]\n"
|
||||
'testpaths = ["src"]\n'
|
||||
'pythonpath = ["src"]\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append("pyproject.toml")
|
||||
src = target / "src" / pkg
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text(
|
||||
f'"""{name} — born deployable."""\n\n__version__ = "0.1.0"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append(f"src/{pkg}/__init__.py")
|
||||
|
||||
return created
|
||||
|
||||
|
||||
# ── AIPass scaffold ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _scaffold_aipass(target: Path, name: str) -> list[str]:
|
||||
"""Write AIPass scaffold files (tiers, hooks, CLAUDE.md, settings, .venv)."""
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import (
|
||||
_claude_settings,
|
||||
_detect_aipass_home,
|
||||
_enroll_project,
|
||||
)
|
||||
from aipass.aipass.apps.handlers.init import scaffold_content as sc
|
||||
|
||||
created: list[str] = []
|
||||
aipass_home = _detect_aipass_home()
|
||||
reg = _registry_name(name)
|
||||
|
||||
# .aipass/
|
||||
aipass_dir = target / ".aipass"
|
||||
aipass_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Tier files
|
||||
if aipass_home:
|
||||
for tier_file in ("tier0_kernel.md", "tier1_navmap.md"):
|
||||
dest = aipass_dir / tier_file
|
||||
src_path = Path(aipass_home) / ".aipass" / tier_file
|
||||
if src_path.is_file():
|
||||
shutil.copy2(str(src_path), str(dest))
|
||||
created.append(f".aipass/{tier_file}")
|
||||
|
||||
# hooks.json + trust enrollment
|
||||
if aipass_home:
|
||||
template = Path(aipass_home) / ".aipass" / "project_hooks.json"
|
||||
if template.is_file():
|
||||
shutil.copy2(str(template), str(aipass_dir / "hooks.json"))
|
||||
created.append(".aipass/hooks.json")
|
||||
_enroll_project(target)
|
||||
|
||||
# CLAUDE.md, AGENTS.md
|
||||
for md_name in ("CLAUDE.md", "AGENTS.md"):
|
||||
dest = target / md_name
|
||||
if dest.exists():
|
||||
continue
|
||||
if aipass_home:
|
||||
tmpl = Path(aipass_home) / ".aipass" / f"project_{md_name}"
|
||||
if tmpl.is_file():
|
||||
content = tmpl.read_text(encoding="utf-8").replace("{name}", reg)
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
created.append(md_name)
|
||||
continue
|
||||
if md_name == "AGENTS.md":
|
||||
dest.write_text(sc.agents_md(reg), encoding="utf-8")
|
||||
created.append(md_name)
|
||||
|
||||
# .claude/settings.json
|
||||
claude_dir = target / ".claude"
|
||||
claude_dir.mkdir(exist_ok=True)
|
||||
(claude_dir / "settings.json").write_text(
|
||||
_claude_settings(aipass_home),
|
||||
encoding="utf-8",
|
||||
)
|
||||
created.append(".claude/settings.json")
|
||||
|
||||
# .claude/commands/prep.md
|
||||
commands_dir = claude_dir / "commands"
|
||||
commands_dir.mkdir(exist_ok=True)
|
||||
(commands_dir / "prep.md").write_text(sc.prep_md(), encoding="utf-8")
|
||||
created.append(".claude/commands/prep.md")
|
||||
|
||||
# .venv symlink
|
||||
if aipass_home:
|
||||
venv = Path(aipass_home) / ".venv"
|
||||
if venv.is_dir():
|
||||
link = target / ".venv"
|
||||
link.symlink_to(venv)
|
||||
created.append(".venv")
|
||||
|
||||
return created
|
||||
|
||||
|
||||
# ── git ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _git_init(target: Path, name: str, template: str) -> None:
|
||||
"""Initialize git repo with birth commit. Guards against re-init."""
|
||||
if (target / ".git").exists():
|
||||
raise RuntimeError(f"'{target}' already has a .git directory")
|
||||
_git(["init", "-b", "main"], target)
|
||||
_git(["add", "-A"], target)
|
||||
_git(
|
||||
["commit", "-m", f"birth: {name} ({template} template) via aipass new"],
|
||||
target,
|
||||
)
|
||||
|
||||
|
||||
# ── agent (via @spawn) ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _agent_home(project_root: Path, name: str) -> Path:
|
||||
"""Compute the agent home directory: src/<pkg>/<pkg>/."""
|
||||
pkg = name.replace("-", "_").lower()
|
||||
return project_root / "src" / pkg / pkg
|
||||
|
||||
|
||||
def _spawn_project_agent(project_root: Path, name: str) -> dict:
|
||||
"""Create the project agent via spawn_agent().
|
||||
|
||||
Agent lives at src/<pkg>/<pkg>/ inside the project. Spawn discovers
|
||||
the project-local registry (minted earlier by _write_registry) by
|
||||
walking up from the agent home to the project root.
|
||||
"""
|
||||
home = _agent_home(project_root, name)
|
||||
result = spawn_agent(
|
||||
target_path=str(home),
|
||||
role="project_agent",
|
||||
purpose=f"Resident agent of the {name} project.",
|
||||
citizen_class="project_agent",
|
||||
)
|
||||
if not result.get("success"):
|
||||
raise RuntimeError(f"spawn_agent failed: {result.get('error', 'unknown')}")
|
||||
logger.info(
|
||||
"[aipass new] agent spawned via @spawn: %s (%d files)",
|
||||
result["branch_name"],
|
||||
result["files_copied"],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── public API ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_project(
|
||||
name: str,
|
||||
template: str = "empty",
|
||||
no_agent: bool = False,
|
||||
) -> dict:
|
||||
"""Create a new project inside the AIPass host installation.
|
||||
|
||||
Returns:
|
||||
dict with name, template, target, host, registry_id, registry_file,
|
||||
files, agent_spawned.
|
||||
|
||||
Raises:
|
||||
ValueError: Invalid name or template.
|
||||
RuntimeError: Not inside AIPass, target exists, git failure.
|
||||
"""
|
||||
_validate_name(name)
|
||||
if template not in TEMPLATES:
|
||||
raise ValueError(f"Unknown template '{template}'. Choose from: {', '.join(TEMPLATES)}")
|
||||
|
||||
host = find_host_root(Path.cwd())
|
||||
if host is None:
|
||||
raise RuntimeError(
|
||||
"Not inside an AIPass installation (no *_REGISTRY.json found). "
|
||||
"`aipass new` creates projects inside the AIPass environment."
|
||||
)
|
||||
|
||||
target = host / "projects" / name
|
||||
if target.exists():
|
||||
raise RuntimeError(f"Project already exists: {target}")
|
||||
|
||||
target.mkdir(parents=True)
|
||||
|
||||
try:
|
||||
registry_id, registry_file = _write_registry(target, name)
|
||||
logger.info("[aipass new] registry minted: %s (%s)", registry_file, registry_id)
|
||||
|
||||
template_files = _write_template(target, name, template)
|
||||
scaffold_files = _scaffold_aipass(target, name)
|
||||
|
||||
spawn_result = None
|
||||
if not no_agent:
|
||||
spawn_result = _spawn_project_agent(target, name)
|
||||
|
||||
_git_init(target, name, template)
|
||||
logger.info("[aipass new] git repo initialized with birth commit")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"template": template,
|
||||
"target": str(target),
|
||||
"host": str(host),
|
||||
"registry_id": registry_id,
|
||||
"registry_file": registry_file,
|
||||
"files": template_files + scaffold_files,
|
||||
"agent_created": spawn_result is not None,
|
||||
"agent_home": str(_agent_home(target, name)) if spawn_result else None,
|
||||
"spawn_result": spawn_result,
|
||||
}
|
||||
except Exception:
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
raise
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: doctor_fix.py
|
||||
# Name: _doctor_fix.py
|
||||
# Description: Structure remediation report for aipass doctor --fix
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-05-15
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: doctor_wire.py
|
||||
# Name: _doctor_wire.py
|
||||
# Description: Auto-wire provider settings from manifest into user config
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-05-08
|
||||
@@ -54,11 +54,11 @@ from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
|
||||
find_project_root,
|
||||
scan_agents,
|
||||
)
|
||||
from aipass.aipass.apps.modules.doctor_fix import (
|
||||
from aipass.aipass.apps.modules._doctor_fix import (
|
||||
print_json_report,
|
||||
print_remediation_report,
|
||||
)
|
||||
from aipass.aipass.apps.modules.doctor_wire import (
|
||||
from aipass.aipass.apps.modules._doctor_wire import (
|
||||
_auto_wire_provider,
|
||||
_prompt_auto_wire as prompt_auto_wire,
|
||||
check_wire_verify,
|
||||
@@ -81,6 +81,8 @@ from aipass.aipass.apps.handlers.ui.progress import (
|
||||
make_doctor_progress,
|
||||
)
|
||||
|
||||
COMMAND = "doctor"
|
||||
|
||||
_BRANCH_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: feedback.py
|
||||
# Description: aipass feedback — toggle the feedback reminder pulse on/off
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-18
|
||||
# Modified: 2026-07-18
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass feedback — user-facing alias for the @hooks feedback toggle.
|
||||
|
||||
Usage:
|
||||
aipass feedback # show current state
|
||||
aipass feedback on # enable feedback reminders
|
||||
aipass feedback off # disable feedback reminders
|
||||
aipass feedback --help
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from aipass.cli.apps.modules import console, error, warning
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
COMMAND = "feedback"
|
||||
|
||||
_DRONE_TIMEOUT = 15
|
||||
|
||||
|
||||
def _run_hooks_feedback(action: str | None) -> int:
|
||||
"""Delegate to drone @hooks feedback. Returns the subprocess exit code."""
|
||||
cmd = ["drone", "@hooks", "feedback"]
|
||||
if action:
|
||||
cmd.append(action)
|
||||
try:
|
||||
proc = subprocess.run(cmd, timeout=_DRONE_TIMEOUT)
|
||||
return proc.returncode
|
||||
except FileNotFoundError:
|
||||
logger.warning("[feedback] drone not found on PATH")
|
||||
warning("drone not found on PATH — cannot reach @hooks.")
|
||||
return 1
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("[feedback] drone @hooks feedback timed out")
|
||||
warning("drone @hooks feedback timed out.")
|
||||
return 1
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the feedback command."""
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass feedback[/bold cyan] — toggle the feedback reminder")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass feedback[/green] [dim]# show current state[/dim]")
|
||||
console.print(" [green]aipass feedback on[/green] [dim]# enable feedback reminders[/dim]")
|
||||
console.print(" [green]aipass feedback off[/green] [dim]# disable feedback reminders[/dim]")
|
||||
console.print()
|
||||
console.print("[dim]Delegates to: drone @hooks feedback[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Show module info for feedback."""
|
||||
console.print()
|
||||
console.print("[bold cyan]feedback Module[/bold cyan]")
|
||||
console.print("User-facing alias for the @hooks feedback pulse toggle.")
|
||||
console.print()
|
||||
console.print("[dim]Delegates to: drone @hooks feedback on/off[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route the feedback command. Returns True if handled."""
|
||||
if command != COMMAND:
|
||||
return False
|
||||
|
||||
if args and args[0] in ("--help", "-h", "help"):
|
||||
json_handler.log_operation("feedback_help", {"command": command})
|
||||
print_help()
|
||||
return True
|
||||
if args and args[0] in ("--info", "info"):
|
||||
json_handler.log_operation("feedback_info", {"command": command})
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
if not args:
|
||||
json_handler.log_operation("feedback_usage", {"command": command})
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
action = args[0] if args[0] in ("on", "off") else None
|
||||
if action is None:
|
||||
error(f"Unknown option: {args[0]}. Use 'on' or 'off'.")
|
||||
print_help()
|
||||
return True
|
||||
|
||||
rc = _run_hooks_feedback(action)
|
||||
json_handler.log_operation(
|
||||
"feedback_toggle",
|
||||
{"action": action or "status", "exit": rc},
|
||||
)
|
||||
if rc != 0:
|
||||
logger.warning("[feedback] drone @hooks feedback exited %d", rc)
|
||||
return True
|
||||
@@ -28,7 +28,7 @@ from aipass.aipass.apps.handlers.json import json_handler
|
||||
|
||||
COMMAND = "handoff"
|
||||
|
||||
_INIT_PROMPT = "I just completed aipass init and am ready to start. What should I do first?"
|
||||
INIT_PROMPT = "I just completed aipass init and am ready to start. What should I do first?"
|
||||
|
||||
CLI_CHOICES = ["claude", "codex"]
|
||||
FLAG_CHOICES = ["default", "skip-permissions"]
|
||||
@@ -48,7 +48,7 @@ def _get_stored_profile() -> dict:
|
||||
|
||||
def do_handoff(
|
||||
cli: str = "claude",
|
||||
prompt: str = _INIT_PROMPT,
|
||||
prompt: str = INIT_PROMPT,
|
||||
cwd: str = ".",
|
||||
flag_variant: str = "default",
|
||||
) -> bool:
|
||||
|
||||
@@ -96,7 +96,7 @@ TEMPLATE_EMPTY = "empty project"
|
||||
TEMPLATE_AIPASS = "aipass_framework"
|
||||
TEMPLATE_CHOICES = [TEMPLATE_EMPTY, TEMPLATE_AIPASS]
|
||||
# first_agent, ping_sweep, handoff, done — skipped for empty (non-framework) projects
|
||||
AIPASS_SPECIFIC_STAGES = {6, 7, 9, 10}
|
||||
AIPASS_SPECIFIC_STAGES = {6, 7}
|
||||
|
||||
|
||||
# --- LOCAL JSON HELPERS ---
|
||||
@@ -603,29 +603,35 @@ def stage_9_handoff(
|
||||
console.print()
|
||||
console.print(render_step_header(9, TOTAL_STAGES, "Handoff"))
|
||||
|
||||
init_prompt = "I just completed aipass init. I am ready to start. What should I do first?"
|
||||
from aipass.aipass.apps.modules.handoff import INIT_PROMPT
|
||||
|
||||
init_prompt = INIT_PROMPT
|
||||
|
||||
_template = (accumulated or {}).get("template", TEMPLATE_AIPASS)
|
||||
console.print()
|
||||
console.print(" Your agent is ready.")
|
||||
console.print(f" [dim]CLI: {cli_choice} | Agent: {agent_path}[/dim]")
|
||||
if _template == TEMPLATE_AIPASS:
|
||||
console.print(" Your agent is ready.")
|
||||
console.print(f" [dim]CLI: {cli_choice} | Agent: {agent_path}[/dim]")
|
||||
else:
|
||||
_display = str(Path(agent_path).resolve()) if agent_path == "." else agent_path
|
||||
console.print(" Your project is ready — launching your CLI.")
|
||||
console.print(f" [dim]CLI: {cli_choice} | Project: {_display}[/dim]")
|
||||
|
||||
from aipass.aipass.apps.handlers.handoff_platform import build_manual_command
|
||||
|
||||
command = build_manual_command(cli_choice, init_prompt, agent_path, flag_variant)
|
||||
display_path = str(Path(agent_path).resolve()) if agent_path == "." else agent_path
|
||||
command = build_manual_command(cli_choice, init_prompt, display_path, flag_variant)
|
||||
inline = False
|
||||
|
||||
if dry_run:
|
||||
console.print(f"[yellow]\\[dry-run][/yellow] would launch handoff: {command}")
|
||||
launched = False
|
||||
elif non_interactive:
|
||||
from aipass.aipass.apps.modules import handoff as handoff_mod
|
||||
|
||||
launched = handoff_mod.do_handoff(
|
||||
cli=cli_choice,
|
||||
prompt=init_prompt,
|
||||
cwd=agent_path,
|
||||
flag_variant=flag_variant,
|
||||
)
|
||||
console.print()
|
||||
console.print(" [dim]Next step (run manually):[/dim]")
|
||||
console.print(f" [cyan]{command}[/cyan]")
|
||||
console.print()
|
||||
launched = False
|
||||
else:
|
||||
console.print()
|
||||
console.print(" [bold]1.[/bold] Stay here — launch agent in this terminal")
|
||||
@@ -729,8 +735,12 @@ def stage_10_done(accumulated: Dict[str, Any] | None = None, dry_run: bool = Fal
|
||||
|
||||
|
||||
# --- MAIN RUNNER ---
|
||||
def _preflight_check() -> str | None:
|
||||
"""Return an error message if CWD is unsafe for init, else None."""
|
||||
def _preflight_check(*, allow_projects_child: bool = False) -> str | None:
|
||||
"""Return an error message if CWD is unsafe for init, else None.
|
||||
|
||||
When *allow_projects_child* is True, the nested-project check is skipped
|
||||
if CWD is ``<host>/projects/<name>``.
|
||||
"""
|
||||
cwd = Path.cwd()
|
||||
# Block if inside an agent directory
|
||||
if (cwd / ".trinity" / "passport.json").is_file():
|
||||
@@ -738,6 +748,12 @@ def _preflight_check() -> str | None:
|
||||
"This directory is an agent branch (has .trinity/passport.json).\n"
|
||||
"Agents are managed by 'drone @spawn', not 'aipass init'."
|
||||
)
|
||||
# Early exit: if CWD is a valid <host>/projects/<name>, skip nesting check
|
||||
if allow_projects_child:
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||
|
||||
if is_projects_child(cwd):
|
||||
return None
|
||||
# Block if inside an existing AIPass project (registry above us).
|
||||
# Walking up to the filesystem root can hit ancestors that can't be
|
||||
# enumerated or stat'd — e.g. locked Windows system entries at the drive
|
||||
@@ -774,6 +790,9 @@ def run_init(
|
||||
template: str | None = None,
|
||||
) -> int:
|
||||
"""Run the 10-stage init flow. Returns 0 on success."""
|
||||
if not sys.stdin.isatty():
|
||||
non_interactive = True
|
||||
|
||||
# Pre-flight: refuse to run inside existing projects or agent dirs
|
||||
err = _preflight_check()
|
||||
if err:
|
||||
@@ -815,6 +834,8 @@ def run_init(
|
||||
warning(f"Resuming from stage {last_done + 1}...")
|
||||
|
||||
accumulated: Dict[str, Any] = {"template": template}
|
||||
if template != TEMPLATE_AIPASS:
|
||||
accumulated["agent_path"] = "."
|
||||
|
||||
stage_fns = [
|
||||
(1, lambda: stage_1_welcome(dry_run=dry_run)),
|
||||
@@ -844,6 +865,8 @@ def run_init(
|
||||
continue
|
||||
if stage_num in AIPASS_SPECIFIC_STAGES and template != TEMPLATE_AIPASS:
|
||||
logger.info("[init_flow] skipping stage %d (not aipass_framework)", stage_num)
|
||||
if not non_interactive:
|
||||
console.print(f"\n[dim] (skipping step {stage_num} — framework-only)[/dim]")
|
||||
continue
|
||||
try:
|
||||
result = fn() or {}
|
||||
@@ -857,11 +880,6 @@ def run_init(
|
||||
warning(f"Stage {stage_num} error: {exc} — continuing.")
|
||||
_save_stage(stage_num, {"error": str(exc)}, dry_run=dry_run)
|
||||
|
||||
if template != TEMPLATE_AIPASS:
|
||||
console.print()
|
||||
success("Project initialized.")
|
||||
console.print("[dim]Run 'aipass init agent <name>' to add an agent.[/dim]")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -179,17 +179,21 @@ def _verify_binaries(home: Path) -> Dict[str, str | None]:
|
||||
return {"drone": drone, "aipass": aipass}
|
||||
|
||||
|
||||
def _should_run_init(non_interactive: bool, with_init: bool, no_init: bool) -> bool:
|
||||
"""Decide whether to auto-launch init. --no-init wins; --with-init forces on.
|
||||
def _build_install_prompt(home: Path, bins: dict) -> str:
|
||||
"""Compose the authored first prompt for the post-install @aipass chat."""
|
||||
parts = [f"Fresh AIPass install completed at {home}."]
|
||||
for name, path in bins.items():
|
||||
if path:
|
||||
parts.append(f"{name}: {path}.")
|
||||
parts.append(
|
||||
"This is my first time here — what can I do with AIPass? Show me a few things to try, with the exact commands."
|
||||
)
|
||||
return " ".join(parts)
|
||||
|
||||
Default: interactive flows chain into init ("one command, done"); headless
|
||||
flows stop at a wired engine and print the next command (safe for CI/Docker).
|
||||
"""
|
||||
if no_init:
|
||||
return False
|
||||
if with_init:
|
||||
return True
|
||||
return not non_interactive
|
||||
|
||||
def _should_run_init(no_init: bool) -> bool:
|
||||
"""Decide whether to auto-launch init. --no-init skips; default = always chain."""
|
||||
return not no_init
|
||||
|
||||
|
||||
def _handoff_to_init(
|
||||
@@ -357,13 +361,26 @@ def run_install(
|
||||
# Step 4 — hand off into init (or print next steps)
|
||||
console.print()
|
||||
console.print(render_step_header(4, TOTAL_STEPS, "First project"))
|
||||
run_it = _should_run_init(non_interactive, with_init, no_init)
|
||||
run_it = _should_run_init(no_init)
|
||||
_handoff_to_init(home, bins.get("aipass"), non_interactive, dry_run, project, run_it)
|
||||
|
||||
# Log BEFORE exec — launch_inline replaces the process and never returns
|
||||
json_handler.log_operation(
|
||||
"aipass_install",
|
||||
{"home": str(home), "non_interactive": non_interactive, "dry_run": dry_run, "init": run_it},
|
||||
)
|
||||
|
||||
# Install-to-chat handoff — launch @aipass concierge in same terminal
|
||||
if run_it and not dry_run and sys.stdin.isatty():
|
||||
prompt = _build_install_prompt(home, bins)
|
||||
aipass_branch = str(Path(__file__).resolve().parents[2])
|
||||
console.print()
|
||||
console.print("[dim]Launching the AIPass concierge — Ctrl-C to stay in the shell[/dim]")
|
||||
console.print()
|
||||
from aipass.aipass.apps.handlers.handoff_platform import launch_inline
|
||||
|
||||
launch_inline("claude", prompt, aipass_branch)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: new_project.py
|
||||
# Description: aipass new — create projects inside the AIPass installation
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-17
|
||||
# Modified: 2026-07-17
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
aipass new — create projects inside the AIPass installation (DPLAN-0247)
|
||||
|
||||
Creates a project at <host>/projects/<name> with its own git repo,
|
||||
AIPass scaffold, and optional resident agent. Born deployable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.aipass.apps.handlers.json import json_handler
|
||||
from aipass.cli.apps.modules import console, error, success
|
||||
from aipass.prax import logger
|
||||
|
||||
COMMAND = "new"
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""List existing projects in the installation."""
|
||||
from aipass.aipass.apps.handlers.new_project import find_host_root
|
||||
|
||||
host = find_host_root(Path.cwd())
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass new[/bold cyan] — project creator")
|
||||
console.print()
|
||||
|
||||
if host is None:
|
||||
console.print("[dim]Not inside an AIPass installation.[/dim]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
projects_dir = host / "projects"
|
||||
if not projects_dir.is_dir():
|
||||
console.print(f"[dim]No projects/ directory at {host}[/dim]")
|
||||
console.print()
|
||||
return
|
||||
|
||||
projects = [d for d in sorted(projects_dir.iterdir()) if d.is_dir() and not d.name.startswith(".")]
|
||||
if not projects:
|
||||
console.print("[dim]No projects yet. Create one:[/dim]")
|
||||
else:
|
||||
console.print(f"[yellow]{len(projects)} project(s):[/yellow]")
|
||||
for p in projects:
|
||||
has_git = (p / ".git").is_dir()
|
||||
has_reg = any(f.name.endswith("_REGISTRY.json") for f in p.iterdir() if f.is_file())
|
||||
markers = []
|
||||
if has_git:
|
||||
markers.append("git")
|
||||
if has_reg:
|
||||
markers.append("registry")
|
||||
info = f" [dim]({', '.join(markers)})[/dim]" if markers else ""
|
||||
console.print(f" [cyan]{p.name}[/cyan]{info}")
|
||||
|
||||
console.print()
|
||||
console.print("[dim]Create: aipass new <name> [--template python] [--no-agent][/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Print usage help for the new command."""
|
||||
from aipass.aipass.apps.handlers.new_project import TEMPLATES
|
||||
|
||||
console.print()
|
||||
console.print("[bold cyan]aipass new[/bold cyan] — create a project inside AIPass")
|
||||
console.print()
|
||||
console.print("[yellow]USAGE:[/yellow]")
|
||||
console.print(" [green]aipass new <name>[/green] [dim]# Create with empty template[/dim]")
|
||||
console.print(" [green]aipass new <name> --template python[/green] [dim]# Create with Python template[/dim]")
|
||||
console.print(" [green]aipass new <name> --no-agent[/green] [dim]# Skip agent creation[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]TEMPLATES:[/yellow]")
|
||||
console.print(f" [dim]{', '.join(TEMPLATES)}[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]WHAT IT DOES:[/yellow]")
|
||||
console.print(" Creates projects/<name> with its own git repo, AIPass scaffold,")
|
||||
console.print(" and optional resident agent. Born deployable — repo + packaging")
|
||||
console.print(" from minute one.")
|
||||
console.print()
|
||||
|
||||
|
||||
def _prompt_template(templates: list[str]) -> str:
|
||||
"""Prompt user to choose a template interactively."""
|
||||
console.print()
|
||||
console.print("[yellow]Choose a template:[/yellow]")
|
||||
for idx, t in enumerate(templates, 1):
|
||||
console.print(f" [green]{idx}[/green]. {t}")
|
||||
console.print()
|
||||
while True:
|
||||
try:
|
||||
choice = input("Template [1]: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
logger.info("template prompt interrupted, defaulting to %s", templates[0])
|
||||
return templates[0]
|
||||
if not choice:
|
||||
return templates[0]
|
||||
if choice.isdigit() and 1 <= int(choice) <= len(templates):
|
||||
return templates[int(choice) - 1]
|
||||
if choice in templates:
|
||||
return choice
|
||||
error(f"Invalid choice. Enter 1-{len(templates)} or a template name.")
|
||||
|
||||
|
||||
def _prompt_agent() -> bool:
|
||||
"""Prompt user whether to skip agent creation. Returns no_agent flag."""
|
||||
console.print()
|
||||
while True:
|
||||
try:
|
||||
choice = input("Create resident agent? [Y/n]: ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
logger.info("agent prompt interrupted, defaulting to create agent")
|
||||
return False
|
||||
if choice in ("", "y", "yes"):
|
||||
return False
|
||||
if choice in ("n", "no"):
|
||||
return True
|
||||
error("Enter y or n.")
|
||||
|
||||
|
||||
def handle_command(command: str, args: list[str]) -> bool:
|
||||
"""Route the 'new' command. Returns True if handled."""
|
||||
if command != COMMAND:
|
||||
return False
|
||||
|
||||
if not args:
|
||||
json_handler.log_operation("new_project_usage", {"command": command})
|
||||
print_introspection()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
json_handler.log_operation("new_project_help", {"command": command})
|
||||
print_help()
|
||||
return True
|
||||
if args[0] == "--info":
|
||||
json_handler.log_operation("new_project_info", {"command": command})
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
name = args[0]
|
||||
template = None
|
||||
no_agent = None
|
||||
has_template_flag = False
|
||||
has_agent_flag = False
|
||||
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i] == "--template" and i + 1 < len(args):
|
||||
template = args[i + 1]
|
||||
has_template_flag = True
|
||||
i += 2
|
||||
elif args[i] == "--no-agent":
|
||||
no_agent = True
|
||||
has_agent_flag = True
|
||||
i += 1
|
||||
else:
|
||||
error(f"Unknown option: {args[i]}")
|
||||
print_help()
|
||||
return True
|
||||
|
||||
from aipass.aipass.apps.handlers.new_project import TEMPLATES, create_project
|
||||
|
||||
if not has_template_flag:
|
||||
template = _prompt_template(list(TEMPLATES))
|
||||
elif template is None:
|
||||
template = "empty"
|
||||
if not has_agent_flag:
|
||||
no_agent = _prompt_agent()
|
||||
elif no_agent is None:
|
||||
no_agent = False
|
||||
|
||||
try:
|
||||
result = create_project(name, template, no_agent)
|
||||
except (RuntimeError, ValueError) as e:
|
||||
logger.warning("[AIPASS] new project failed: %s", e)
|
||||
error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
console.print()
|
||||
success(f"Project '{name}' created at {result['target']}")
|
||||
console.print()
|
||||
console.print(f" [dim]Registry:[/dim] {result['registry_file']}")
|
||||
console.print(f" [dim]Template:[/dim] {result['template']}")
|
||||
if result["agent_created"]:
|
||||
console.print(" [dim]Agent:[/dim] created (full framework agent)")
|
||||
else:
|
||||
console.print(" [dim]Agent:[/dim] skipped (--no-agent)")
|
||||
|
||||
json_handler.log_operation(
|
||||
"new_project_create",
|
||||
{"name": name, "template": template, "target": result["target"]},
|
||||
)
|
||||
logger.info("[AIPASS] new project: %s (%s) at %s", name, template, result["target"])
|
||||
|
||||
if result["agent_created"] and sys.stdin.isatty():
|
||||
console.print()
|
||||
console.print("[dim]Launching your manager agent — Ctrl-C to stay in the shell[/dim]")
|
||||
console.print()
|
||||
from aipass.aipass.apps.handlers.handoff_platform import launch_inline
|
||||
|
||||
launch_inline(
|
||||
"claude",
|
||||
"You are the new resident agent of this project."
|
||||
" Read your passport and README, then tell me what you can do.",
|
||||
result["agent_home"],
|
||||
)
|
||||
|
||||
console.print()
|
||||
console.print("[yellow]Next steps:[/yellow]")
|
||||
if result["agent_created"]:
|
||||
console.print(f" [cyan]cd {result['agent_home']}[/cyan]")
|
||||
console.print(" [cyan]claude[/cyan] [dim]# meet your project agent[/dim]")
|
||||
else:
|
||||
console.print(f" [cyan]cd {result['target']}[/cyan]")
|
||||
console.print()
|
||||
return True
|
||||
@@ -153,10 +153,10 @@ class TestMain:
|
||||
"""--version prints real package version and returns 0."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--version"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 0
|
||||
printed = mock_print.call_args[0][0]
|
||||
printed = mock_con.print.call_args[0][0]
|
||||
assert printed.startswith("aipass ")
|
||||
assert printed != "aipass 0.1.0"
|
||||
|
||||
@@ -164,10 +164,10 @@ class TestMain:
|
||||
"""-V prints real package version and returns 0."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-V"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 0
|
||||
printed = mock_print.call_args[0][0]
|
||||
printed = mock_con.print.call_args[0][0]
|
||||
assert printed.startswith("aipass ")
|
||||
|
||||
def test_version_flag_fallback(self) -> None:
|
||||
@@ -179,76 +179,81 @@ class TestMain:
|
||||
"aipass.aipass.apps.aipass.importlib.metadata.version",
|
||||
side_effect=_not_found,
|
||||
):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 0
|
||||
mock_print.assert_called_once_with("aipass unknown")
|
||||
mock_con.print.assert_called_once_with("aipass unknown")
|
||||
|
||||
def test_help_flag_shows_help(self) -> None:
|
||||
"""--help shows module list and returns 0."""
|
||||
"""--help calls print_help and returns 0."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "--help"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 0
|
||||
mock_print.assert_called()
|
||||
mock_con.print.assert_called()
|
||||
|
||||
def test_h_flag_shows_help(self) -> None:
|
||||
"""-h shows module list and returns 0."""
|
||||
"""-h shows help and returns 0."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "-h"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print"):
|
||||
with patch("aipass.aipass.apps.aipass.console"):
|
||||
result = main()
|
||||
assert result == 0
|
||||
|
||||
def test_no_args_shows_help(self) -> None:
|
||||
"""No arguments shows module list and returns 0."""
|
||||
"""No arguments shows introspection and returns 0."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print"):
|
||||
with patch("aipass.aipass.apps.aipass.console"):
|
||||
result = main()
|
||||
assert result == 0
|
||||
|
||||
def test_help_word_shows_help(self) -> None:
|
||||
"""'help' as only arg shows module list and returns 0."""
|
||||
def test_help_word_routes_to_module(self) -> None:
|
||||
"""'help' as only arg routes to help_chat module, not root help."""
|
||||
mod = MagicMock()
|
||||
mod.handle_command.return_value = True
|
||||
mod.__name__ = "aipass.aipass.apps.modules.help_chat"
|
||||
mod.COMMAND = "help"
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "help"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print"):
|
||||
result = main()
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||
result = main()
|
||||
assert result == 0
|
||||
mod.handle_command.assert_called_once_with("help", [])
|
||||
|
||||
def test_help_shows_module_count(self) -> None:
|
||||
"""Help output includes discovered module count."""
|
||||
mod = types.ModuleType("test_mod")
|
||||
mod.__doc__ = "Test module doc"
|
||||
def test_introspection_shows_public_commands(self) -> None:
|
||||
"""Introspection lists modules with COMMAND in _PUBLIC_COMMANDS."""
|
||||
mod = types.ModuleType("aipass.aipass.apps.modules.help_chat")
|
||||
mod.__doc__ = "Help chatbot"
|
||||
mod.COMMAND = "help" # type: ignore[attr-defined]
|
||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
main()
|
||||
first_call_args = mock_print.call_args_list[0][0][0]
|
||||
assert "1 modules" in first_call_args
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "help" in printed
|
||||
|
||||
def test_help_shows_module_with_no_doc(self) -> None:
|
||||
"""Module without docstring shows 'No description'."""
|
||||
mod = types.ModuleType("nodoc_mod")
|
||||
mod.__doc__ = None
|
||||
def test_introspection_hides_non_public(self) -> None:
|
||||
"""Modules without COMMAND in _PUBLIC_COMMANDS are hidden."""
|
||||
mod = types.ModuleType("aipass.aipass.apps.modules.internal")
|
||||
mod.__doc__ = "Internal module"
|
||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[mod]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
main()
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "No description" in printed
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "internal" not in printed
|
||||
|
||||
def test_unknown_command_returns_1(self) -> None:
|
||||
"""Unknown command prints error and returns 1."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "xyzzy"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 1
|
||||
mock_print.assert_called_with("Unknown command: xyzzy")
|
||||
mock_con.print.assert_called_with("Unknown command: xyzzy")
|
||||
|
||||
def test_known_command_routes_and_returns_0(self) -> None:
|
||||
"""Known command that gets handled returns 0."""
|
||||
@@ -266,10 +271,10 @@ class TestMain:
|
||||
"""@drone prints guidance pointing to drone, not 'Unknown command'."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@drone"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "@drone" in printed
|
||||
assert "drone routing target" in printed
|
||||
assert "Unknown command" not in printed
|
||||
@@ -278,10 +283,10 @@ class TestMain:
|
||||
"""@memory prints guidance with the actual @name the user typed."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "@memory"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "@memory" in printed
|
||||
assert "drone @memory" in printed
|
||||
|
||||
@@ -289,10 +294,10 @@ class TestMain:
|
||||
"""Non-@ bad command still prints 'Unknown command', not drone guidance."""
|
||||
with patch("aipass.aipass.apps.aipass.sys.argv", ["aipass", "frobnicate"]):
|
||||
with patch("aipass.aipass.apps.aipass.discover_modules", return_value=[]):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
result = main()
|
||||
assert result == 1
|
||||
mock_print.assert_called_with("Unknown command: frobnicate")
|
||||
mock_con.print.assert_called_with("Unknown command: frobnicate")
|
||||
|
||||
def test_command_with_remaining_args(self) -> None:
|
||||
"""Remaining args are passed to route_command."""
|
||||
@@ -306,7 +311,7 @@ class TestMain:
|
||||
mod.handle_command.assert_called_once_with("doctor", ["--verbose", "--fix"])
|
||||
|
||||
def test_help_shows_command_constant(self) -> None:
|
||||
"""Help listing uses module COMMAND constant, not file stem."""
|
||||
"""Introspection uses module COMMAND constant, not file stem."""
|
||||
mod = types.ModuleType("aipass.aipass.apps.modules.help_chat")
|
||||
mod.__doc__ = "Help chatbot"
|
||||
mod.COMMAND = "help" # type: ignore[attr-defined]
|
||||
@@ -316,14 +321,14 @@ class TestMain:
|
||||
"aipass.aipass.apps.aipass.discover_modules",
|
||||
return_value=[mod],
|
||||
):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
main()
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "help" in printed
|
||||
assert "help_chat" not in printed
|
||||
|
||||
def test_help_falls_back_to_stem(self) -> None:
|
||||
"""Without COMMAND constant, help listing uses file stem."""
|
||||
def test_introspection_skips_no_command_module(self) -> None:
|
||||
"""Modules without COMMAND in _PUBLIC_COMMANDS are hidden from introspection."""
|
||||
mod = types.ModuleType("aipass.aipass.apps.modules.doctor")
|
||||
mod.__doc__ = "Doctor module"
|
||||
mod.handle_command = lambda c, a: True # type: ignore[attr-defined]
|
||||
@@ -332,10 +337,14 @@ class TestMain:
|
||||
"aipass.aipass.apps.aipass.discover_modules",
|
||||
return_value=[mod],
|
||||
):
|
||||
with patch("builtins.print") as mock_print:
|
||||
with patch("aipass.aipass.apps.aipass.console") as mock_con:
|
||||
main()
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "doctor" in printed
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert (
|
||||
"Commands:" not in printed or "doctor" not in printed.split("Commands:")[1]
|
||||
if "Commands:" in printed
|
||||
else True
|
||||
)
|
||||
|
||||
def test_handler_crash_surfaces_error(self) -> None:
|
||||
"""Handler crash prints real error, not 'Unknown command'."""
|
||||
@@ -347,12 +356,12 @@ class TestMain:
|
||||
"aipass.aipass.apps.aipass.discover_modules",
|
||||
return_value=[mod],
|
||||
):
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
with patch("aipass.aipass.apps.aipass.console"):
|
||||
with patch("aipass.aipass.apps.aipass.error") as mock_err:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "db connection failed" in printed
|
||||
assert "Unknown command" not in printed
|
||||
err_text = " ".join(str(a) for call in mock_err.call_args_list for a in call[0])
|
||||
assert "db connection failed" in err_text
|
||||
|
||||
def test_import_failure_surfaces_on_command(self) -> None:
|
||||
"""Failed module import surfaces when user types that command."""
|
||||
@@ -365,11 +374,11 @@ class TestMain:
|
||||
):
|
||||
aipass_mod._import_failures.clear()
|
||||
aipass_mod._import_failures["broken"] = ImportError("no module")
|
||||
with patch("builtins.print") as mock_print:
|
||||
result = main()
|
||||
with patch("aipass.aipass.apps.aipass.console"):
|
||||
with patch("aipass.aipass.apps.aipass.error") as mock_err:
|
||||
result = main()
|
||||
assert result == 1
|
||||
printed = " ".join(str(a) for call in mock_print.call_args_list for a in call[0])
|
||||
assert "failed to load" in printed
|
||||
assert "no module" in printed
|
||||
assert "Unknown command" not in printed
|
||||
err_text = " ".join(str(a) for call in mock_err.call_args_list for a in call[0])
|
||||
assert "failed to load" in err_text
|
||||
assert "no module" in err_text
|
||||
aipass_mod._import_failures.clear()
|
||||
|
||||
@@ -656,7 +656,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_no_settings_file_returns_empty(self, tmp_path) -> None:
|
||||
"""Missing settings.json returns no results."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
with patch("aipass.aipass.apps.handlers.provider_reconcile.Path.home", return_value=tmp_path):
|
||||
results = reconcile_stale_deny(fix=False)
|
||||
@@ -664,7 +664,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_no_stale_rules_returns_pass(self, tmp_path) -> None:
|
||||
"""Settings with no stale rm rules returns PASS."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
@@ -680,7 +680,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_stale_rules_detected_without_fix(self, tmp_path) -> None:
|
||||
"""Stale rm rules present returns WARN when fix=False."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
@@ -697,7 +697,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_fix_removes_stale_rules(self, tmp_path) -> None:
|
||||
"""fix=True removes stale rules and preserves others."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
@@ -719,7 +719,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_fix_single_stale_rule(self, tmp_path) -> None:
|
||||
"""fix=True works when only one of two stale rules is present."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
@@ -736,7 +736,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_fix_idempotent(self, tmp_path) -> None:
|
||||
"""Running fix twice is safe — second run returns PASS with no stale rules."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
@@ -753,7 +753,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_empty_deny_list_returns_pass(self, tmp_path) -> None:
|
||||
"""Empty deny list returns PASS."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
@@ -765,7 +765,7 @@ class TestReconcileStaleDeny:
|
||||
|
||||
def test_no_permissions_key_returns_pass(self, tmp_path) -> None:
|
||||
"""Settings without permissions key returns PASS."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
|
||||
from aipass.aipass.apps.modules._doctor_wire import reconcile_stale_deny
|
||||
|
||||
settings = tmp_path / ".claude" / "settings.json"
|
||||
settings.parent.mkdir(parents=True)
|
||||
@@ -781,10 +781,10 @@ class TestCheckWireVerify:
|
||||
|
||||
def test_pass_on_zero_exit(self) -> None:
|
||||
"""Exit 0 from drone @hooks verify produces a PASS row."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
||||
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||
|
||||
fake = MagicMock(returncode=0, stdout="✓ Wire check passed\n\n0 errors, 0 warnings\n")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.subprocess.run", return_value=fake):
|
||||
with patch("aipass.aipass.apps.modules._doctor_wire.subprocess.run", return_value=fake):
|
||||
results = check_wire_verify()
|
||||
assert len(results) == 1
|
||||
assert results[0].label == "wire verify"
|
||||
@@ -792,10 +792,10 @@ class TestCheckWireVerify:
|
||||
|
||||
def test_fail_on_nonzero_exit(self) -> None:
|
||||
"""Non-zero exit from drone @hooks verify produces a FAIL row."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
||||
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||
|
||||
fake = MagicMock(returncode=1, stdout="ERROR empty array\n2 errors, 0 warnings\n")
|
||||
with patch("aipass.aipass.apps.modules.doctor_wire.subprocess.run", return_value=fake):
|
||||
with patch("aipass.aipass.apps.modules._doctor_wire.subprocess.run", return_value=fake):
|
||||
results = check_wire_verify()
|
||||
assert len(results) == 1
|
||||
assert results[0].glyph == "[red]✗[/red]"
|
||||
@@ -803,10 +803,10 @@ class TestCheckWireVerify:
|
||||
|
||||
def test_warn_on_drone_not_found(self) -> None:
|
||||
"""FileNotFoundError (drone missing) produces a WARN row."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
||||
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor_wire.subprocess.run",
|
||||
"aipass.aipass.apps.modules._doctor_wire.subprocess.run",
|
||||
side_effect=FileNotFoundError("drone"),
|
||||
):
|
||||
results = check_wire_verify()
|
||||
@@ -817,10 +817,10 @@ class TestCheckWireVerify:
|
||||
"""TimeoutExpired produces a WARN row."""
|
||||
import subprocess as sp
|
||||
|
||||
from aipass.aipass.apps.modules.doctor_wire import check_wire_verify
|
||||
from aipass.aipass.apps.modules._doctor_wire import check_wire_verify
|
||||
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor_wire.subprocess.run",
|
||||
"aipass.aipass.apps.modules._doctor_wire.subprocess.run",
|
||||
side_effect=sp.TimeoutExpired(cmd="drone", timeout=10),
|
||||
):
|
||||
results = check_wire_verify()
|
||||
@@ -849,15 +849,15 @@ class TestPromptAutoWireIsatty:
|
||||
|
||||
def test_non_tty_stdin_skips_prompt_and_declines(self) -> None:
|
||||
"""Non-tty stdin must NOT call input() — it declines and warns instead."""
|
||||
from aipass.aipass.apps.modules import doctor_wire
|
||||
from aipass.aipass.apps.modules import _doctor_wire
|
||||
|
||||
with (
|
||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
||||
patch.object(_doctor_wire.sys, "stdin") as mock_stdin,
|
||||
patch("builtins.input") as mock_input,
|
||||
patch.object(doctor_wire, "_print_manual_wire_warning") as mock_warn,
|
||||
patch.object(_doctor_wire, "_print_manual_wire_warning") as mock_warn,
|
||||
):
|
||||
mock_stdin.isatty.return_value = False
|
||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
||||
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||
|
||||
assert result is False
|
||||
mock_input.assert_not_called()
|
||||
@@ -865,30 +865,30 @@ class TestPromptAutoWireIsatty:
|
||||
|
||||
def test_tty_stdin_prompts_and_respects_decline(self) -> None:
|
||||
"""Tty stdin still prompts; a 'n' answer declines."""
|
||||
from aipass.aipass.apps.modules import doctor_wire
|
||||
from aipass.aipass.apps.modules import _doctor_wire
|
||||
|
||||
with (
|
||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
||||
patch.object(_doctor_wire.sys, "stdin") as mock_stdin,
|
||||
patch("builtins.input", return_value="n") as mock_input,
|
||||
patch.object(doctor_wire, "_print_manual_wire_warning"),
|
||||
patch.object(_doctor_wire, "_print_manual_wire_warning"),
|
||||
):
|
||||
mock_stdin.isatty.return_value = True
|
||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
||||
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||
|
||||
assert result is False
|
||||
mock_input.assert_called_once()
|
||||
|
||||
def test_tty_stdin_accepts_and_wires(self) -> None:
|
||||
"""Tty stdin with a 'y' answer runs the wire and returns True."""
|
||||
from aipass.aipass.apps.modules import doctor_wire
|
||||
from aipass.aipass.apps.modules import _doctor_wire
|
||||
|
||||
with (
|
||||
patch.object(doctor_wire.sys, "stdin") as mock_stdin,
|
||||
patch.object(_doctor_wire.sys, "stdin") as mock_stdin,
|
||||
patch("builtins.input", return_value="y"),
|
||||
patch.object(doctor_wire, "_auto_wire_provider", return_value=["wired hook"]) as mock_wire,
|
||||
patch.object(_doctor_wire, "_auto_wire_provider", return_value=["wired hook"]) as mock_wire,
|
||||
):
|
||||
mock_stdin.isatty.return_value = True
|
||||
result = doctor_wire._prompt_auto_wire(**self._args())
|
||||
result = _doctor_wire._prompt_auto_wire(**self._args())
|
||||
|
||||
assert result is True
|
||||
mock_wire.assert_called_once()
|
||||
|
||||
@@ -12,7 +12,7 @@ import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from aipass.aipass.apps.modules.doctor_fix import (
|
||||
from aipass.aipass.apps.modules._doctor_fix import (
|
||||
RemediationItem,
|
||||
detect_project_name,
|
||||
format_json_report,
|
||||
@@ -68,7 +68,7 @@ class TestDetectProjectName:
|
||||
no_reg = tmp_path / "empty_project"
|
||||
no_reg.mkdir()
|
||||
with patch(
|
||||
"aipass.aipass.apps.modules.doctor_fix._discover_registry",
|
||||
"aipass.aipass.apps.modules._doctor_fix._discover_registry",
|
||||
return_value=no_reg / "MISSING_REGISTRY.json",
|
||||
):
|
||||
result = detect_project_name(no_reg)
|
||||
@@ -368,16 +368,16 @@ class TestPrintFunctions:
|
||||
class TestDoctorFixHandleCommand:
|
||||
def test_wrong_command(self) -> None:
|
||||
"""Non-doctor_fix commands are not handled."""
|
||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
||||
from aipass.aipass.apps.modules._doctor_fix import handle_command
|
||||
|
||||
assert handle_command("doctor", []) is False
|
||||
assert handle_command("help", []) is False
|
||||
|
||||
def test_no_args_shows_usage(self) -> None:
|
||||
"""No args shows usage message (not introspection banner)."""
|
||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
||||
from aipass.aipass.apps.modules._doctor_fix import handle_command
|
||||
|
||||
with patch("aipass.aipass.apps.modules.doctor_fix.console") as mock_console:
|
||||
with patch("aipass.aipass.apps.modules._doctor_fix.console") as mock_console:
|
||||
result = handle_command("doctor_fix", [])
|
||||
assert result is True
|
||||
printed = " ".join(str(c) for c in mock_console.print.call_args_list)
|
||||
@@ -385,9 +385,9 @@ class TestDoctorFixHandleCommand:
|
||||
|
||||
def test_info_flag(self) -> None:
|
||||
"""--info triggers print_introspection."""
|
||||
from aipass.aipass.apps.modules.doctor_fix import handle_command
|
||||
from aipass.aipass.apps.modules._doctor_fix import handle_command
|
||||
|
||||
with patch("aipass.aipass.apps.modules.doctor_fix.print_introspection") as mock:
|
||||
with patch("aipass.aipass.apps.modules._doctor_fix.print_introspection") as mock:
|
||||
result = handle_command("doctor_fix", ["--info"])
|
||||
assert result is True
|
||||
mock.assert_called_once()
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_feedback.py
|
||||
# Description: Tests for aipass feedback — toggle alias for @hooks feedback pulse
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-18
|
||||
# Modified: 2026-07-18
|
||||
# =============================================
|
||||
|
||||
"""Tests for the aipass feedback module."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from aipass.aipass.apps.modules.feedback import handle_command, print_help, print_introspection
|
||||
|
||||
_MOD = "aipass.aipass.apps.modules.feedback"
|
||||
|
||||
|
||||
class TestHandleCommand:
|
||||
"""Command routing for aipass feedback."""
|
||||
|
||||
def test_ignores_other_commands(self) -> None:
|
||||
"""A non-feedback command is not handled."""
|
||||
assert handle_command("doctor", []) is False
|
||||
|
||||
def test_help(self) -> None:
|
||||
"""--help is handled."""
|
||||
assert handle_command("feedback", ["--help"]) is True
|
||||
|
||||
def test_info(self) -> None:
|
||||
"""--info is handled."""
|
||||
assert handle_command("feedback", ["--info"]) is True
|
||||
|
||||
def test_unknown_arg_shows_error(self) -> None:
|
||||
"""An unknown argument shows an error and help."""
|
||||
with patch(f"{_MOD}.error") as mock_err:
|
||||
assert handle_command("feedback", ["banana"]) is True
|
||||
mock_err.assert_called_once()
|
||||
|
||||
def test_on_delegates_to_hooks(self) -> None:
|
||||
"""'on' delegates to drone @hooks feedback on."""
|
||||
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
||||
handle_command("feedback", ["on"])
|
||||
cmd = run.call_args[0][0]
|
||||
assert cmd == ["drone", "@hooks", "feedback", "on"]
|
||||
|
||||
def test_off_delegates_to_hooks(self) -> None:
|
||||
"""'off' delegates to drone @hooks feedback off."""
|
||||
with patch(f"{_MOD}.subprocess.run", return_value=MagicMock(returncode=0)) as run:
|
||||
handle_command("feedback", ["off"])
|
||||
cmd = run.call_args[0][0]
|
||||
assert cmd == ["drone", "@hooks", "feedback", "off"]
|
||||
|
||||
def test_no_args_shows_introspection(self) -> None:
|
||||
"""No args shows module introspection."""
|
||||
with patch(f"{_MOD}.subprocess.run") as run:
|
||||
assert handle_command("feedback", []) is True
|
||||
run.assert_not_called()
|
||||
|
||||
def test_drone_not_found(self) -> None:
|
||||
"""Missing drone warns cleanly, no crash."""
|
||||
with (
|
||||
patch(f"{_MOD}.subprocess.run", side_effect=FileNotFoundError("drone")),
|
||||
patch(f"{_MOD}.warning") as warn,
|
||||
):
|
||||
handle_command("feedback", ["on"])
|
||||
warn.assert_called_once()
|
||||
|
||||
|
||||
class TestSmoke:
|
||||
"""Help/introspection render without error."""
|
||||
|
||||
def test_print_help_runs(self) -> None:
|
||||
print_help()
|
||||
|
||||
def test_print_introspection_runs(self) -> None:
|
||||
print_introspection()
|
||||
@@ -586,6 +586,17 @@ class TestStages:
|
||||
result = stage_9_handoff(agent_path="src/mybot", non_interactive=True)
|
||||
assert "src/mybot" in result["handoff_command"]
|
||||
|
||||
def test_stage_9_non_interactive_no_spawn(self, tmp_local_json) -> None:
|
||||
"""Non-interactive stage 9 prints the command but never spawns a session."""
|
||||
with (
|
||||
patch(f"{_MOD}.console"),
|
||||
patch("aipass.aipass.apps.modules.handoff.do_handoff") as mock_handoff,
|
||||
):
|
||||
result = stage_9_handoff(non_interactive=True)
|
||||
mock_handoff.assert_not_called()
|
||||
assert result["launched"] is False
|
||||
assert result["handoff_command"]
|
||||
|
||||
def test_stage_10_done_returns_empty(self, tmp_local_json) -> None:
|
||||
"""stage_10_done returns {} and marks stage 10 complete."""
|
||||
with patch(f"{_MOD}.console"):
|
||||
@@ -785,8 +796,8 @@ class TestTemplateSelector:
|
||||
]
|
||||
return {name: MagicMock(return_value={}) for name in stage_names}
|
||||
|
||||
def test_empty_project_default_skips_scaffold(self, tmp_local_json) -> None:
|
||||
"""empty project (default) = no scaffold; framework-only stages 6,7,9,10 skipped."""
|
||||
def test_empty_project_default_skips_agent_and_ping(self, tmp_local_json) -> None:
|
||||
"""empty project (default) = no scaffold; framework-only stages 6,7 skipped; 9,10 run."""
|
||||
mocks = self._stage_patches()
|
||||
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
||||
result = run_init(non_interactive=True, template=TEMPLATE_EMPTY)
|
||||
@@ -798,9 +809,11 @@ class TestTemplateSelector:
|
||||
"stage_4_style_questions",
|
||||
"stage_5_tool_choice",
|
||||
"stage_8_smoke_test",
|
||||
"stage_9_handoff",
|
||||
"stage_10_done",
|
||||
):
|
||||
assert mocks[name].called, f"{name} should have been called"
|
||||
for name in ("stage_6_first_agent", "stage_7_ping_sweep", "stage_9_handoff", "stage_10_done"):
|
||||
for name in ("stage_6_first_agent", "stage_7_ping_sweep"):
|
||||
assert not mocks[name].called, f"{name} should NOT have been called"
|
||||
|
||||
def test_aipass_framework_runs_full_scaffold(self, tmp_local_json) -> None:
|
||||
@@ -862,6 +875,27 @@ class TestTemplateSelector:
|
||||
assert "setup.sh" in msg
|
||||
assert "pip" not in msg
|
||||
|
||||
def test_empty_template_stage9_gets_cwd_as_agent_path(self, tmp_local_json) -> None:
|
||||
"""Empty template sets agent_path='.' so stage 9 hands off from CWD."""
|
||||
mocks = self._stage_patches()
|
||||
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
|
||||
run_init(non_interactive=True, template=TEMPLATE_EMPTY)
|
||||
stage_9_call = mocks["stage_9_handoff"].call_args
|
||||
assert stage_9_call is not None
|
||||
agent_path_arg = stage_9_call[0][2] if len(stage_9_call[0]) > 2 else stage_9_call[1].get("agent_path", "")
|
||||
assert agent_path_arg == "."
|
||||
|
||||
def test_non_tty_forces_non_interactive(self, tmp_local_json) -> None:
|
||||
"""When stdin is not a TTY, run_init auto-forces non_interactive (no crash)."""
|
||||
mocks = self._stage_patches()
|
||||
with (
|
||||
patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks),
|
||||
patch("sys.stdin") as mock_stdin,
|
||||
):
|
||||
mock_stdin.isatty.return_value = False
|
||||
rc = run_init(non_interactive=False, template=TEMPLATE_EMPTY)
|
||||
assert rc == 0
|
||||
|
||||
def test_aipass_specific_stages_constant(self) -> None:
|
||||
"""AIPASS_SPECIFIC_STAGES contains exactly {6, 7, 9, 10}."""
|
||||
assert AIPASS_SPECIFIC_STAGES == {6, 7, 9, 10}
|
||||
"""AIPASS_SPECIFIC_STAGES contains exactly {6, 7} — stages 9/10 run for ALL templates."""
|
||||
assert AIPASS_SPECIFIC_STAGES == {6, 7}
|
||||
|
||||
@@ -17,6 +17,7 @@ from aipass.aipass.apps.modules.install import (
|
||||
DEFAULT_HOME,
|
||||
DEFAULT_PROJECT,
|
||||
TOTAL_STEPS,
|
||||
_build_install_prompt,
|
||||
_clone_repo,
|
||||
_handoff_to_init,
|
||||
_looks_like_aipass_tree,
|
||||
@@ -215,21 +216,13 @@ class TestRunInstall:
|
||||
class TestShouldRunInit:
|
||||
"""Deciding whether the install chains into init."""
|
||||
|
||||
def test_no_init_wins(self) -> None:
|
||||
"""--no-init disables the handoff even alongside --with-init."""
|
||||
assert _should_run_init(non_interactive=False, with_init=True, no_init=True) is False
|
||||
def test_no_init_skips(self) -> None:
|
||||
"""--no-init disables the handoff."""
|
||||
assert _should_run_init(no_init=True) is False
|
||||
|
||||
def test_with_init_forces_headless(self) -> None:
|
||||
"""--with-init runs init even when the install was headless."""
|
||||
assert _should_run_init(non_interactive=True, with_init=True, no_init=False) is True
|
||||
|
||||
def test_headless_defaults_off(self) -> None:
|
||||
"""A plain headless install stops before init."""
|
||||
assert _should_run_init(non_interactive=True, with_init=False, no_init=False) is False
|
||||
|
||||
def test_interactive_defaults_on(self) -> None:
|
||||
"""A plain interactive install chains into init."""
|
||||
assert _should_run_init(non_interactive=False, with_init=False, no_init=False) is True
|
||||
def test_default_chains(self) -> None:
|
||||
"""Default: always chain into init."""
|
||||
assert _should_run_init(no_init=False) is True
|
||||
|
||||
|
||||
class TestResolveProjectDir:
|
||||
@@ -327,6 +320,75 @@ class TestHandleCommand:
|
||||
assert kwargs["project"] == "/x/proj"
|
||||
|
||||
|
||||
class TestBuildInstallPrompt:
|
||||
"""Authored first prompt for the post-install @aipass chat."""
|
||||
|
||||
def test_includes_home(self, tmp_path: Path) -> None:
|
||||
"""Prompt mentions the install home directory."""
|
||||
prompt = _build_install_prompt(tmp_path, {"drone": "/x/drone", "aipass": "/x/aipass"})
|
||||
assert str(tmp_path) in prompt
|
||||
|
||||
def test_includes_verified_bins(self) -> None:
|
||||
"""Verified binary paths appear in the prompt."""
|
||||
prompt = _build_install_prompt(Path("/h"), {"drone": "/x/drone", "aipass": "/x/aipass"})
|
||||
assert "/x/drone" in prompt
|
||||
assert "/x/aipass" in prompt
|
||||
|
||||
def test_omits_none_bins(self) -> None:
|
||||
"""Binaries that weren't found are omitted, not shown as None."""
|
||||
prompt = _build_install_prompt(Path("/h"), {"drone": None, "aipass": "/x/aipass"})
|
||||
assert "None" not in prompt
|
||||
assert "/x/aipass" in prompt
|
||||
|
||||
def test_ends_with_question(self) -> None:
|
||||
"""Prompt ends by asking what to explore."""
|
||||
prompt = _build_install_prompt(Path("/h"), {})
|
||||
assert "?" in prompt
|
||||
|
||||
|
||||
class TestInstallChatHandoff:
|
||||
"""Install-to-chat handoff launches @aipass after init on TTY."""
|
||||
|
||||
def test_tty_launches_inline(self) -> None:
|
||||
"""Interactive TTY install launches the @aipass concierge after init."""
|
||||
home = Path("/fake/AIPass")
|
||||
with (
|
||||
patch(f"{_MOD}._resolve_home", return_value=home),
|
||||
patch(f"{_MOD}.is_throwaway_path", return_value=False),
|
||||
patch(f"{_MOD}._clone_repo", return_value=True),
|
||||
patch(f"{_MOD}._run_setup", return_value=True),
|
||||
patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}),
|
||||
patch(f"{_MOD}._check_and_fix_owner"),
|
||||
patch(f"{_MOD}._handoff_to_init"),
|
||||
patch(f"{_MOD}.sys.stdin") as mock_stdin,
|
||||
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||
):
|
||||
mock_stdin.isatty.return_value = True
|
||||
run_install(non_interactive=False, dry_run=False)
|
||||
mock_launch.assert_called_once()
|
||||
prompt_arg = mock_launch.call_args[0][1]
|
||||
assert "Fresh AIPass install" in prompt_arg
|
||||
|
||||
def test_no_tty_skips_launch(self) -> None:
|
||||
"""Non-TTY install skips the chat handoff."""
|
||||
home = Path("/fake/AIPass")
|
||||
with (
|
||||
patch(f"{_MOD}._resolve_home", return_value=home),
|
||||
patch(f"{_MOD}.is_throwaway_path", return_value=False),
|
||||
patch(f"{_MOD}._clone_repo", return_value=True),
|
||||
patch(f"{_MOD}._run_setup", return_value=True),
|
||||
patch(f"{_MOD}._verify_binaries", return_value={"drone": "/x/drone", "aipass": "/x/aipass"}),
|
||||
patch(f"{_MOD}._check_and_fix_owner"),
|
||||
patch(f"{_MOD}._handoff_to_init"),
|
||||
patch(f"{_MOD}.sys.stdin") as mock_stdin,
|
||||
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||
):
|
||||
mock_stdin.isatty.return_value = False
|
||||
rc = run_install(non_interactive=True, dry_run=False)
|
||||
mock_launch.assert_not_called()
|
||||
assert rc == 0
|
||||
|
||||
|
||||
class TestSmoke:
|
||||
"""Help/introspection render and constants hold."""
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ class TestReturnTypeContracts:
|
||||
|
||||
def test_doctor_wire_handle_command_returns_bool(self):
|
||||
"""Doctor wire handle_command returns True for match, False otherwise."""
|
||||
from aipass.aipass.apps.modules.doctor_wire import handle_command as wire_cmd
|
||||
from aipass.aipass.apps.modules._doctor_wire import handle_command as wire_cmd
|
||||
|
||||
assert wire_cmd("doctor_wire", []) is True
|
||||
assert wire_cmd("not_wire", []) is False
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_new_project.py
|
||||
# Description: Tests for aipass new — project creation handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-17
|
||||
# Modified: 2026-07-17
|
||||
# =============================================
|
||||
|
||||
"""Tests for the new_project handler and module.
|
||||
|
||||
All file operations use tmp_path to stay fully isolated from the live
|
||||
filesystem. Tests mock subprocess calls to avoid real git/drone invocations.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest # pyright: ignore[reportMissingImports]
|
||||
|
||||
from aipass.aipass.apps.handlers.new_project import (
|
||||
_agent_home,
|
||||
_registry_name,
|
||||
_spawn_project_agent,
|
||||
_validate_name,
|
||||
_write_registry,
|
||||
_write_template,
|
||||
create_project,
|
||||
find_host_root,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_host_root
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_find_host_root_finds_registry(tmp_path):
|
||||
"""Finds directory containing *_REGISTRY.json."""
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
sub = tmp_path / "projects" / "myapp"
|
||||
sub.mkdir(parents=True)
|
||||
assert find_host_root(sub) == tmp_path
|
||||
|
||||
|
||||
def test_find_host_root_returns_none_without_registry(tmp_path):
|
||||
"""Returns None when no registry exists above start."""
|
||||
assert find_host_root(tmp_path) is None
|
||||
|
||||
|
||||
def test_find_host_root_finds_closest_registry(tmp_path):
|
||||
"""Walks up and finds the closest *_REGISTRY.json."""
|
||||
(tmp_path / "HOST_REGISTRY.json").write_text("{}")
|
||||
sub = tmp_path / "a" / "b"
|
||||
sub.mkdir(parents=True)
|
||||
assert find_host_root(sub) == tmp_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_name_accepts_valid():
|
||||
assert _validate_name("myapp") == "myapp"
|
||||
assert _validate_name("My-App_2") == "My-App_2"
|
||||
|
||||
|
||||
def test_validate_name_rejects_empty():
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
_validate_name("")
|
||||
|
||||
|
||||
def test_validate_name_rejects_leading_digit():
|
||||
with pytest.raises(ValueError, match="Must start with a letter"):
|
||||
_validate_name("2fast")
|
||||
|
||||
|
||||
def test_validate_name_rejects_special_chars():
|
||||
with pytest.raises(ValueError, match="Must start with a letter"):
|
||||
_validate_name("my app!")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _registry_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_name_uppercases():
|
||||
assert _registry_name("myapp") == "MYAPP"
|
||||
|
||||
|
||||
def test_registry_name_replaces_special():
|
||||
assert _registry_name("my.app") == "MY_APP"
|
||||
|
||||
|
||||
def test_registry_name_preserves_hyphens():
|
||||
assert _registry_name("my-app") == "MY-APP"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _write_registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_registry_creates_file(tmp_path):
|
||||
rid, fname = _write_registry(tmp_path, "demo")
|
||||
path = tmp_path / fname
|
||||
assert path.exists()
|
||||
data = json.loads(path.read_text())
|
||||
assert data["metadata"]["id"] == rid
|
||||
assert data["metadata"]["name"] == "DEMO"
|
||||
assert fname == "DEMO_REGISTRY.json"
|
||||
assert data["branches"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _write_template — empty
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_template_empty(tmp_path):
|
||||
created = _write_template(tmp_path, "demo", "empty")
|
||||
assert "README.md" in created
|
||||
assert ".gitignore" in created
|
||||
assert (tmp_path / "README.md").exists()
|
||||
assert (tmp_path / ".gitignore").exists()
|
||||
assert not (tmp_path / "pyproject.toml").exists()
|
||||
assert not (tmp_path / "src").exists()
|
||||
gitignore = (tmp_path / ".gitignore").read_text()
|
||||
assert ".venv\n" in gitignore
|
||||
assert ".venv/\n" not in gitignore
|
||||
assert "*_REGISTRY.lock" in gitignore
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _write_template — python
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_template_python(tmp_path):
|
||||
created = _write_template(tmp_path, "demo", "python")
|
||||
assert "pyproject.toml" in created
|
||||
assert "src/demo/__init__.py" in created
|
||||
assert (tmp_path / "pyproject.toml").exists()
|
||||
assert (tmp_path / "src" / "demo" / "__init__.py").exists()
|
||||
pyproject = (tmp_path / "pyproject.toml").read_text()
|
||||
assert 'name = "demo"' in pyproject
|
||||
|
||||
|
||||
def test_write_template_python_hyphen_name(tmp_path):
|
||||
_write_template(tmp_path, "my-app", "python")
|
||||
assert (tmp_path / "src" / "my_app" / "__init__.py").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_project — integration (mocked subprocess)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def host_env(tmp_path):
|
||||
"""Set up a minimal AIPass host installation in tmp_path."""
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text(json.dumps({"metadata": {"id": "host-id"}, "branches": []}))
|
||||
(tmp_path / "projects").mkdir()
|
||||
(tmp_path / ".aipass").mkdir()
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _mock_git_run(args, **kwargs):
|
||||
"""Stub subprocess.run for git commands — always succeeds."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
|
||||
def test_create_project_empty_template(host_env, monkeypatch):
|
||||
monkeypatch.chdir(host_env)
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._enroll_project",
|
||||
),
|
||||
):
|
||||
result = create_project("testproj", template="empty", no_agent=True)
|
||||
|
||||
target = Path(result["target"])
|
||||
assert target.exists()
|
||||
assert result["name"] == "testproj"
|
||||
assert result["template"] == "empty"
|
||||
assert result["registry_file"] == "TESTPROJ_REGISTRY.json"
|
||||
assert (target / "TESTPROJ_REGISTRY.json").exists()
|
||||
assert (target / "README.md").exists()
|
||||
assert (target / ".gitignore").exists()
|
||||
assert not (target / "pyproject.toml").exists()
|
||||
|
||||
|
||||
def test_create_project_python_template(host_env, monkeypatch):
|
||||
monkeypatch.chdir(host_env)
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._enroll_project",
|
||||
),
|
||||
):
|
||||
result = create_project("pyapp", template="python", no_agent=True)
|
||||
|
||||
target = Path(result["target"])
|
||||
assert (target / "pyproject.toml").exists()
|
||||
assert (target / "src" / "pyapp" / "__init__.py").exists()
|
||||
|
||||
|
||||
def test_create_project_rejects_existing(host_env, monkeypatch):
|
||||
monkeypatch.chdir(host_env)
|
||||
(host_env / "projects" / "taken").mkdir()
|
||||
with pytest.raises(RuntimeError, match="already exists"):
|
||||
create_project("taken", no_agent=True)
|
||||
|
||||
|
||||
def test_create_project_rejects_invalid_name(host_env, monkeypatch):
|
||||
monkeypatch.chdir(host_env)
|
||||
with pytest.raises(ValueError, match="Must start with a letter"):
|
||||
create_project("123bad", no_agent=True)
|
||||
|
||||
|
||||
def test_create_project_rejects_bad_template(host_env, monkeypatch):
|
||||
monkeypatch.chdir(host_env)
|
||||
with pytest.raises(ValueError, match="Unknown template"):
|
||||
create_project("foo", template="rust", no_agent=True)
|
||||
|
||||
|
||||
def test_create_project_no_host(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with pytest.raises(RuntimeError, match="Not inside an AIPass"):
|
||||
create_project("foo", no_agent=True)
|
||||
|
||||
|
||||
def test_create_project_cleans_up_on_failure(host_env, monkeypatch):
|
||||
monkeypatch.chdir(host_env)
|
||||
|
||||
def _fail_git(args, **kwargs):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
result = MagicMock()
|
||||
result.returncode = 1
|
||||
result.stderr = "simulated failure"
|
||||
return result
|
||||
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_fail_git),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
pytest.raises(RuntimeError, match="simulated failure"),
|
||||
):
|
||||
create_project("failproj", no_agent=True)
|
||||
|
||||
assert not (host_env / "projects" / "failproj").exists()
|
||||
|
||||
|
||||
def test_create_project_registry_before_scaffold(host_env, monkeypatch):
|
||||
"""Registry file must exist before scaffold runs (order invariant)."""
|
||||
monkeypatch.chdir(host_env)
|
||||
creation_order = []
|
||||
|
||||
original_write_registry = _write_registry
|
||||
original_write_template = _write_template
|
||||
|
||||
def track_registry(target, name):
|
||||
creation_order.append("registry")
|
||||
return original_write_registry(target, name)
|
||||
|
||||
def track_template(target, name, template):
|
||||
creation_order.append("template")
|
||||
return original_write_template(target, name, template)
|
||||
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project._write_registry",
|
||||
side_effect=track_registry,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project._write_template",
|
||||
side_effect=track_template,
|
||||
),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
):
|
||||
create_project("ordertest", no_agent=True)
|
||||
|
||||
assert creation_order.index("registry") < creation_order.index("template")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _spawn_project_agent (delegates to spawn_agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SPAWN_SUCCESS = {
|
||||
"success": True,
|
||||
"branch_name": "DEMO",
|
||||
"path": "/tmp/demo",
|
||||
"files_copied": 12,
|
||||
"registry_updated": True,
|
||||
"validation_issues": [],
|
||||
}
|
||||
|
||||
|
||||
def test_agent_home_simple():
|
||||
"""Agent home is src/<pkg>/<pkg>/."""
|
||||
from pathlib import Path
|
||||
|
||||
home = _agent_home(Path("/proj"), "demo")
|
||||
assert home == Path("/proj/src/demo/demo")
|
||||
|
||||
|
||||
def test_agent_home_hyphenated():
|
||||
"""Hyphens normalized to underscores, matching python template."""
|
||||
from pathlib import Path
|
||||
|
||||
home = _agent_home(Path("/proj"), "my-app")
|
||||
assert home == Path("/proj/src/my_app/my_app")
|
||||
|
||||
|
||||
def test_spawn_project_agent_calls_spawn(tmp_path):
|
||||
"""Calls spawn_agent with correct citizen_class, purpose, and agent_home path."""
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value=_SPAWN_SUCCESS,
|
||||
) as mock_spawn:
|
||||
result = _spawn_project_agent(tmp_path, "demo")
|
||||
expected_home = str(tmp_path / "src" / "demo" / "demo")
|
||||
mock_spawn.assert_called_once_with(
|
||||
target_path=expected_home,
|
||||
role="project_agent",
|
||||
purpose="Resident agent of the demo project.",
|
||||
citizen_class="project_agent",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["branch_name"] == "DEMO"
|
||||
|
||||
|
||||
def test_spawn_project_agent_raises_on_failure(tmp_path):
|
||||
"""Raises RuntimeError when spawn_agent returns success=False."""
|
||||
with (
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value={"success": False, "error": "template missing"},
|
||||
),
|
||||
pytest.raises(RuntimeError, match="spawn_agent failed.*template missing"),
|
||||
):
|
||||
_spawn_project_agent(tmp_path, "broken")
|
||||
|
||||
|
||||
def test_spawn_project_agent_returns_spawn_result(tmp_path):
|
||||
"""Returns the full result dict from spawn_agent."""
|
||||
with patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value={**_SPAWN_SUCCESS, "citizen_number": 1},
|
||||
):
|
||||
result = _spawn_project_agent(tmp_path, "demo")
|
||||
assert result["files_copied"] == 12
|
||||
assert result["citizen_number"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_project — WITH agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_project_with_agent(host_env, monkeypatch):
|
||||
"""WITH-agent path: spawn_agent called, result propagated."""
|
||||
monkeypatch.chdir(host_env)
|
||||
spawn_ok = {
|
||||
"success": True,
|
||||
"branch_name": "WITHAGENT",
|
||||
"path": str(host_env / "projects" / "withagent"),
|
||||
"files_copied": 15,
|
||||
"registry_updated": True,
|
||||
"validation_issues": [],
|
||||
}
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value=spawn_ok,
|
||||
) as mock_spawn,
|
||||
):
|
||||
result = create_project("withagent", template="empty", no_agent=False)
|
||||
|
||||
assert result["agent_created"] is True
|
||||
assert result["spawn_result"] == spawn_ok
|
||||
expected_home = str(host_env / "projects" / "withagent" / "src" / "withagent" / "withagent")
|
||||
assert result["agent_home"] == expected_home
|
||||
mock_spawn.assert_called_once()
|
||||
call_kwargs = mock_spawn.call_args[1]
|
||||
assert call_kwargs["target_path"] == expected_home
|
||||
assert call_kwargs["citizen_class"] == "project_agent"
|
||||
|
||||
|
||||
def test_create_project_spawn_failure_cleans_up(host_env, monkeypatch):
|
||||
"""spawn_agent failure triggers cleanup — no partial project left."""
|
||||
monkeypatch.chdir(host_env)
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value={"success": False, "error": "template missing"},
|
||||
),
|
||||
pytest.raises(RuntimeError, match="spawn_agent failed"),
|
||||
):
|
||||
create_project("failspawn", template="empty", no_agent=False)
|
||||
|
||||
assert not (host_env / "projects" / "failspawn").exists()
|
||||
|
||||
|
||||
def test_create_project_no_agent_next_steps(host_env, monkeypatch):
|
||||
"""no_agent output omits 'meet your project agent' line."""
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
monkeypatch.chdir(host_env)
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
patch("aipass.aipass.apps.modules.new_project.console") as mock_con,
|
||||
):
|
||||
handle_command("new", ["cosmtest", "--template", "empty", "--no-agent"])
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "meet your project agent" not in printed
|
||||
|
||||
|
||||
def test_create_project_no_agent_flag(host_env, monkeypatch):
|
||||
"""no_agent=True skips passport and registry seating."""
|
||||
monkeypatch.chdir(host_env)
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
):
|
||||
result = create_project("noagent", template="empty", no_agent=True)
|
||||
|
||||
assert result["agent_created"] is False
|
||||
assert result["agent_home"] is None
|
||||
target = Path(result["target"])
|
||||
assert not (target / "src" / "noagent" / "noagent").exists()
|
||||
reg = json.loads((target / result["registry_file"]).read_text())
|
||||
assert reg["metadata"]["total_branches"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_projects_child (guard relaxation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_projects_child_valid(tmp_path):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
target = tmp_path / "projects" / "myapp"
|
||||
target.mkdir(parents=True)
|
||||
assert is_projects_child(target) is True
|
||||
|
||||
|
||||
def test_is_projects_child_not_in_projects(tmp_path):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
target = tmp_path / "elsewhere" / "myapp"
|
||||
target.mkdir(parents=True)
|
||||
assert is_projects_child(target) is False
|
||||
|
||||
|
||||
def test_is_projects_child_no_host_registry(tmp_path):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import is_projects_child
|
||||
|
||||
target = tmp_path / "projects" / "myapp"
|
||||
target.mkdir(parents=True)
|
||||
assert is_projects_child(target) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _guard_init relaxation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_guard_init_blocks_nested_by_default(tmp_path):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import _guard_init
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
target = tmp_path / "projects" / "nested"
|
||||
target.mkdir(parents=True)
|
||||
with pytest.raises(RuntimeError, match="inside AIPass project"):
|
||||
_guard_init(target)
|
||||
|
||||
|
||||
def test_guard_init_allows_nested_with_flag(tmp_path):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import _guard_init
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
target = tmp_path / "projects" / "nested"
|
||||
target.mkdir(parents=True)
|
||||
_guard_init(target, allow_projects_child=True)
|
||||
|
||||
|
||||
def test_guard_init_still_blocks_non_projects_nested(tmp_path):
|
||||
from aipass.aipass.apps.handlers.init.bootstrap import _guard_init
|
||||
|
||||
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
|
||||
target = tmp_path / "elsewhere" / "nested"
|
||||
target.mkdir(parents=True)
|
||||
with pytest.raises(RuntimeError, match="inside AIPass project"):
|
||||
_guard_init(target, allow_projects_child=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module handle_command
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_module_handles_new_command():
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
assert handle_command("notmine", []) is False
|
||||
|
||||
|
||||
def test_module_handles_help():
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
assert handle_command("new", ["--help"]) is True
|
||||
|
||||
|
||||
def test_module_handles_no_args():
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
assert handle_command("new", []) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive prompts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prompt_template_default():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||
|
||||
with patch("builtins.input", return_value=""):
|
||||
assert _prompt_template(["empty", "python"]) == "empty"
|
||||
|
||||
|
||||
def test_prompt_template_by_number():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||
|
||||
with patch("builtins.input", return_value="2"):
|
||||
assert _prompt_template(["empty", "python"]) == "python"
|
||||
|
||||
|
||||
def test_prompt_template_by_name():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||
|
||||
with patch("builtins.input", return_value="python"):
|
||||
assert _prompt_template(["empty", "python"]) == "python"
|
||||
|
||||
|
||||
def test_prompt_template_eof():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_template
|
||||
|
||||
with patch("builtins.input", side_effect=EOFError):
|
||||
assert _prompt_template(["empty", "python"]) == "empty"
|
||||
|
||||
|
||||
def test_prompt_agent_default_yes():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_agent
|
||||
|
||||
with patch("builtins.input", return_value=""):
|
||||
assert _prompt_agent() is False
|
||||
|
||||
|
||||
def test_prompt_agent_no():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_agent
|
||||
|
||||
with patch("builtins.input", return_value="n"):
|
||||
assert _prompt_agent() is True
|
||||
|
||||
|
||||
def test_prompt_agent_eof():
|
||||
from aipass.aipass.apps.modules.new_project import _prompt_agent
|
||||
|
||||
with patch("builtins.input", side_effect=EOFError):
|
||||
assert _prompt_agent() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TTY auto-launch (FIX 3: aipass new auto-launches on TTY)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tty_auto_launches_agent(host_env, monkeypatch):
|
||||
"""On a TTY with an agent created, launch_inline is called."""
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
monkeypatch.chdir(host_env)
|
||||
spawn_ok = {
|
||||
"success": True,
|
||||
"branch_name": "LAUNCH",
|
||||
"path": str(host_env / "projects" / "launch"),
|
||||
"files_copied": 12,
|
||||
"registry_updated": True,
|
||||
"validation_issues": [],
|
||||
}
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch("builtins.input", return_value=""),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value=spawn_ok,
|
||||
),
|
||||
patch("aipass.aipass.apps.modules.new_project.console"),
|
||||
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||
):
|
||||
mock_sys.stdin.isatty.return_value = True
|
||||
handle_command("new", ["launch", "--template", "empty"])
|
||||
mock_launch.assert_called_once()
|
||||
assert "launch" in mock_launch.call_args[0][2]
|
||||
|
||||
|
||||
def test_no_tty_skips_auto_launch(host_env, monkeypatch):
|
||||
"""On a non-TTY, launch_inline is NOT called — fallback to printed instructions."""
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
monkeypatch.chdir(host_env)
|
||||
spawn_ok = {
|
||||
"success": True,
|
||||
"branch_name": "PIPED",
|
||||
"path": str(host_env / "projects" / "piped"),
|
||||
"files_copied": 12,
|
||||
"registry_updated": True,
|
||||
"validation_issues": [],
|
||||
}
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch("builtins.input", return_value=""),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.new_project.spawn_agent",
|
||||
return_value=spawn_ok,
|
||||
),
|
||||
patch("aipass.aipass.apps.modules.new_project.console") as mock_con,
|
||||
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||
):
|
||||
mock_sys.stdin.isatty.return_value = False
|
||||
handle_command("new", ["piped", "--template", "empty"])
|
||||
mock_launch.assert_not_called()
|
||||
printed = " ".join(str(a) for call in mock_con.print.call_args_list for a in call[0])
|
||||
assert "cd" in printed
|
||||
assert "claude" in printed
|
||||
|
||||
|
||||
def test_no_agent_skips_auto_launch(host_env, monkeypatch):
|
||||
"""With --no-agent, launch_inline is not called even on TTY."""
|
||||
from aipass.aipass.apps.modules.new_project import handle_command
|
||||
|
||||
monkeypatch.chdir(host_env)
|
||||
with (
|
||||
patch("subprocess.run", side_effect=_mock_git_run),
|
||||
patch(
|
||||
"aipass.aipass.apps.handlers.init.bootstrap._detect_aipass_home",
|
||||
return_value=None,
|
||||
),
|
||||
patch("aipass.aipass.apps.handlers.init.bootstrap._enroll_project"),
|
||||
patch("aipass.aipass.apps.modules.new_project.console"),
|
||||
patch("aipass.aipass.apps.modules.new_project.sys") as mock_sys,
|
||||
patch("aipass.aipass.apps.handlers.handoff_platform.launch_inline") as mock_launch,
|
||||
):
|
||||
mock_sys.stdin.isatty.return_value = True
|
||||
handle_command("new", ["nolaunch", "--template", "empty", "--no-agent"])
|
||||
mock_launch.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# aipass.py entry point help (cli_ux)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_aipass_print_introspection():
|
||||
from aipass.aipass.apps.aipass import print_introspection
|
||||
|
||||
print_introspection([])
|
||||
|
||||
|
||||
def test_aipass_print_help():
|
||||
from aipass.aipass.apps.aipass import print_help
|
||||
|
||||
print_help([])
|
||||
@@ -18,6 +18,27 @@ drone @api <command> [args]
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Validate your API key
|
||||
drone @api validate
|
||||
|
||||
# Test the connection
|
||||
drone @api test
|
||||
|
||||
# List available models
|
||||
drone @api models
|
||||
|
||||
# Make an API call
|
||||
drone @api call "Hello, world" --model anthropic/claude-3.5-sonnet
|
||||
|
||||
# Check usage stats
|
||||
drone @api stats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|
||||
@@ -213,6 +213,24 @@ def print_help():
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [yellow]Credentials:[/yellow]")
|
||||
console.print(" [dim]drone @api get-key[/dim] [dim]# Show OpenRouter key[/dim]")
|
||||
console.print(" [dim]drone @api validate[/dim] [dim]# Validate OpenRouter key[/dim]")
|
||||
console.print(" [dim]drone @api validate google[/dim] [dim]# Validate Google OAuth2[/dim]")
|
||||
console.print()
|
||||
console.print(" [yellow]Models & calls:[/yellow]")
|
||||
console.print(" [dim]drone @api models --all[/dim] [dim]# List all models[/dim]")
|
||||
console.print(' [dim]drone @api call "Summarize this" --model anthropic/claude-3.5-sonnet[/dim]')
|
||||
console.print()
|
||||
console.print(" [yellow]Usage tracking:[/yellow]")
|
||||
console.print(" [dim]drone @api stats[/dim] [dim]# Overall usage stats[/dim]")
|
||||
console.print(" [dim]drone @api caller-usage flow[/dim] [dim]# Usage by caller[/dim]")
|
||||
console.print()
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
console.print(
|
||||
"[dim]Commands: get-key, get-secret, validate, test, models, status, call,"
|
||||
" list-providers, init, track, stats, session, caller-usage, cleanup[/dim]"
|
||||
|
||||
@@ -79,6 +79,27 @@ All 11 commands are auto-discovered by the entry point router.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Register a project for backup
|
||||
drone @backup register /path/to/project --name myapp
|
||||
|
||||
# Full mirror snapshot
|
||||
drone @backup snapshot @myapp
|
||||
|
||||
# Incremental timestamped backup
|
||||
drone @backup versioned @myapp
|
||||
|
||||
# Check backup status
|
||||
drone @backup status @myapp
|
||||
|
||||
# List available versions of a file
|
||||
drone @backup restore @myapp list src/main.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `.backup/` Store Structure
|
||||
|
||||
Each registered project gets a `.backup/` directory at its root:
|
||||
|
||||
@@ -42,7 +42,7 @@ def print_introspection(modules: list[Any]) -> None:
|
||||
console.print()
|
||||
console.print(f"[bold cyan]BACKUP[/bold cyan] v{VERSION} — project backup & drive sync")
|
||||
console.print()
|
||||
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
||||
console.print(f"[bold dim]Discovered Modules:[/bold dim] {len(modules)}")
|
||||
console.print()
|
||||
for module in modules:
|
||||
name = module.__name__.split(".")[-1]
|
||||
@@ -83,6 +83,26 @@ def print_help() -> None:
|
||||
console.print(" [green]share[/green] Upload a single file to Drive + get a shareable link")
|
||||
console.print(" [green]drive_clear[/green] Clear backups from the remote drive")
|
||||
console.print()
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [bold dim]Register & back up:[/bold dim]")
|
||||
console.print(" [dim]drone @backup register /path/to/project --name myapp[/dim]")
|
||||
console.print(" [dim]drone @backup snapshot @myapp[/dim]")
|
||||
console.print(" [dim]drone @backup versioned @myapp[/dim]")
|
||||
console.print(" [dim]drone @backup all @myapp[/dim]")
|
||||
console.print()
|
||||
console.print(" [bold dim]Status & restore:[/bold dim]")
|
||||
console.print(" [dim]drone @backup status @myapp[/dim]")
|
||||
console.print(" [dim]drone @backup restore @myapp list src/main.py[/dim]")
|
||||
console.print(" [dim]drone @backup restore @myapp file src/main.py ./restored.py[/dim]")
|
||||
console.print()
|
||||
console.print(" [bold dim]Drive sync:[/bold dim]")
|
||||
console.print(" [dim]drone @backup drive_check @myapp[/dim]")
|
||||
console.print(" [dim]drone @backup drive_sync @myapp[/dim]")
|
||||
console.print(" [dim]drone @backup share report.pdf --public[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def discover_modules() -> list[Any]:
|
||||
|
||||
@@ -7,7 +7,16 @@
|
||||
**Version:** 2.1.0
|
||||
**Seedgo:** 99%
|
||||
**Tests:** 127 passing (5 files)
|
||||
**Last Updated:** 2026-05-16
|
||||
**Last Updated:** 2026-07-17
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @cli # Show discovered modules
|
||||
drone @cli display demo # Run display function showcase
|
||||
drone @cli templates demo # Run operation template showcase
|
||||
drone @cli --help # Full usage guide
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
+87
-78
@@ -37,7 +37,7 @@ from rich.panel import Panel
|
||||
from rich import box
|
||||
|
||||
# CLI modules (showcasing our own services!)
|
||||
from aipass.cli.apps.modules.display import console as CONSOLE, header, error
|
||||
from aipass.cli.apps.modules.display import console, header, error
|
||||
|
||||
VERSION = "2.1.0"
|
||||
CLI_ROOT = Path(__file__).parent
|
||||
@@ -110,37 +110,37 @@ def print_introspection() -> None:
|
||||
command_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] not in SERVICE_MODULES]
|
||||
service_modules = [m for m in modules if getattr(m, "__name__", "").split(".")[-1] in SERVICE_MODULES]
|
||||
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("[bold cyan]CLI - Command Line Interface Branch[/bold cyan]")
|
||||
CONSOLE.print(f" Version: {VERSION}")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("[dim]Universal Display & Output Service Provider[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print()
|
||||
console.print("[bold cyan]CLI - Command Line Interface Branch[/bold cyan]")
|
||||
console.print(f" Version: {VERSION}")
|
||||
console.print()
|
||||
console.print("[dim]Universal Display & Output Service Provider[/dim]")
|
||||
console.print()
|
||||
|
||||
# Discovered command modules
|
||||
CONSOLE.print(f"[yellow]Discovered Modules:[/yellow] {len(command_modules)}")
|
||||
console.print(f"[yellow]Discovered Modules:[/yellow] {len(command_modules)}")
|
||||
for module in command_modules:
|
||||
name = getattr(module, "__name__", "unknown").split(".")[-1]
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
CONSOLE.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
||||
console.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
||||
if not command_modules:
|
||||
CONSOLE.print(" [dim]No command modules discovered[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print(" [dim]No command modules discovered[/dim]")
|
||||
console.print()
|
||||
|
||||
# Service modules (import-only, but with utility commands)
|
||||
if service_modules:
|
||||
CONSOLE.print(f"[yellow]Services:[/yellow] {len(service_modules)}")
|
||||
console.print(f"[yellow]Services:[/yellow] {len(service_modules)}")
|
||||
for module in service_modules:
|
||||
name = getattr(module, "__name__", "unknown").split(".")[-1]
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
CONSOLE.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
||||
CONSOLE.print()
|
||||
console.print(f" [cyan]\u2022[/cyan] {name} \u2014 {desc}")
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[yellow]Next:[/yellow] Explore a module")
|
||||
CONSOLE.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli display demo[/green] [dim]# Run display showcase[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli --help[/green] [dim]# Full usage guide[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print("[yellow]Next:[/yellow] Explore a module")
|
||||
console.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
||||
console.print(" [green]drone @cli display demo[/green] [dim]# Run display showcase[/dim]")
|
||||
console.print(" [green]drone @cli --help[/green] [dim]# Full usage guide[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
@@ -149,45 +149,54 @@ def print_help() -> None:
|
||||
Shows COMMANDS, EXAMPLES, full reference.
|
||||
Follows seedgo help pattern.
|
||||
"""
|
||||
CONSOLE.print()
|
||||
console.print()
|
||||
|
||||
header("CLI - Display & Templates Service Provider")
|
||||
|
||||
CONSOLE.print("[dim]Universal display and output formatting for all AIPass branches[/dim]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("\u2500" * 70)
|
||||
CONSOLE.print()
|
||||
console.print("[dim]Universal display and output formatting for all AIPass branches[/dim]")
|
||||
console.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# Usage
|
||||
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]drone @cli <command> [args...][/dim]")
|
||||
console.print(" [dim]drone @cli --help[/dim]")
|
||||
console.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# What is CLI
|
||||
CONSOLE.print("[bold cyan]WHAT IS CLI?[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("CLI is the [bold]Display & Templates Service[/bold] - like Prax for logging:")
|
||||
CONSOLE.print(" [green]\u2713[/green] Centralized display formatting (headers, tables, panels)")
|
||||
CONSOLE.print(" [green]\u2713[/green] Reusable templates for common operations")
|
||||
CONSOLE.print(" [green]\u2713[/green] Rich library integration for beautiful output")
|
||||
CONSOLE.print(" [green]\u2713[/green] Consistent styling across all AIPass branches")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("Update CLI once \u2192 All branches instantly benefit from improvements")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("\u2500" * 70)
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]WHAT IS CLI?[/bold cyan]")
|
||||
console.print()
|
||||
console.print("CLI is the [bold]Display & Templates Service[/bold] - like Prax for logging:")
|
||||
console.print(" [green]\u2713[/green] Centralized display formatting (headers, tables, panels)")
|
||||
console.print(" [green]\u2713[/green] Reusable templates for common operations")
|
||||
console.print(" [green]\u2713[/green] Rich library integration for beautiful output")
|
||||
console.print(" [green]\u2713[/green] Consistent styling across all AIPass branches")
|
||||
console.print()
|
||||
console.print("Update CLI once \u2192 All branches instantly benefit from improvements")
|
||||
console.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# Commands
|
||||
CONSOLE.print("[bold cyan]COMMANDS:[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print(" [green]drone @cli[/green] [dim]# Show discovered modules[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli display demo[/green] [dim]# Run display demo[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli templates[/green] [dim]# Templates module info[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli templates demo[/green] [dim]# Run templates demo[/dim]")
|
||||
CONSOLE.print(" [green]drone @cli --help[/green] [dim]# This help message[/dim]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("\u2500" * 70)
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]COMMANDS:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]drone @cli[/green] [dim]# Show discovered modules[/dim]")
|
||||
console.print(" [green]drone @cli display[/green] [dim]# Display module info[/dim]")
|
||||
console.print(" [green]drone @cli display demo[/green] [dim]# Run display demo[/dim]")
|
||||
console.print(" [green]drone @cli templates[/green] [dim]# Templates module info[/dim]")
|
||||
console.print(" [green]drone @cli templates demo[/green] [dim]# Run templates demo[/dim]")
|
||||
console.print(" [green]drone @cli --help[/green] [dim]# This help message[/dim]")
|
||||
console.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# Public services
|
||||
CONSOLE.print("[bold cyan]PUBLIC SERVICES (apps/modules/):[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]PUBLIC SERVICES (apps/modules/):[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
services_table = Table(show_header=True, header_style="bold cyan", border_style="dim")
|
||||
services_table.add_column("Module", style="green")
|
||||
@@ -199,34 +208,34 @@ def print_help() -> None:
|
||||
)
|
||||
services_table.add_row("templates", "operation_start(), operation_complete()", "Standard operation patterns")
|
||||
|
||||
CONSOLE.print(services_table)
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("\u2500" * 70)
|
||||
CONSOLE.print()
|
||||
console.print(services_table)
|
||||
console.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# Import examples
|
||||
CONSOLE.print("[bold cyan]HOW TO IMPORT CLI SERVICES:[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]HOW TO IMPORT CLI SERVICES:[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[yellow]Display functions:[/yellow]")
|
||||
CONSOLE.print("[dim] from aipass.cli.apps.modules.display import header, success, error, warning[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print("[yellow]Display functions:[/yellow]")
|
||||
console.print("[dim] from aipass.cli.apps.modules.display import header, success, error, warning[/dim]")
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[yellow]Templates:[/yellow]")
|
||||
CONSOLE.print("[dim] from aipass.cli.apps.modules.templates import operation_start, operation_complete[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print("[yellow]Templates:[/yellow]")
|
||||
console.print("[dim] from aipass.cli.apps.modules.templates import operation_start, operation_complete[/dim]")
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[yellow]Rich console:[/yellow]")
|
||||
CONSOLE.print("[dim] from aipass.cli.apps.modules.display import console[/dim]")
|
||||
CONSOLE.print("[dim] console.print('[bold]Hello[/bold]') # Rich formatted output[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print("[yellow]Rich console:[/yellow]")
|
||||
console.print("[dim] from aipass.cli.apps.modules.display import console[/dim]")
|
||||
console.print("[dim] console.print('[bold]Hello[/bold]') # Rich formatted output[/dim]")
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("\u2500" * 70)
|
||||
CONSOLE.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# Architecture
|
||||
CONSOLE.print("[bold cyan]ARCHITECTURE:[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]ARCHITECTURE:[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
arch_text = """[bold]CLI Branch Structure:[/bold]
|
||||
|
||||
@@ -240,19 +249,19 @@ def print_help() -> None:
|
||||
[green]\u2713[/green] Rich library = Underlying formatting engine
|
||||
- Console, Table, Panel, Columns, Text styling"""
|
||||
|
||||
CONSOLE.print(Panel(arch_text, border_style="green", padding=(1, 2), box=box.ROUNDED))
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("\u2500" * 70)
|
||||
CONSOLE.print()
|
||||
console.print(Panel(arch_text, border_style="green", padding=(1, 2), box=box.ROUNDED))
|
||||
console.print()
|
||||
console.print("\u2500" * 70)
|
||||
console.print()
|
||||
|
||||
# Drone compliance — commands line
|
||||
CONSOLE.print("[dim]Commands: display, templates, demo, --help[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print("[dim]Commands: display, templates, demo, --help[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def show_version():
|
||||
"""Print version."""
|
||||
CONSOLE.print(f"CLI v{VERSION}")
|
||||
console.print(f"CLI v{VERSION}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -306,9 +315,9 @@ if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("CLI interrupted by user")
|
||||
CONSOLE.print("\n[yellow]Operation cancelled[/yellow]")
|
||||
console.print("\n[yellow]Operation cancelled[/yellow]")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"CLI error: {e}", exc_info=True)
|
||||
CONSOLE.print(f"\n[red]Error: {e}[/red]")
|
||||
error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
@@ -51,7 +51,7 @@ class TestMainFlow:
|
||||
"""No args shows introspection and returns 0."""
|
||||
cons, _get_output = _make_capture_console()
|
||||
with (
|
||||
patch.object(cli_module, "CONSOLE", cons),
|
||||
patch.object(cli_module, "console", cons),
|
||||
patch.object(display, "CONSOLE", cons),
|
||||
patch("sys.argv", ["cli"]),
|
||||
):
|
||||
@@ -63,7 +63,7 @@ class TestMainFlow:
|
||||
cons, _get_output = _make_capture_console()
|
||||
err_cons, _get_err = _make_capture_console()
|
||||
with (
|
||||
patch.object(cli_module, "CONSOLE", cons),
|
||||
patch.object(cli_module, "console", cons),
|
||||
patch.object(display, "CONSOLE", cons),
|
||||
patch.object(display, "err_console", err_cons),
|
||||
patch.object(display, "_TRIGGER", None),
|
||||
@@ -77,7 +77,7 @@ class TestMainFlow:
|
||||
"""--version returns 0."""
|
||||
cons, get_output = _make_capture_console()
|
||||
with (
|
||||
patch.object(cli_module, "CONSOLE", cons),
|
||||
patch.object(cli_module, "console", cons),
|
||||
patch.object(display, "CONSOLE", cons),
|
||||
patch("sys.argv", ["cli", "--version"]),
|
||||
):
|
||||
@@ -91,7 +91,7 @@ class TestMainFlow:
|
||||
cons, _get_output = _make_capture_console()
|
||||
err_cons, get_err = _make_capture_console()
|
||||
with (
|
||||
patch.object(cli_module, "CONSOLE", cons),
|
||||
patch.object(cli_module, "console", cons),
|
||||
patch.object(display, "CONSOLE", cons),
|
||||
patch.object(display, "err_console", err_cons),
|
||||
patch("sys.argv", ["cli", "nonexistent_cmd_xyz"]),
|
||||
@@ -106,7 +106,7 @@ class TestMainFlow:
|
||||
cons, _get_output = _make_capture_console()
|
||||
err_cons, _get_err = _make_capture_console()
|
||||
with (
|
||||
patch.object(cli_module, "CONSOLE", cons),
|
||||
patch.object(cli_module, "console", cons),
|
||||
patch.object(display, "CONSOLE", cons),
|
||||
patch.object(display, "err_console", err_cons),
|
||||
patch.object(display, "_TRIGGER", None),
|
||||
@@ -125,7 +125,7 @@ class TestMainFlow:
|
||||
"""cli_entry() is the console_scripts entry point — verify it's callable."""
|
||||
cons, _get_output = _make_capture_console()
|
||||
with (
|
||||
patch.object(cli_module, "CONSOLE", cons),
|
||||
patch.object(cli_module, "console", cons),
|
||||
patch.object(display, "CONSOLE", cons),
|
||||
patch("sys.argv", ["aipass", "--version"]),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
|
||||
@@ -10,6 +10,20 @@
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @daemon # Show discovered modules
|
||||
drone @daemon update # Status digest
|
||||
drone @daemon activity # Quick 24h activity summary
|
||||
drone @daemon queue # View pending scheduled jobs
|
||||
drone @daemon run # Fire all due jobs now
|
||||
drone @daemon branch-health DAEMON # Deep dive on a branch
|
||||
drone @daemon install-timer # Enable systemd 2-min timer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Builder citizen -- full 3-layer architecture with identity and memory. DAEMON serves as the background orchestration branch: it discovers modules at startup, routes CLI commands to them, and provides introspection and help output via Rich console.
|
||||
|
||||
@@ -162,8 +162,22 @@ def print_help(modules: List[Any]):
|
||||
console.print("-" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [yellow]Status & monitoring:[/yellow]")
|
||||
console.print(" [dim]drone @daemon update[/dim] [dim]# Status digest[/dim]")
|
||||
console.print(" [dim]drone @daemon activity[/dim] [dim]# Quick 24h summary[/dim]")
|
||||
console.print(" [dim]drone @daemon activity-report --json[/dim] [dim]# Full report (raw)[/dim]")
|
||||
console.print(" [dim]drone @daemon branch-health DAEMON[/dim] [dim]# Single branch dive[/dim]")
|
||||
console.print()
|
||||
console.print(" [yellow]Scheduler:[/yellow]")
|
||||
console.print(" [dim]drone @daemon queue[/dim] [dim]# View pending jobs[/dim]")
|
||||
console.print(" [dim]drone @daemon run[/dim] [dim]# Fire due jobs now[/dim]")
|
||||
console.print(" [dim]drone @daemon install-timer[/dim] [dim]# Enable systemd timer[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[bold]TIP:[/bold] For module-specific help:")
|
||||
console.print(" [dim]daemon <command> --help[/dim]")
|
||||
console.print(" [dim]drone @daemon <command> --help[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,18 @@ DevPulse handles the day-to-day: working with the user to plan, design, troubles
|
||||
| Active plans | `drone @flow list open` |
|
||||
| Branch list | `drone systems` |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Talk to the hub — it picks up where the last session left off
|
||||
cd src/aipass/devpulse
|
||||
claude
|
||||
|
||||
# Or drive it via drone from anywhere in AIPass
|
||||
drone @devpulse compass query "registry" # search rated decisions
|
||||
drone @devpulse feedback inbox # cross-project feedback
|
||||
```
|
||||
|
||||
## Invoke
|
||||
|
||||
```bash
|
||||
|
||||
@@ -89,11 +89,14 @@ def print_introspection():
|
||||
"""Print branch introspection — discovered modules and capabilities."""
|
||||
modules = discover_modules()
|
||||
console.print("[bold cyan]DEVPULSE[/bold cyan] — Orchestration Hub")
|
||||
console.print("[dim]The user's primary collaborator — design, plan, dispatch, track[/dim]")
|
||||
console.print(f" Modules discovered: {len(modules)}")
|
||||
for module in modules:
|
||||
name = module.__name__.split(".")[-1]
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
console.print(f" {name:20} {desc}")
|
||||
console.print()
|
||||
console.print("Run 'drone @devpulse --help' for usage information")
|
||||
|
||||
|
||||
def print_help():
|
||||
@@ -112,6 +115,11 @@ def print_help():
|
||||
console.print("[bold]FLAGS:[/bold]")
|
||||
console.print(" --help, -h Show this help message")
|
||||
console.print(" --version, -V Show version")
|
||||
console.print()
|
||||
console.print("[bold]EXAMPLES:[/bold]")
|
||||
console.print(' drone @devpulse compass query "registry" Search rated decisions')
|
||||
console.print(" drone @devpulse watchdog agent @flow Watch a dispatched agent")
|
||||
console.print(" drone @devpulse feedback inbox Check cross-project feedback")
|
||||
|
||||
|
||||
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
|
||||
|
||||
@@ -21,6 +21,17 @@
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone systems # See all registered branches
|
||||
drone @seedgo audit aipass # Route a command to a branch
|
||||
drone @flow --help # Show help for any branch
|
||||
drone scan @memory # Discover available commands
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands / Usage
|
||||
|
||||
Drone provides a CLI for terminal use and a Python API for programmatic access.
|
||||
|
||||
@@ -93,7 +93,7 @@ def _discover_modules() -> list[tuple[str, str]]:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def show_help() -> None:
|
||||
def print_help() -> None:
|
||||
"""Display drone help with Rich formatting."""
|
||||
table = Table(show_header=False, box=None, pad_edge=False, show_edge=False)
|
||||
table.add_column(style="cyan", no_wrap=True)
|
||||
@@ -116,22 +116,22 @@ def show_help() -> None:
|
||||
console.print()
|
||||
console.print("[dim]Routes commands to registered AIPass branches and modules.[/dim]")
|
||||
console.print()
|
||||
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]drone @<target> <command> [args...][/dim]")
|
||||
console.print(" [dim]drone <built-in> [args...][/dim]")
|
||||
console.print(" [dim]drone --help[/dim]")
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print()
|
||||
console.print("[bold]Examples:[/bold]")
|
||||
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]drone @seedgo audit aipass[/green]")
|
||||
console.print(" [green]drone @flow status[/green]")
|
||||
console.print(" [green]drone systems[/green]")
|
||||
console.print(" [green]drone activate @seedgo[/green]")
|
||||
console.print(" [green]drone audit[/green] [dim](custom shortcut)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Alias for seedgo standard compliance (audit expects print_help)."""
|
||||
show_help()
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Display branch overview — auto-discovers modules."""
|
||||
console.print()
|
||||
|
||||
@@ -26,6 +26,18 @@ Flow is AIPass's plan management system. Every branch uses flow to create, track
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @flow create . "My task description" # Create a plan in the current directory
|
||||
drone @flow list open # See all open plans
|
||||
drone @flow close FPLAN-0042 # Close a completed plan
|
||||
drone @flow create . "Design topic" dplan # Create a design plan (DPLAN)
|
||||
drone @flow templates # List available plan types
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -446,6 +446,11 @@
|
||||
"standard": "trigger",
|
||||
"reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle \u2014 not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/feedback.py",
|
||||
"standard": "trigger",
|
||||
"reason": "sentinel.unlink() removes a .aipass/feedback_off toggle file \u2014 not a tracked resource. Deliberate user action via 'drone @hooks feedback on'."
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/hookstatus.py",
|
||||
"standard": "json_structure",
|
||||
|
||||
@@ -2,15 +2,25 @@
|
||||
|
||||
# Hooks
|
||||
|
||||
> Hook infrastructure for AIPass. Single engine dispatches all hooks across platforms (Claude, Codex) with per-project config, full logging, and crash isolation. The 13th citizen.
|
||||
> Hook infrastructure for AIPass. A single dispatch engine routes hook events across platforms (Claude Code, Codex) with per-project configuration, full logging, and crash isolation.
|
||||
|
||||
Every hook event flows through one engine. Platform bridges normalize the event format, the engine reads per-project config (`.aipass/hooks.json`), dispatches matching handlers, and logs everything to prax + JSONL.
|
||||
Every hook event flows through one engine. Platform bridges normalize the event format, the engine reads per-project config (`.aipass/hooks.json`), dispatches matching handlers, and logs everything to JSONL diagnostics.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @hooks status # Show hook config for current project
|
||||
drone @hooks log # Tail recent hook activity
|
||||
drone @hooks engine # Show connected handlers
|
||||
drone @hooks verify # Cross-check provider ↔ project wiring
|
||||
drone @hooks --help # Full help reference
|
||||
```
|
||||
|
||||
## Start here
|
||||
|
||||
| You want to | Read |
|
||||
|---|---|
|
||||
| Identity, memory, session history | [`.trinity/`](.trinity/) |
|
||||
| Identity, session history | [`.trinity/`](.trinity/) |
|
||||
| Hook engine design | `DPLAN-0184` |
|
||||
| Per-project config | `.aipass/hooks.json` |
|
||||
|
||||
@@ -25,6 +35,9 @@ Every hook event flows through one engine. Platform bridges normalize the event
|
||||
| `drone @hooks hooksound` | Show current sound mute status |
|
||||
| `drone @hooks hooksound off` | Mute all hook sounds |
|
||||
| `drone @hooks hooksound on` | Unmute all hook sounds |
|
||||
| `drone @hooks feedback` | Show feedback pulse status (enabled/disabled) |
|
||||
| `drone @hooks feedback off` | Disable feedback pulse for this project |
|
||||
| `drone @hooks feedback on` | Enable feedback pulse for this project |
|
||||
| `drone @hooks dismiss <alert-id>` | Remove an alert from `.aipass/alerts.json` |
|
||||
| `drone @hooks cadence` | Show prompt injection cadence config and state |
|
||||
| `drone @hooks verify` | Cross-check provider settings vs project hook config |
|
||||
@@ -56,6 +69,7 @@ src/aipass/hooks/
|
||||
│ │ ├── hook_test.py # Portable test runner (drone @hooks test)
|
||||
│ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/<pid>.json)
|
||||
│ │ ├── engine.py # Core dispatch — routes events to handlers
|
||||
│ │ ├── feedback.py # Feedback pulse toggle (drone @hooks feedback on/off)
|
||||
│ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off)
|
||||
│ │ ├── hookstatus.py # Config viewer (drone @hooks status)
|
||||
│ │ ├── alert_dismiss.py # Dismiss alerts (drone @hooks dismiss <id>)
|
||||
@@ -71,6 +85,7 @@ src/aipass/hooks/
|
||||
│ │ │ ├── tier0_kernel.py # Injects tier0 kernel prompt (every turn)
|
||||
│ │ │ ├── navmap.py # Injects tier1 navmap prompt (periodic)
|
||||
│ │ │ ├── identity.py # Injects passport identity block
|
||||
│ │ │ ├── feedback_pulse.py # Periodic feedback ask (~10 turns, toggleable)
|
||||
│ │ │ └── persistent_alert.py # Injects advisory banners from .aipass/alerts.json
|
||||
│ │ ├── security/ # Enforcement hooks
|
||||
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
|
||||
@@ -117,7 +132,7 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
|
||||
|
||||
| Event | Hooks | Description |
|
||||
|---|---|---|
|
||||
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + auto-process + TG mirror |
|
||||
| UserPromptSubmit | presence_gate, persistent_alert, identity, email, branch_loader, tier0_kernel, navmap, feedback_pulse, auto_process, user_message_relay | Presence gate + alerts + prompt injection + inbox + feedback + auto-process + TG mirror |
|
||||
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate, registry_gate | Security gates + guardrails + sound |
|
||||
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
|
||||
| SubagentStop | subagent_gate | Seedgo validation |
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: feedback_pulse.py
|
||||
# Version: 1.0.0
|
||||
# Description: Periodic feedback ask — one ignorable line every ~10 turns
|
||||
# Branch: hooks
|
||||
# Layer: apps/handlers/prompt
|
||||
# Created: 2026-07-18
|
||||
# Modified: 2026-07-18
|
||||
# =============================================
|
||||
|
||||
"""Periodic feedback pulse — surfaces a one-line feedback ask every ~10 turns.
|
||||
|
||||
Scoped to external user projects (not the AIPass host). Toggle via
|
||||
drone @hooks feedback on/off. State persists across session restarts
|
||||
as a .aipass/feedback_off sentinel file per project."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.hooks.apps.handlers.json import json_handler
|
||||
|
||||
_STATE_DIR = Path(tempfile.gettempdir())
|
||||
_PERIOD = 10
|
||||
_FEEDBACK_URL = "https://github.com/AIOSAI/AIPass/issues"
|
||||
_FEEDBACK_LINE = f"How are we doing? Your feedback is hugely appreciated → {_FEEDBACK_URL}"
|
||||
|
||||
|
||||
def _state_path(hook_data: dict) -> Path | None:
|
||||
session_id = hook_data.get("session_id", "")
|
||||
if not session_id:
|
||||
session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "")
|
||||
if not session_id:
|
||||
return None
|
||||
return _STATE_DIR / f"aipass-feedback-pulse-{session_id}.json"
|
||||
|
||||
|
||||
def _load_and_increment(path: Path) -> int:
|
||||
"""Load turn counter, increment, and persist. Returns the new turn number."""
|
||||
try:
|
||||
if path.exists():
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
turn = data.get("turn", 0) + 1
|
||||
else:
|
||||
turn = 0
|
||||
path.write_text(json.dumps({"turn": turn}), encoding="utf-8")
|
||||
return turn
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.info("[HOOKS] feedback_pulse: state access failed: %s", exc)
|
||||
return 0
|
||||
|
||||
|
||||
def _find_aipass_dir(cwd: str | None = None) -> Path | None:
|
||||
"""Walk up from CWD to find the nearest .aipass/ directory."""
|
||||
start = Path(cwd) if cwd else Path.cwd()
|
||||
for parent in [start, *start.parents]:
|
||||
candidate = parent / ".aipass"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
if parent == parent.parent:
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def _is_disabled(cwd: str | None = None) -> bool:
|
||||
"""Check if feedback pulse is toggled off for this project."""
|
||||
aipass_dir = _find_aipass_dir(cwd)
|
||||
if aipass_dir is None:
|
||||
return True
|
||||
sentinel = aipass_dir / "feedback_off"
|
||||
return sentinel.exists()
|
||||
|
||||
|
||||
def handle(hook_data: dict) -> dict:
|
||||
"""Inject feedback pulse line on cadence (~every 10 turns, skipping early turns)."""
|
||||
try:
|
||||
path = _state_path(hook_data)
|
||||
if path is None:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
turn = _load_and_increment(path)
|
||||
|
||||
if turn < _PERIOD or turn % _PERIOD != 0:
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
cwd = hook_data.get("cwd", "")
|
||||
if _is_disabled(cwd or None):
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
|
||||
json_handler.log_operation("feedback_pulse", {"turn": turn})
|
||||
return {"stdout": _FEEDBACK_LINE, "exit_code": 0}
|
||||
|
||||
except Exception as exc:
|
||||
logger.info("[HOOKS] feedback_pulse: unexpected error: %s", exc)
|
||||
return {"stdout": "", "exit_code": 0}
|
||||
@@ -33,9 +33,7 @@ if sys.platform == "win32":
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: E402
|
||||
from aipass.cli.apps.modules import err_console # noqa: E402
|
||||
|
||||
CONSOLE = err_console
|
||||
from aipass.cli.apps.modules import console # noqa: E402
|
||||
|
||||
# =============================================================================
|
||||
# MODULE DISCOVERY
|
||||
@@ -85,86 +83,86 @@ def discover_modules() -> list[Any]:
|
||||
def print_introspection():
|
||||
"""Print branch introspection — discovered modules and capabilities."""
|
||||
modules = discover_modules()
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("[bold cyan]HOOKS — Hook Infrastructure for AIPass[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print()
|
||||
console.print("[bold cyan]HOOKS — Hook Infrastructure for AIPass[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
|
||||
console.print()
|
||||
|
||||
CONSOLE.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
||||
CONSOLE.print()
|
||||
console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}")
|
||||
console.print()
|
||||
for module in modules:
|
||||
name = module.__name__.split(".")[-1]
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
CONSOLE.print(f" [cyan]•[/cyan] {name:20} [dim]{desc}[/dim]")
|
||||
console.print(f" [cyan]•[/cyan] {name:20} [dim]{desc}[/dim]")
|
||||
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("Run [green]'drone @hooks --help'[/green] for usage information")
|
||||
CONSOLE.print()
|
||||
console.print()
|
||||
console.print("Run [green]'drone @hooks --help'[/green] for usage information")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help():
|
||||
"""Print CLI help — usage instructions and available commands."""
|
||||
modules = discover_modules()
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("[bold cyan]HOOKS[/bold cyan] [dim]v1.1.0[/dim] — Hook Infrastructure for AIPass")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("─" * 70)
|
||||
CONSOLE.print()
|
||||
console.print()
|
||||
console.print("[bold cyan]HOOKS[/bold cyan] [dim]v1.1.0[/dim] — Hook Infrastructure for AIPass")
|
||||
console.print()
|
||||
console.print("[dim]Dispatches hooks across platforms with per-project config, logging, and crash isolation.[/dim]")
|
||||
console.print()
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print(" [dim]drone @hooks <command> [args...][/dim]")
|
||||
CONSOLE.print(" [dim]drone @hooks --help[/dim]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("─" * 70)
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]USAGE:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]drone @hooks <command> [args...][/dim]")
|
||||
console.print(" [dim]drone @hooks --help[/dim]")
|
||||
console.print()
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[bold cyan]COMMANDS:[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]COMMANDS:[/bold cyan]")
|
||||
console.print()
|
||||
for module in modules:
|
||||
commands = getattr(module, "HELP_COMMANDS", None)
|
||||
if commands:
|
||||
for cmd, desc in commands:
|
||||
CONSOLE.print(f" [green]{cmd:26}[/green] [dim]{desc}[/dim]")
|
||||
console.print(f" [green]{cmd:26}[/green] [dim]{desc}[/dim]")
|
||||
else:
|
||||
name = module.__name__.split(".")[-1]
|
||||
desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description"
|
||||
CONSOLE.print(f" [green]{name:26}[/green] [dim]{desc}[/dim]")
|
||||
console.print(f" [green]{name:26}[/green] [dim]{desc}[/dim]")
|
||||
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("─" * 70)
|
||||
CONSOLE.print()
|
||||
console.print()
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[bold cyan]BRIDGES:[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print(
|
||||
console.print("[bold cyan]BRIDGES:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(
|
||||
" [green]claude[/green] [dim]Claude Code bridge (provider settings entry point)[/dim]"
|
||||
)
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("─" * 70)
|
||||
CONSOLE.print()
|
||||
console.print()
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print(" [dim]drone @hooks status[/dim] [dim]# Show hook config for current project[/dim]")
|
||||
CONSOLE.print(" [dim]drone @hooks log[/dim] [dim]# Tail recent hook activity[/dim]")
|
||||
CONSOLE.print(" [dim]drone @hooks hooksound off[/dim] [dim]# Mute all hook sounds[/dim]")
|
||||
CONSOLE.print(" [dim]drone @hooks hooksound on[/dim] [dim]# Unmute all hook sounds[/dim]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("─" * 70)
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]drone @hooks status[/dim] [dim]# Show hook config for current project[/dim]")
|
||||
console.print(" [dim]drone @hooks log[/dim] [dim]# Tail recent hook activity[/dim]")
|
||||
console.print(" [dim]drone @hooks hooksound off[/dim] [dim]# Mute all hook sounds[/dim]")
|
||||
console.print(" [dim]drone @hooks hooksound on[/dim] [dim]# Unmute all hook sounds[/dim]")
|
||||
console.print()
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
CONSOLE.print("[bold cyan]FLAGS:[/bold cyan]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print(" [green]--help, -h[/green] [dim]Show this help message[/dim]")
|
||||
CONSOLE.print(" [green]--version, -V[/green] [dim]Show version[/dim]")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print("[bold]TIP:[/bold] For command-specific help:")
|
||||
CONSOLE.print(" [dim]drone @hooks <command> --help[/dim]")
|
||||
CONSOLE.print()
|
||||
console.print("[bold cyan]FLAGS:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [green]--help, -h[/green] [dim]Show this help message[/dim]")
|
||||
console.print(" [green]--version, -V[/green] [dim]Show version[/dim]")
|
||||
console.print()
|
||||
console.print("[bold]TIP:[/bold] For command-specific help:")
|
||||
console.print(" [dim]drone @hooks <command> --help[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def route_command(command: str, args: list[str], modules: list[Any]) -> bool:
|
||||
@@ -192,7 +190,7 @@ def handle_command(command: str, args: list) -> bool:
|
||||
return True
|
||||
|
||||
if command in ["--version", "-V"]:
|
||||
CONSOLE.print("hooks 1.1.0")
|
||||
console.print("hooks 1.1.0")
|
||||
return True
|
||||
|
||||
return route_command(command, args, modules)
|
||||
@@ -209,7 +207,7 @@ def main() -> int:
|
||||
if handle_command(args[0], args[1:]):
|
||||
return 0
|
||||
|
||||
CONSOLE.print(f"Unknown command: {args[0]}. Try: drone @hooks --help")
|
||||
console.print(f"Unknown command: {args[0]}. Try: drone @hooks --help")
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: feedback.py
|
||||
# Version: 1.0.0
|
||||
# Description: Feedback pulse toggle — on/off control for periodic feedback ask
|
||||
# Branch: hooks
|
||||
# Layer: apps/modules
|
||||
# Created: 2026-07-18
|
||||
# Modified: 2026-07-18
|
||||
# =============================================
|
||||
|
||||
"""Feedback pulse toggle — on/off control for the periodic feedback ask via drone @hooks feedback."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from aipass.cli.apps.modules import err_console
|
||||
from aipass.hooks.apps.handlers.json import json_handler
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger # noqa: F401
|
||||
|
||||
CONSOLE = err_console
|
||||
|
||||
HELP_COMMANDS = [
|
||||
("feedback on", "Enable feedback pulse (default)"),
|
||||
("feedback off", "Disable feedback pulse"),
|
||||
("feedback", "Show current feedback pulse status"),
|
||||
]
|
||||
|
||||
|
||||
def _find_aipass_dir() -> Path | None:
|
||||
"""Walk up from CWD to find the nearest .aipass/ directory."""
|
||||
cwd = Path.cwd()
|
||||
for parent in [cwd, *cwd.parents]:
|
||||
candidate = parent / ".aipass"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
if parent == parent.parent:
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def _sentinel() -> Path | None:
|
||||
"""Return the sentinel file path, or None if no .aipass/ dir found."""
|
||||
aipass_dir = _find_aipass_dir()
|
||||
if aipass_dir is None:
|
||||
return None
|
||||
return aipass_dir / "feedback_off"
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Print module structure for drone routing."""
|
||||
sentinel = _sentinel()
|
||||
if sentinel is None:
|
||||
status = "NO PROJECT"
|
||||
else:
|
||||
status = "DISABLED" if sentinel.exists() else "ENABLED"
|
||||
CONSOLE.print(f"[bold cyan]feedback[/bold cyan] — Feedback pulse ({status})")
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Route feedback commands from drone @hooks."""
|
||||
if command == "feedback":
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
|
||||
sub = args[0]
|
||||
|
||||
if sub in ("--help", "-h", "help"):
|
||||
CONSOLE.print("[bold cyan]feedback[/bold cyan] — Toggle the periodic feedback pulse")
|
||||
CONSOLE.print()
|
||||
CONSOLE.print(" drone @hooks feedback Show current status")
|
||||
CONSOLE.print(" drone @hooks feedback on Enable feedback pulse (default)")
|
||||
CONSOLE.print(" drone @hooks feedback off Disable feedback pulse")
|
||||
return True
|
||||
|
||||
if sub == "off":
|
||||
sentinel = _sentinel()
|
||||
if sentinel is None:
|
||||
CONSOLE.print("[yellow]No .aipass/ directory found[/yellow]")
|
||||
return True
|
||||
sentinel.touch()
|
||||
json_handler.log_operation("feedback_toggle", {"state": "off"})
|
||||
CONSOLE.print("[yellow]Feedback pulse DISABLED[/yellow]")
|
||||
return True
|
||||
|
||||
if sub == "on":
|
||||
sentinel = _sentinel()
|
||||
if sentinel is None:
|
||||
CONSOLE.print("[yellow]No .aipass/ directory found[/yellow]")
|
||||
return True
|
||||
if sentinel.exists():
|
||||
sentinel.unlink()
|
||||
json_handler.log_operation("feedback_toggle", {"state": "on"})
|
||||
CONSOLE.print("[green]Feedback pulse ENABLED[/green]")
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -375,8 +375,8 @@ class TestHooksEntryPoint:
|
||||
|
||||
print_introspection()
|
||||
captured = capsys.readouterr()
|
||||
assert "HOOKS" in captured.err
|
||||
assert "Discovered Modules" in captured.err
|
||||
assert "HOOKS" in captured.out
|
||||
assert "Discovered Modules" in captured.out
|
||||
|
||||
def test_handle_command_returns_bool(self):
|
||||
from aipass.hooks.apps.hooks import handle_command
|
||||
@@ -592,35 +592,35 @@ class TestCliRouting:
|
||||
|
||||
print_help()
|
||||
captured = capsys.readouterr()
|
||||
assert "HOOKS" in captured.err
|
||||
assert "drone @hooks" in captured.err
|
||||
assert "HOOKS" in captured.out
|
||||
assert "drone @hooks" in captured.out
|
||||
|
||||
def test_print_help_surfaces_subcommands(self, capsys):
|
||||
from aipass.hooks.apps.hooks import print_help
|
||||
|
||||
print_help()
|
||||
captured = capsys.readouterr()
|
||||
assert "hooksound on" in captured.err
|
||||
assert "hooksound off" in captured.err
|
||||
assert "status" in captured.err
|
||||
assert "log" in captured.err
|
||||
assert "hooksound on" in captured.out
|
||||
assert "hooksound off" in captured.out
|
||||
assert "status" in captured.out
|
||||
assert "log" in captured.out
|
||||
|
||||
def test_print_help_has_examples_section(self, capsys):
|
||||
from aipass.hooks.apps.hooks import print_help
|
||||
|
||||
print_help()
|
||||
captured = capsys.readouterr()
|
||||
assert "EXAMPLES" in captured.err
|
||||
assert "drone @hooks status" in captured.err
|
||||
assert "drone @hooks hooksound off" in captured.err
|
||||
assert "EXAMPLES" in captured.out
|
||||
assert "drone @hooks status" in captured.out
|
||||
assert "drone @hooks hooksound off" in captured.out
|
||||
|
||||
def test_print_help_has_usage_section(self, capsys):
|
||||
from aipass.hooks.apps.hooks import print_help
|
||||
|
||||
print_help()
|
||||
captured = capsys.readouterr()
|
||||
assert "USAGE" in captured.err
|
||||
assert "drone @hooks <command>" in captured.err
|
||||
assert "USAGE" in captured.out
|
||||
assert "drone @hooks <command>" in captured.out
|
||||
|
||||
def test_help_commands_auto_discovered(self, capsys):
|
||||
from aipass.hooks.apps.hooks import print_help
|
||||
@@ -631,7 +631,7 @@ class TestCliRouting:
|
||||
print_help()
|
||||
captured = capsys.readouterr()
|
||||
for cmd, _ in hs_cmds + hst_cmds + eng_cmds:
|
||||
assert cmd in captured.err
|
||||
assert cmd in captured.out
|
||||
|
||||
def test_output_capture_status(self, capsys):
|
||||
from aipass.hooks.apps.hooks import handle_command
|
||||
@@ -647,7 +647,7 @@ class TestCliRouting:
|
||||
with patch("sys.argv", ["hooks", "--version"]):
|
||||
main()
|
||||
captured = capsys.readouterr()
|
||||
assert "1.1.0" in captured.err
|
||||
assert "1.1.0" in captured.out
|
||||
|
||||
|
||||
class TestConfigDataContracts:
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_feedback_pulse.py
|
||||
# Version: 1.0.0
|
||||
# Description: Tests for feedback pulse handler and toggle module
|
||||
# Branch: hooks
|
||||
# Layer: tests
|
||||
# Created: 2026-07-18
|
||||
# Modified: 2026-07-18
|
||||
# =============================================
|
||||
|
||||
"""Tests for feedback_pulse handler and feedback toggle module."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestFeedbackPulseHandler:
|
||||
"""Tests for the feedback_pulse prompt handler."""
|
||||
|
||||
def _handler(self):
|
||||
from aipass.hooks.apps.handlers.prompt.feedback_pulse import handle
|
||||
|
||||
return handle
|
||||
|
||||
def test_no_session_id_returns_empty(self):
|
||||
result = self._handler()({"session_id": ""})
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_early_turns_return_empty(self, tmp_path):
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||
tmp_path,
|
||||
):
|
||||
for i in range(10):
|
||||
result = self._handler()({"session_id": "test-session"})
|
||||
assert result["stdout"] == "", f"Turn {i} should not fire"
|
||||
|
||||
def test_fires_on_turn_10(self, tmp_path):
|
||||
result = {"stdout": "", "exit_code": 0}
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||
tmp_path,
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
for i in range(11):
|
||||
result = self._handler()({"session_id": "test-fire"})
|
||||
|
||||
assert "feedback" in result["stdout"].lower()
|
||||
assert "https://github.com/AIOSAI/AIPass/issues" in result["stdout"]
|
||||
|
||||
def test_fires_on_turn_20(self, tmp_path):
|
||||
result = {"stdout": "", "exit_code": 0}
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||
tmp_path,
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
for i in range(21):
|
||||
result = self._handler()({"session_id": "test-fire-20"})
|
||||
|
||||
assert "feedback" in result["stdout"].lower()
|
||||
|
||||
def test_skips_turn_11_through_19(self, tmp_path):
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||
tmp_path,
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
for i in range(11):
|
||||
self._handler()({"session_id": "test-skip"})
|
||||
|
||||
for i in range(9):
|
||||
result = self._handler()({"session_id": "test-skip"})
|
||||
assert result["stdout"] == "", f"Turn {11 + i} should not fire"
|
||||
|
||||
def test_disabled_returns_empty(self, tmp_path):
|
||||
result = {"stdout": "", "exit_code": 0}
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||
tmp_path,
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
for i in range(11):
|
||||
result = self._handler()({"session_id": "test-disabled"})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
|
||||
def test_output_is_one_line(self, tmp_path):
|
||||
result = {"stdout": "", "exit_code": 0}
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||
tmp_path,
|
||||
),
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._is_disabled",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
for i in range(11):
|
||||
result = self._handler()({"session_id": "test-oneline"})
|
||||
|
||||
assert "\n" not in result["stdout"]
|
||||
|
||||
def test_state_file_persists(self, tmp_path):
|
||||
state_file = tmp_path / "aipass-feedback-pulse-test-persist.json"
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||
tmp_path,
|
||||
):
|
||||
self._handler()({"session_id": "test-persist"})
|
||||
|
||||
assert state_file.exists()
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == 0
|
||||
|
||||
def test_state_increments_across_calls(self, tmp_path):
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||
tmp_path,
|
||||
):
|
||||
for i in range(5):
|
||||
self._handler()({"session_id": "test-incr"})
|
||||
|
||||
state_file = tmp_path / "aipass-feedback-pulse-test-incr.json"
|
||||
data = json.loads(state_file.read_text())
|
||||
assert data["turn"] == 4
|
||||
|
||||
def test_corrupted_state_recovers(self, tmp_path):
|
||||
state_file = tmp_path / "aipass-feedback-pulse-test-corrupt.json"
|
||||
state_file.write_text("not json")
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||
tmp_path,
|
||||
):
|
||||
result = self._handler()({"session_id": "test-corrupt"})
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_handler_exception_returns_safe(self):
|
||||
with patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._state_path",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
result = self._handler()({"session_id": "test-crash"})
|
||||
|
||||
assert result["stdout"] == ""
|
||||
assert result["exit_code"] == 0
|
||||
|
||||
def test_session_id_from_env(self, tmp_path):
|
||||
with (
|
||||
patch(
|
||||
"aipass.hooks.apps.handlers.prompt.feedback_pulse._STATE_DIR",
|
||||
tmp_path,
|
||||
),
|
||||
patch.dict(
|
||||
"os.environ",
|
||||
{"CLAUDE_CODE_SESSION_ID": "env-session"},
|
||||
),
|
||||
):
|
||||
self._handler()({"session_id": ""})
|
||||
|
||||
state_file = tmp_path / "aipass-feedback-pulse-env-session.json"
|
||||
assert state_file.exists()
|
||||
|
||||
|
||||
class TestFeedbackPulseToggle:
|
||||
"""Tests for the _is_disabled toggle and sentinel file."""
|
||||
|
||||
def test_no_aipass_dir_is_disabled(self, tmp_path):
|
||||
from aipass.hooks.apps.handlers.prompt import feedback_pulse
|
||||
|
||||
# The walk climbs to the drive root, so a real .aipass in any ancestor
|
||||
# (e.g. the CI runner's home after windows-setup installs AIPass) leaks
|
||||
# into the result — the no-dir case is only constructible by patching.
|
||||
with patch.object(feedback_pulse, "_find_aipass_dir", return_value=None):
|
||||
assert feedback_pulse._is_disabled(str(tmp_path)) is True
|
||||
|
||||
def test_aipass_dir_no_sentinel_is_enabled(self, tmp_path):
|
||||
(tmp_path / ".aipass").mkdir()
|
||||
from aipass.hooks.apps.handlers.prompt.feedback_pulse import _is_disabled
|
||||
|
||||
assert _is_disabled(str(tmp_path)) is False
|
||||
|
||||
def test_sentinel_exists_is_disabled(self, tmp_path):
|
||||
aipass_dir = tmp_path / ".aipass"
|
||||
aipass_dir.mkdir()
|
||||
(aipass_dir / "feedback_off").touch()
|
||||
from aipass.hooks.apps.handlers.prompt.feedback_pulse import _is_disabled
|
||||
|
||||
assert _is_disabled(str(tmp_path)) is True
|
||||
|
||||
|
||||
class TestFeedbackToggleModule:
|
||||
"""Tests for the feedback toggle CLI module (drone @hooks feedback)."""
|
||||
|
||||
def test_handle_command_feedback_shows_status(self, capsys):
|
||||
from aipass.hooks.apps.modules.feedback import handle_command
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||
return_value=Path("/nonexistent/sentinel"),
|
||||
):
|
||||
assert handle_command("feedback", []) is True
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "ENABLED" in captured.err
|
||||
|
||||
def test_handle_command_feedback_disabled(self, capsys, tmp_path):
|
||||
sentinel = tmp_path / "feedback_off"
|
||||
sentinel.touch()
|
||||
from aipass.hooks.apps.modules.feedback import handle_command
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||
return_value=sentinel,
|
||||
):
|
||||
assert handle_command("feedback", []) is True
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "DISABLED" in captured.err
|
||||
|
||||
def test_handle_command_feedback_off(self, tmp_path):
|
||||
sentinel = tmp_path / "feedback_off"
|
||||
from aipass.hooks.apps.modules.feedback import handle_command
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||
return_value=sentinel,
|
||||
):
|
||||
assert handle_command("feedback", ["off"]) is True
|
||||
|
||||
assert sentinel.exists()
|
||||
|
||||
def test_handle_command_feedback_on(self, tmp_path):
|
||||
sentinel = tmp_path / "feedback_off"
|
||||
sentinel.touch()
|
||||
from aipass.hooks.apps.modules.feedback import handle_command
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||
return_value=sentinel,
|
||||
):
|
||||
assert handle_command("feedback", ["on"]) is True
|
||||
|
||||
assert not sentinel.exists()
|
||||
|
||||
def test_handle_command_feedback_on_no_sentinel(self, tmp_path):
|
||||
sentinel = tmp_path / "feedback_off"
|
||||
from aipass.hooks.apps.modules.feedback import handle_command
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||
return_value=sentinel,
|
||||
):
|
||||
assert handle_command("feedback", ["on"]) is True
|
||||
|
||||
def test_handle_command_feedback_help(self, capsys):
|
||||
from aipass.hooks.apps.modules.feedback import handle_command
|
||||
|
||||
assert handle_command("feedback", ["--help"]) is True
|
||||
captured = capsys.readouterr()
|
||||
assert "drone @hooks feedback" in captured.err
|
||||
|
||||
def test_handle_command_no_aipass_dir(self, capsys):
|
||||
from aipass.hooks.apps.modules.feedback import handle_command
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||
return_value=None,
|
||||
):
|
||||
assert handle_command("feedback", []) is True
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "NO PROJECT" in captured.err
|
||||
|
||||
def test_handle_command_off_no_aipass_dir(self, capsys):
|
||||
from aipass.hooks.apps.modules.feedback import handle_command
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||
return_value=None,
|
||||
):
|
||||
assert handle_command("feedback", ["off"]) is True
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No .aipass/" in captured.err
|
||||
|
||||
def test_unrelated_command_returns_false(self):
|
||||
from aipass.hooks.apps.modules.feedback import handle_command
|
||||
|
||||
assert handle_command("other", []) is False
|
||||
|
||||
def test_state_survives_session_restart(self, tmp_path):
|
||||
"""Toggle state persists on disk — survives session restarts."""
|
||||
sentinel = tmp_path / "feedback_off"
|
||||
from aipass.hooks.apps.modules.feedback import handle_command
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||
return_value=sentinel,
|
||||
):
|
||||
handle_command("feedback", ["off"])
|
||||
|
||||
assert sentinel.exists()
|
||||
|
||||
with patch(
|
||||
"aipass.hooks.apps.modules.feedback._sentinel",
|
||||
return_value=sentinel,
|
||||
):
|
||||
handle_command("feedback", ["on"])
|
||||
|
||||
assert not sentinel.exists()
|
||||
@@ -8,6 +8,18 @@
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @memory search "query" # Search archived memories across all branches
|
||||
drone @memory rollover status # Show what needs archiving per branch
|
||||
drone @memory rollover check # Dry run — preview pending rollovers
|
||||
drone @memory lint # Audit .trinity entries for limit violations
|
||||
drone @memory watch # Auto-rollover watcher (Ctrl+C to stop)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -117,6 +117,12 @@ def print_help():
|
||||
console.print()
|
||||
console.print("[bold cyan]PRAX - System-Wide Logging Infrastructure[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Unified logging system for AIPass ecosystem[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]Usage:[/bold cyan]")
|
||||
console.print(" [green]drone @prax <command>[/green] [dim][options][/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Commands:[/yellow]")
|
||||
console.print(" [cyan]monitor[/cyan] Mission Control - unified real-time monitoring")
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
|
||||
# Seedgo
|
||||
|
||||
**Purpose:** Standards compliance platform for AIPass. Audits all 11 core agents against 40 code standards + diagnostics, manages bypass rules, runs proof certification, and provides per-file checklist validation consumed by auto-fix hooks.
|
||||
**Purpose:** Standards compliance platform for AIPass. Audits all 11 core agents against 42 code standards + diagnostics, manages bypass rules, runs proof certification, and provides per-file checklist validation consumed by the PostToolUse auto-fix gate.
|
||||
**Module:** `aipass.seedgo`
|
||||
**Version:** 2.0.0
|
||||
**Created:** 2026-03-05
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @seedgo audit aipass # Audit all branches against all standards
|
||||
drone @seedgo checklist <file> # Check a single file
|
||||
drone @seedgo standards_query aipass_standards cli # Look up what a standard checks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
### What I Do
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: cli_ux_check.py
|
||||
# Description: CLI UX Standards Checker Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-17
|
||||
# Modified: 2026-07-17
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
CLI UX Standards Checker Handler
|
||||
|
||||
Validates that branch entry points follow the AIPass house pattern for CLI
|
||||
help and introspection output.
|
||||
|
||||
Good entry points (flow.py, prax.py, drone.py, seedgo.py) all have:
|
||||
- print_introspection() and print_help() as separate two-tier functions
|
||||
- Rich console.print() output (no bare print())
|
||||
- Styled title, purpose/tagline, and --help pointer in introspection
|
||||
- Usage and Examples sections in help
|
||||
|
||||
Checks:
|
||||
1. two_tier_help - Both print_introspection() and print_help() exist
|
||||
2. rich_console - Help functions use console.print(), not bare print()
|
||||
3. title_markup - print_introspection() has a [bold styled title
|
||||
4. purpose_line - print_introspection() has a [dim] purpose/tagline line
|
||||
5. help_pointer - print_introspection() references --help
|
||||
6. usage_section - print_help() includes a Usage section
|
||||
7. examples_section - print_help() includes an Examples section
|
||||
8. no_internal_modules - modules/ does not expose internal plumbing files
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
||||
|
||||
# Only check entry points: apps/{branch}.py files
|
||||
AUDIT_SCOPE = "entry_point"
|
||||
|
||||
|
||||
def check_module(module_path: str, bypass_rules: list | None = None) -> Dict:
|
||||
"""
|
||||
Check if entry point follows the AIPass CLI UX house pattern.
|
||||
|
||||
Args:
|
||||
module_path: Path to Python module to check
|
||||
bypass_rules: Optional list of bypass rules to skip specific violations
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'passed': bool, # Overall pass/fail
|
||||
'checks': [ # Individual check results
|
||||
{
|
||||
'name': str, # Check name
|
||||
'passed': bool, # Pass/fail
|
||||
'message': str, # Details
|
||||
}
|
||||
],
|
||||
'score': int, # 0-100 percentage
|
||||
'standard': str # Standard name
|
||||
}
|
||||
"""
|
||||
checks: List[Dict] = []
|
||||
path = Path(module_path)
|
||||
|
||||
# Normalize to forward slashes so string matching works on Windows too
|
||||
module_path = Path(module_path).as_posix()
|
||||
|
||||
# Check if entire standard is bypassed for this file
|
||||
if is_bypassed(module_path, "cli_ux", bypass_rules=bypass_rules):
|
||||
return {
|
||||
"passed": True,
|
||||
"checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}],
|
||||
"score": 100,
|
||||
"standard": "CLI_UX",
|
||||
}
|
||||
|
||||
# Validate file exists
|
||||
if not path.exists():
|
||||
return {
|
||||
"passed": False,
|
||||
"checks": [{"name": "File exists", "passed": False, "message": f"File not found: {module_path}"}],
|
||||
"score": 0,
|
||||
"standard": "CLI_UX",
|
||||
}
|
||||
|
||||
# Skip __init__.py files
|
||||
if path.name == "__init__.py":
|
||||
return {
|
||||
"passed": True,
|
||||
"checks": [{"name": "CLI UX check", "passed": True, "message": "__init__.py skipped"}],
|
||||
"score": 100,
|
||||
"standard": "CLI_UX",
|
||||
}
|
||||
|
||||
# Skip non-entry-point files (entry points live at apps/{name}.py)
|
||||
if not _is_entry_point(module_path, path):
|
||||
return {
|
||||
"passed": True,
|
||||
"checks": [
|
||||
{
|
||||
"name": "CLI UX check",
|
||||
"passed": True,
|
||||
"message": "Not an entry point file (not applicable)",
|
||||
}
|
||||
],
|
||||
"score": 100,
|
||||
"standard": "CLI_UX",
|
||||
}
|
||||
|
||||
# Read file
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
logger.info("Cannot read %s: %s", path, e)
|
||||
return {
|
||||
"passed": False,
|
||||
"checks": [{"name": "File readable", "passed": False, "message": f"Error reading file: {e}"}],
|
||||
"score": 0,
|
||||
"standard": "CLI_UX",
|
||||
}
|
||||
|
||||
# Empty file
|
||||
if not content.strip():
|
||||
return {
|
||||
"passed": True,
|
||||
"checks": [{"name": "CLI UX check", "passed": True, "message": "Empty file skipped"}],
|
||||
"score": 100,
|
||||
"standard": "CLI_UX",
|
||||
}
|
||||
|
||||
# Parse AST
|
||||
try:
|
||||
tree = ast.parse(content, filename=module_path)
|
||||
except SyntaxError as e:
|
||||
logger.info("Skipped %s: SyntaxError during parse", path)
|
||||
return {
|
||||
"passed": False,
|
||||
"checks": [{"name": "File parseable", "passed": False, "message": f"Syntax error: {e}"}],
|
||||
"score": 0,
|
||||
"standard": "CLI_UX",
|
||||
}
|
||||
|
||||
# Find the two key functions via AST
|
||||
introspection_func = _find_function(tree, "print_introspection")
|
||||
help_func = _find_function(tree, "print_help")
|
||||
|
||||
# --- Check 1: two_tier_help ---
|
||||
checks.append(_check_two_tier_help(tree, path.name))
|
||||
|
||||
# --- Check 2: rich_console ---
|
||||
checks.append(_check_rich_console(introspection_func, help_func, path.name))
|
||||
|
||||
# --- Check 3: title_markup ---
|
||||
checks.append(_check_title_markup(introspection_func, path.name))
|
||||
|
||||
# --- Check 4: purpose_line ---
|
||||
checks.append(_check_purpose_line(introspection_func, path.name))
|
||||
|
||||
# --- Check 5: help_pointer ---
|
||||
checks.append(_check_help_pointer(introspection_func, path.name))
|
||||
|
||||
# --- Check 6: usage_section ---
|
||||
checks.append(_check_usage_section(help_func, path.name))
|
||||
|
||||
# --- Check 7: examples_section ---
|
||||
checks.append(_check_examples_section(help_func, path.name))
|
||||
|
||||
# --- Check 8: no_internal_modules ---
|
||||
checks.append(_check_no_internal_modules(module_path, path))
|
||||
|
||||
# Calculate score
|
||||
passed_checks = sum(1 for check in checks if check["passed"])
|
||||
total_checks = len(checks)
|
||||
score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0
|
||||
|
||||
# Overall pass = ALL checks passed
|
||||
overall_passed = all(check["passed"] for check in checks)
|
||||
|
||||
json_handler.log_operation("check_completed", {"file": str(module_path), "score": score, "standard": "cli_ux"})
|
||||
return {"passed": overall_passed, "checks": checks, "score": score, "standard": "CLI_UX"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_entry_point(module_path: str, path: Path) -> bool:
|
||||
"""
|
||||
Detect if file is an entry point: apps/{name}.py (directly in apps/, not in subdirectory).
|
||||
|
||||
Entry points live at apps/{name}.py -- their parent directory is 'apps'.
|
||||
Files in apps/modules/, apps/handlers/, apps/plugins/ etc. are NOT entry points.
|
||||
"""
|
||||
if not path.name.endswith(".py"):
|
||||
return False
|
||||
posix_path = Path(module_path).as_posix()
|
||||
if "apps/" not in posix_path:
|
||||
return False
|
||||
return path.parent.name == "apps"
|
||||
|
||||
|
||||
def _find_function(tree: ast.Module, name: str) -> Optional[ast.FunctionDef]:
|
||||
"""Find a top-level function definition by name."""
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _collect_string_constants(func_node: ast.FunctionDef) -> List[str]:
|
||||
"""Extract all string constants from a function body, including f-string parts."""
|
||||
return [
|
||||
node.value for node in ast.walk(func_node) if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _check_two_tier_help(tree: ast.Module, filename: str) -> Dict:
|
||||
"""Check 1: Entry point has BOTH print_introspection AND print_help."""
|
||||
found = set()
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.FunctionDef) and node.name in ("print_introspection", "print_help"):
|
||||
found.add(node.name)
|
||||
|
||||
missing = {"print_introspection", "print_help"} - found
|
||||
|
||||
if not missing:
|
||||
return {
|
||||
"name": "two_tier_help",
|
||||
"passed": True,
|
||||
"message": f"Both print_introspection() and print_help() found in {filename}",
|
||||
}
|
||||
|
||||
missing_str = ", ".join(sorted(missing))
|
||||
return {
|
||||
"name": "two_tier_help",
|
||||
"passed": False,
|
||||
"message": f"Entry point must define both print_introspection() and print_help() — {missing_str} not found",
|
||||
}
|
||||
|
||||
|
||||
def _check_rich_console(
|
||||
introspection_func: Optional[ast.FunctionDef],
|
||||
help_func: Optional[ast.FunctionDef],
|
||||
filename: str,
|
||||
) -> Dict:
|
||||
"""Check 2: Help functions use console.print(), not bare print()."""
|
||||
funcs_to_check = []
|
||||
if introspection_func is not None:
|
||||
funcs_to_check.append(("print_introspection", introspection_func))
|
||||
if help_func is not None:
|
||||
funcs_to_check.append(("print_help", help_func))
|
||||
|
||||
if not funcs_to_check:
|
||||
# Neither function exists -- auto-fail (two_tier_help already catches the root cause)
|
||||
return {
|
||||
"name": "rich_console",
|
||||
"passed": False,
|
||||
"message": "Help functions must use console.print() from aipass.cli, not bare print()",
|
||||
}
|
||||
|
||||
has_console_print = False
|
||||
has_bare_print = False
|
||||
|
||||
for _func_name, func_node in funcs_to_check:
|
||||
for node in ast.walk(func_node):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
# console.print() — Attribute call where value.id == 'console' and attr == 'print'
|
||||
if isinstance(node.func, ast.Attribute):
|
||||
if (
|
||||
node.func.attr == "print"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "console"
|
||||
):
|
||||
has_console_print = True
|
||||
# bare print() — Name call where id == 'print'
|
||||
if isinstance(node.func, ast.Name) and node.func.id == "print":
|
||||
has_bare_print = True
|
||||
|
||||
if has_bare_print:
|
||||
return {
|
||||
"name": "rich_console",
|
||||
"passed": False,
|
||||
"message": "Help functions must use console.print() from aipass.cli, not bare print()",
|
||||
}
|
||||
|
||||
if not has_console_print:
|
||||
return {
|
||||
"name": "rich_console",
|
||||
"passed": False,
|
||||
"message": "Help functions must use console.print() from aipass.cli, not bare print()",
|
||||
}
|
||||
|
||||
return {
|
||||
"name": "rich_console",
|
||||
"passed": True,
|
||||
"message": f"Help functions in {filename} use console.print() (no bare print())",
|
||||
}
|
||||
|
||||
|
||||
def _check_title_markup(introspection_func: Optional[ast.FunctionDef], filename: str) -> Dict:
|
||||
"""Check 3: print_introspection contains a [bold styled title."""
|
||||
if introspection_func is None:
|
||||
return {
|
||||
"name": "title_markup",
|
||||
"passed": False,
|
||||
"message": (
|
||||
"print_introspection() must include a styled title line (e.g. [bold cyan]Branch Name[/bold cyan])"
|
||||
),
|
||||
}
|
||||
|
||||
strings = _collect_string_constants(introspection_func)
|
||||
for s in strings:
|
||||
if "[bold" in s:
|
||||
return {
|
||||
"name": "title_markup",
|
||||
"passed": True,
|
||||
"message": f"print_introspection() in {filename} has a styled title with [bold markup",
|
||||
}
|
||||
|
||||
return {
|
||||
"name": "title_markup",
|
||||
"passed": False,
|
||||
"message": "print_introspection() must include a styled title line (e.g. [bold cyan]Branch Name[/bold cyan])",
|
||||
}
|
||||
|
||||
|
||||
def _check_purpose_line(introspection_func: Optional[ast.FunctionDef], filename: str) -> Dict:
|
||||
"""Check 4: print_introspection contains a [dim] purpose/tagline line."""
|
||||
if introspection_func is None:
|
||||
return {
|
||||
"name": "purpose_line",
|
||||
"passed": False,
|
||||
"message": "print_introspection() must include a dim-styled purpose/tagline line",
|
||||
}
|
||||
|
||||
strings = _collect_string_constants(introspection_func)
|
||||
for s in strings:
|
||||
if "[dim]" in s:
|
||||
return {
|
||||
"name": "purpose_line",
|
||||
"passed": True,
|
||||
"message": f"print_introspection() in {filename} has a [dim] purpose/tagline line",
|
||||
}
|
||||
|
||||
return {
|
||||
"name": "purpose_line",
|
||||
"passed": False,
|
||||
"message": "print_introspection() must include a dim-styled purpose/tagline line",
|
||||
}
|
||||
|
||||
|
||||
def _check_help_pointer(introspection_func: Optional[ast.FunctionDef], filename: str) -> Dict:
|
||||
"""Check 5: print_introspection contains a closing pointer to --help."""
|
||||
if introspection_func is None:
|
||||
return {
|
||||
"name": "help_pointer",
|
||||
"passed": False,
|
||||
"message": "print_introspection() must include a closing pointer to --help for more info",
|
||||
}
|
||||
|
||||
strings = _collect_string_constants(introspection_func)
|
||||
for s in strings:
|
||||
if "--help" in s:
|
||||
return {
|
||||
"name": "help_pointer",
|
||||
"passed": True,
|
||||
"message": f"print_introspection() in {filename} includes a --help pointer",
|
||||
}
|
||||
|
||||
return {
|
||||
"name": "help_pointer",
|
||||
"passed": False,
|
||||
"message": "print_introspection() must include a closing pointer to --help for more info",
|
||||
}
|
||||
|
||||
|
||||
def _check_usage_section(help_func: Optional[ast.FunctionDef], filename: str) -> Dict:
|
||||
"""Check 6: print_help contains a Usage section."""
|
||||
if help_func is None:
|
||||
return {
|
||||
"name": "usage_section",
|
||||
"passed": False,
|
||||
"message": "print_help() must include a Usage section",
|
||||
}
|
||||
|
||||
strings = _collect_string_constants(help_func)
|
||||
for s in strings:
|
||||
if "usage" in s.lower():
|
||||
return {
|
||||
"name": "usage_section",
|
||||
"passed": True,
|
||||
"message": f"print_help() in {filename} includes a Usage section",
|
||||
}
|
||||
|
||||
return {
|
||||
"name": "usage_section",
|
||||
"passed": False,
|
||||
"message": "print_help() must include a Usage section",
|
||||
}
|
||||
|
||||
|
||||
def _check_examples_section(help_func: Optional[ast.FunctionDef], filename: str) -> Dict:
|
||||
"""Check 7: print_help contains an Examples section."""
|
||||
if help_func is None:
|
||||
return {
|
||||
"name": "examples_section",
|
||||
"passed": False,
|
||||
"message": "print_help() must include an Examples section",
|
||||
}
|
||||
|
||||
strings = _collect_string_constants(help_func)
|
||||
for s in strings:
|
||||
if "example" in s.lower():
|
||||
return {
|
||||
"name": "examples_section",
|
||||
"passed": True,
|
||||
"message": f"print_help() in {filename} includes an Examples section",
|
||||
}
|
||||
|
||||
return {
|
||||
"name": "examples_section",
|
||||
"passed": False,
|
||||
"message": "print_help() must include an Examples section",
|
||||
}
|
||||
|
||||
|
||||
def _check_no_internal_modules(module_path: str, path: Path) -> Dict:
|
||||
"""
|
||||
Check 8: modules/ directory does not expose internal plumbing files.
|
||||
|
||||
Files whose stems end with _wire, _fix, _impl, or _internal should be
|
||||
underscore-prefixed to hide from discovery.
|
||||
"""
|
||||
internal_suffixes = ("_wire", "_fix", "_impl", "_internal")
|
||||
|
||||
# Derive the branch root from the entry point path:
|
||||
# entry point is at {branch_root}/apps/{name}.py -> parent.parent is branch_root
|
||||
branch_root = path.parent.parent
|
||||
modules_dir = branch_root / "apps" / "modules"
|
||||
|
||||
if not modules_dir.is_dir():
|
||||
return {
|
||||
"name": "no_internal_modules",
|
||||
"passed": True,
|
||||
"message": "No modules/ directory found (nothing to check)",
|
||||
}
|
||||
|
||||
exposed_internal: List[str] = []
|
||||
for py_file in modules_dir.glob("*.py"):
|
||||
stem = py_file.stem
|
||||
# Skip __init__.py and already-underscore-prefixed files
|
||||
if stem.startswith("_"):
|
||||
continue
|
||||
if any(stem.endswith(suffix) for suffix in internal_suffixes):
|
||||
exposed_internal.append(py_file.name)
|
||||
|
||||
if exposed_internal:
|
||||
names = ", ".join(sorted(exposed_internal))
|
||||
return {
|
||||
"name": "no_internal_modules",
|
||||
"passed": False,
|
||||
"message": (
|
||||
f"modules/ directory exposes internal plumbing: {names} "
|
||||
f"— prefix with underscore or use COMMAND attribute"
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"name": "no_internal_modules",
|
||||
"passed": True,
|
||||
"message": "No exposed internal plumbing files in modules/",
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: cli_ux_content.py
|
||||
# Description: CLI UX Standards Content Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-17
|
||||
# Modified: 2026-07-17
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
CLI UX Standards Content Handler
|
||||
|
||||
Provides formatted CLI UX standards content.
|
||||
Module orchestrates, handler implements.
|
||||
"""
|
||||
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def get_cli_ux_standards() -> str:
|
||||
"""Return formatted CLI UX standards content with Rich markup.
|
||||
|
||||
Returns:
|
||||
str: Formatted standards text with Rich styling
|
||||
"""
|
||||
lines = [
|
||||
"[bold red]CLI UX STANDARD[/bold red]",
|
||||
"",
|
||||
"[bold cyan]CORE RULE:[/bold cyan] Entry points must follow the AIPass house pattern",
|
||||
"",
|
||||
"[yellow]RULE:[/yellow] Every branch entry point (apps/{branch}.py) must provide",
|
||||
" a two-tier help surface: print_introspection() for bare invocation and",
|
||||
" print_help() for --help, both using Rich console.print() from aipass.cli.",
|
||||
"",
|
||||
"=" * 70,
|
||||
"",
|
||||
"[bold cyan]THE HOUSE PATTERN:[/bold cyan]",
|
||||
"",
|
||||
" [green]1.[/green] [bold]Two-tier help[/bold] -- print_introspection() (light) + print_help() (full)",
|
||||
" [green]2.[/green] [bold]Rich console[/bold] -- console.print() with markup, never bare print()",
|
||||
" [green]3.[/green] [bold]Title + purpose[/bold] -- styled title line + dim tagline in introspection",
|
||||
" [green]4.[/green] [bold]Closing pointer[/bold] -- introspection ends with --help reference",
|
||||
" [green]5.[/green] [bold]Usage + examples[/bold] -- print_help() includes USAGE and EXAMPLES sections",
|
||||
" [green]6.[/green] [bold]No internal leaks[/bold] -- modules/ must not expose internal plumbing",
|
||||
"",
|
||||
"=" * 70,
|
||||
"",
|
||||
"[bold cyan]WHAT IS CHECKED:[/bold cyan]",
|
||||
"",
|
||||
" [yellow]Scope:[/yellow] entry_point -- only apps/{branch}.py files",
|
||||
"",
|
||||
" [yellow]Check 1:[/yellow] two_tier_help",
|
||||
" Both print_introspection() and print_help() defined as top-level functions",
|
||||
"",
|
||||
" [yellow]Check 2:[/yellow] rich_console",
|
||||
" Help functions use console.print(), no bare print() calls",
|
||||
"",
|
||||
" [yellow]Check 3:[/yellow] title_markup",
|
||||
" print_introspection() contains a [bold]-styled title line",
|
||||
"",
|
||||
" [yellow]Check 4:[/yellow] purpose_line",
|
||||
" print_introspection() contains a [dim]-styled purpose/tagline",
|
||||
"",
|
||||
" [yellow]Check 5:[/yellow] help_pointer",
|
||||
" print_introspection() references --help for more info",
|
||||
"",
|
||||
" [yellow]Check 6:[/yellow] usage_section",
|
||||
" print_help() contains a Usage section",
|
||||
"",
|
||||
" [yellow]Check 7:[/yellow] examples_section",
|
||||
" print_help() contains an Examples section",
|
||||
"",
|
||||
" [yellow]Check 8:[/yellow] no_internal_modules",
|
||||
" modules/ directory does not expose internal plumbing names",
|
||||
"",
|
||||
"=" * 70,
|
||||
"",
|
||||
"[bold cyan]GOOD EXAMPLE (flow.py introspection):[/bold cyan]",
|
||||
"",
|
||||
' console.print("[bold cyan]Flow - PLAN Management System[/bold cyan]")',
|
||||
' console.print("[dim]Task orchestration and workflow management[/dim]")',
|
||||
" # ... module list ...",
|
||||
" console.print(\"[dim]Run 'drone @flow --help' for usage information[/dim]\")",
|
||||
"",
|
||||
"[bold cyan]BAD EXAMPLE (aipass.py -- fails all checks):[/bold cyan]",
|
||||
"",
|
||||
' print(f"AIPASS - {len(modules)} modules discovered") # bare print',
|
||||
" # no print_introspection/print_help, no title/purpose, no pointer",
|
||||
"",
|
||||
"=" * 70,
|
||||
"",
|
||||
"[bold cyan]HOW TO FIX:[/bold cyan]",
|
||||
"",
|
||||
" 1. Define print_introspection() with: styled title, dim purpose, module list, --help pointer",
|
||||
" 2. Define print_help() with: USAGE section, command list, EXAMPLES section",
|
||||
" 3. Replace all bare print() with console.print() from aipass.cli",
|
||||
" 4. Prefix internal modules with underscore or set COMMAND attribute",
|
||||
"",
|
||||
"=" * 70,
|
||||
"",
|
||||
"[bold cyan]SCORING:[/bold cyan]",
|
||||
" Score = (passed_checks / 8) * 100",
|
||||
" Pass requires all 8 checks green",
|
||||
"",
|
||||
"[bold cyan]REFERENCE:[/bold cyan]",
|
||||
" [dim]See: seedgo standards pack (cli_ux)[/dim]",
|
||||
" [dim]Compare: drone @flow (good) vs aipass --help (bad)[/dim]",
|
||||
]
|
||||
|
||||
json_handler.log_operation("standard_content_queried", {"standard": "cli_ux"})
|
||||
return "\n".join(lines)
|
||||
@@ -34,6 +34,9 @@ _DOCTEST_RE = re.compile(r"^\s*(\.\.\.|>>>)\s")
|
||||
# Test file name patterns
|
||||
_TEST_FILE_RE = re.compile(r"^(test_.+|.+_test|conftest)\.py$")
|
||||
|
||||
# String literals — strip content before regex to avoid false positives on print( inside strings
|
||||
_STRING_LITERAL_RE = re.compile(r'""".*?"""|\'\'\'.*?\'\'\'|"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'')
|
||||
|
||||
|
||||
def _is_in_main_block(lines: list[str], lineno: int) -> bool:
|
||||
"""
|
||||
@@ -108,8 +111,9 @@ def _scan_file(file_path: Path) -> tuple[list[int], str | None]:
|
||||
if _DOCTEST_RE.match(line):
|
||||
continue
|
||||
|
||||
# Strip inline comments before checking for print(
|
||||
# Strip inline comments and string literal contents before checking
|
||||
code_part = line.split("#")[0]
|
||||
code_part = _STRING_LITERAL_RE.sub('""', code_part)
|
||||
|
||||
if not _PRINT_RE.search(code_part):
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: readme_quality_check.py
|
||||
# Description: README Quality Standards Checker Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-17
|
||||
# Modified: 2026-07-17
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
README Quality Standards Checker Handler
|
||||
|
||||
Validates README.md content quality from a stranger's perspective.
|
||||
Orthogonal to readme_check.py which validates structural completeness.
|
||||
|
||||
Checks:
|
||||
1. Quick Start section with runnable command example
|
||||
2. Stranger accessibility (not too many unexplained internal references)
|
||||
3. Invoke/Usage command matches branch name
|
||||
4. Clear description within first 10 lines
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from aipass.prax import logger
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed
|
||||
|
||||
# Audit scope: entry points only (apps/{name}.py)
|
||||
AUDIT_SCOPE = "entry_point"
|
||||
|
||||
# Internal AIPass branch names — a stranger would not recognise these
|
||||
# without context. "aipass" is excluded because it is the project name
|
||||
# itself and appears unavoidably in every branch description.
|
||||
_INTERNAL_NAMES = frozenset(
|
||||
{
|
||||
"drone",
|
||||
"devpulse",
|
||||
"ai_mail",
|
||||
"flow",
|
||||
"seedgo",
|
||||
"prax",
|
||||
"memory",
|
||||
"spawn",
|
||||
"hooks",
|
||||
"trigger",
|
||||
"api",
|
||||
"cli",
|
||||
"skills",
|
||||
"daemon",
|
||||
"commons",
|
||||
"backup",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def check_module(module_path: str, bypass_rules: list | None = None) -> Dict:
|
||||
"""
|
||||
Check if branch README meets content-quality standards.
|
||||
|
||||
Args:
|
||||
module_path: Path to branch entry point (e.g., src/aipass/seedgo/apps/branch.py)
|
||||
bypass_rules: Optional list of bypass rules to skip certain checks
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'passed': bool,
|
||||
'checks': [{'name': str, 'passed': bool, 'message': str}],
|
||||
'score': int,
|
||||
'standard': str
|
||||
}
|
||||
"""
|
||||
checks: List[Dict] = []
|
||||
|
||||
# Skip __init__.py files
|
||||
if Path(module_path).name == "__init__.py":
|
||||
return {
|
||||
"passed": True,
|
||||
"checks": [{"name": "README quality", "passed": True, "message": "__init__.py file (skipped)"}],
|
||||
"score": 100,
|
||||
"standard": "README_QUALITY",
|
||||
}
|
||||
|
||||
# Check if entire standard is bypassed for this file
|
||||
if is_bypassed(module_path, "readme_quality", bypass_rules=bypass_rules):
|
||||
return {
|
||||
"passed": True,
|
||||
"checks": [{"name": "Bypassed", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}],
|
||||
"score": 100,
|
||||
"standard": "README_QUALITY",
|
||||
}
|
||||
|
||||
# Derive branch root: module_path is apps/{branch}.py, go up 2 levels
|
||||
entry_path = Path(module_path)
|
||||
branch_root = entry_path.parent.parent
|
||||
branch_name = branch_root.name
|
||||
|
||||
# Locate README
|
||||
readme_path = branch_root / "README.md"
|
||||
if not readme_path.exists():
|
||||
fail_msg = "README.md not found at branch root"
|
||||
for name in ["quick_start", "stranger_accessible", "invoke_match", "what_description"]:
|
||||
checks.append({"name": name, "passed": False, "message": fail_msg})
|
||||
|
||||
return {"passed": False, "checks": checks, "score": 0, "standard": "README_QUALITY"}
|
||||
|
||||
# Read README content
|
||||
try:
|
||||
content = readme_path.read_text(encoding="utf-8")
|
||||
lines = content.split("\n")
|
||||
except Exception as e:
|
||||
logger.info("Cannot read README at %s: %s", readme_path, e)
|
||||
return {
|
||||
"passed": False,
|
||||
"checks": [{"name": "File readable", "passed": False, "message": f"Error reading README: {e}"}],
|
||||
"score": 0,
|
||||
"standard": "README_QUALITY",
|
||||
}
|
||||
|
||||
# Check 1: Quick Start section with code example
|
||||
checks.append(_check_quick_start(lines, module_path, bypass_rules))
|
||||
|
||||
# Check 2: Stranger accessibility
|
||||
checks.append(_check_stranger_accessible(lines, module_path, bypass_rules))
|
||||
|
||||
# Check 3: Invoke/Usage command matches branch
|
||||
checks.append(_check_invoke_match(lines, branch_name, module_path, bypass_rules))
|
||||
|
||||
# Check 4: Clear description within first 10 lines
|
||||
checks.append(_check_what_description(lines, module_path, bypass_rules))
|
||||
|
||||
# Calculate score
|
||||
passed_checks = sum(1 for c in checks if c["passed"])
|
||||
total_checks = len(checks)
|
||||
score = int((passed_checks / total_checks * 100)) if total_checks > 0 else 0
|
||||
overall_passed = all(c["passed"] for c in checks)
|
||||
|
||||
json_handler.log_operation(
|
||||
"check_completed",
|
||||
{"file": str(module_path), "score": score, "standard": "readme_quality"},
|
||||
)
|
||||
return {"passed": overall_passed, "checks": checks, "score": score, "standard": "README_QUALITY"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Individual checks #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _check_quick_start(lines: List[str], file_path: str, bypass_rules: list | None) -> Dict:
|
||||
"""README must have a Quick Start / Getting Started section with at least one fenced code block."""
|
||||
if is_bypassed(file_path, "readme_quality", None, bypass_rules):
|
||||
return {"name": "quick_start", "passed": True, "message": "Bypassed by bypass rules"}
|
||||
|
||||
# Find a heading containing "quick start", "getting started", or "quickstart"
|
||||
qs_pattern = re.compile(r"^(#{1,6})\s+.*(quick\s*start|getting\s+started)", re.IGNORECASE)
|
||||
section_start: int | None = None
|
||||
section_level: int = 0
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
m = qs_pattern.match(line)
|
||||
if m:
|
||||
section_start = i
|
||||
section_level = len(m.group(1))
|
||||
break
|
||||
|
||||
if section_start is None:
|
||||
return {
|
||||
"name": "quick_start",
|
||||
"passed": False,
|
||||
"message": "README must have a Quick Start section with at least one runnable command example",
|
||||
}
|
||||
|
||||
# Scan from section heading to next heading of equal or higher level (or EOF)
|
||||
has_code_block = False
|
||||
for j in range(section_start + 1, len(lines)):
|
||||
stripped = lines[j].strip()
|
||||
# Next heading at same or higher level ends the section
|
||||
heading_match = re.match(r"^(#{1,6})\s+", lines[j])
|
||||
if heading_match and len(heading_match.group(1)) <= section_level:
|
||||
break
|
||||
if stripped.startswith("```"):
|
||||
has_code_block = True
|
||||
break
|
||||
|
||||
if has_code_block:
|
||||
return {"name": "quick_start", "passed": True, "message": "Quick Start section found with code example"}
|
||||
|
||||
return {
|
||||
"name": "quick_start",
|
||||
"passed": False,
|
||||
"message": "README must have a Quick Start section with at least one runnable command example",
|
||||
}
|
||||
|
||||
|
||||
def _check_stranger_accessible(lines: List[str], file_path: str, bypass_rules: list | None) -> Dict:
|
||||
"""First 5 content lines must not reference more than 2 unique internal branch names."""
|
||||
if is_bypassed(file_path, "readme_quality", None, bypass_rules):
|
||||
return {"name": "stranger_accessible", "passed": True, "message": "Bypassed by bypass rules"}
|
||||
|
||||
# Build regex once
|
||||
names_pattern = re.compile(
|
||||
r"\b(" + "|".join(re.escape(n) for n in sorted(_INTERNAL_NAMES, key=len, reverse=True)) + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Collect first 5 non-empty, non-heading lines after the title heading
|
||||
content_lines: List[str] = []
|
||||
past_title = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
# Skip until we pass the first heading (title)
|
||||
if not past_title:
|
||||
if stripped.startswith("#"):
|
||||
past_title = True
|
||||
continue
|
||||
# Skip headings and empty lines
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
content_lines.append(stripped)
|
||||
if len(content_lines) >= 5:
|
||||
break
|
||||
|
||||
if not content_lines:
|
||||
# No content lines found — nothing to flag
|
||||
return {
|
||||
"name": "stranger_accessible",
|
||||
"passed": True,
|
||||
"message": "No content lines found to check (skipped)",
|
||||
}
|
||||
|
||||
# Count unique internal name references across collected lines
|
||||
found_names: set = set()
|
||||
for cl in content_lines:
|
||||
for m in names_pattern.finditer(cl):
|
||||
found_names.add(m.group(1).lower())
|
||||
|
||||
if len(found_names) > 2:
|
||||
sorted_names = ", ".join(sorted(found_names))
|
||||
return {
|
||||
"name": "stranger_accessible",
|
||||
"passed": False,
|
||||
"message": (
|
||||
f"First paragraph references {len(found_names)} internal branches"
|
||||
f" ({sorted_names}) — strangers won't understand without context"
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
"name": "stranger_accessible",
|
||||
"passed": True,
|
||||
"message": f"First paragraph references {len(found_names)} internal branch name(s) (limit 2)",
|
||||
}
|
||||
|
||||
|
||||
def _check_invoke_match(lines: List[str], branch_name: str, file_path: str, bypass_rules: list | None) -> Dict:
|
||||
"""If an Invoke / Usage / How to Run section exists, its command should reference the branch name."""
|
||||
if is_bypassed(file_path, "readme_quality", None, bypass_rules):
|
||||
return {"name": "invoke_match", "passed": True, "message": "Bypassed by bypass rules"}
|
||||
|
||||
# Find heading containing "invoke", "usage", or "how to run"
|
||||
heading_pattern = re.compile(r"^(#{1,6})\s+.*(invoke|usage|how\s+to\s+run)", re.IGNORECASE)
|
||||
section_start: int | None = None
|
||||
section_level: int = 0
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
m = heading_pattern.match(line)
|
||||
if m:
|
||||
section_start = i
|
||||
section_level = len(m.group(1))
|
||||
break
|
||||
|
||||
if section_start is None:
|
||||
return {
|
||||
"name": "invoke_match",
|
||||
"passed": True,
|
||||
"message": "No invoke section found — skipped",
|
||||
}
|
||||
|
||||
# Extract section content until next heading of equal or higher level
|
||||
section_content: List[str] = []
|
||||
for j in range(section_start + 1, len(lines)):
|
||||
heading_match = re.match(r"^(#{1,6})\s+", lines[j])
|
||||
if heading_match and len(heading_match.group(1)) <= section_level:
|
||||
break
|
||||
section_content.append(lines[j])
|
||||
|
||||
section_text = "\n".join(section_content)
|
||||
|
||||
# Check if the branch name appears in the section (case-insensitive)
|
||||
if re.search(re.escape(branch_name), section_text, re.IGNORECASE):
|
||||
return {
|
||||
"name": "invoke_match",
|
||||
"passed": True,
|
||||
"message": f"Invoke section references branch '{branch_name}'",
|
||||
}
|
||||
|
||||
# Also accept drone @{branch} pattern (standard invocation)
|
||||
if re.search(r"drone\s+@" + re.escape(branch_name), section_text, re.IGNORECASE):
|
||||
return {
|
||||
"name": "invoke_match",
|
||||
"passed": True,
|
||||
"message": f"Invoke section uses drone @{branch_name} pattern",
|
||||
}
|
||||
|
||||
return {
|
||||
"name": "invoke_match",
|
||||
"passed": False,
|
||||
"message": f"Invoke section command does not match branch name '{branch_name}'",
|
||||
}
|
||||
|
||||
|
||||
def _check_what_description(lines: List[str], file_path: str, bypass_rules: list | None) -> Dict:
|
||||
"""README must describe what the branch does within the first 10 lines."""
|
||||
if is_bypassed(file_path, "readme_quality", None, bypass_rules):
|
||||
return {"name": "what_description", "passed": True, "message": "Bypassed by bypass rules"}
|
||||
|
||||
# Collect first 10 lines, check for at least one non-heading, non-empty line > 20 chars
|
||||
for line in lines[:10]:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if len(stripped) > 20:
|
||||
return {
|
||||
"name": "what_description",
|
||||
"passed": True,
|
||||
"message": "Description found within first 10 lines",
|
||||
}
|
||||
|
||||
return {
|
||||
"name": "what_description",
|
||||
"passed": False,
|
||||
"message": "README must describe what the branch does within the first 10 lines",
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: readme_quality_content.py
|
||||
# Description: README Quality Standards Content Handler
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-17
|
||||
# Modified: 2026-07-17
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
README Quality Standards Content Handler
|
||||
|
||||
Provides formatted README quality standards content.
|
||||
Module orchestrates, handler implements.
|
||||
"""
|
||||
|
||||
from aipass.seedgo.apps.handlers.json import json_handler
|
||||
|
||||
|
||||
def get_readme_quality_standards() -> str:
|
||||
"""Return formatted README quality standards content with Rich markup.
|
||||
|
||||
Returns:
|
||||
str: Formatted standards text with Rich styling
|
||||
"""
|
||||
lines = [
|
||||
"[bold red]README QUALITY STANDARD[/bold red]",
|
||||
"",
|
||||
"[bold cyan]CORE RULE:[/bold cyan] READMEs must be accessible to strangers",
|
||||
"",
|
||||
"[yellow]RULE:[/yellow] AIPass is open source. A stranger cloning the repo should",
|
||||
" understand what each branch does, how to invoke it, and be able to",
|
||||
" get started from the README alone -- no insider knowledge required.",
|
||||
"",
|
||||
"[dim]Note: This standard checks CONTENT QUALITY. The existing 'readme'",
|
||||
"standard checks STRUCTURAL completeness (sections, tree, links).",
|
||||
"Both must pass.[/dim]",
|
||||
"",
|
||||
"=" * 70,
|
||||
"",
|
||||
"[bold cyan]WHAT IS CHECKED:[/bold cyan]",
|
||||
"",
|
||||
" [yellow]Scope:[/yellow] entry_point -- derives README from branch root",
|
||||
"",
|
||||
" [yellow]Check 1:[/yellow] quick_start",
|
||||
" README has a Quick Start / Getting Started section containing",
|
||||
" at least one fenced code block with a runnable command.",
|
||||
"",
|
||||
" [yellow]Check 2:[/yellow] stranger_accessible",
|
||||
" First 5 content lines must not reference more than 2 internal",
|
||||
" AIPass branch names. Strangers cannot parse insider jargon.",
|
||||
"",
|
||||
" [yellow]Check 3:[/yellow] invoke_match",
|
||||
" If an Invoke/Usage section exists, the command shown must",
|
||||
" reference the correct branch name.",
|
||||
"",
|
||||
" [yellow]Check 4:[/yellow] what_description",
|
||||
" README must describe what the branch does within the first",
|
||||
" 10 lines (at least one content line longer than 20 characters).",
|
||||
"",
|
||||
"=" * 70,
|
||||
"",
|
||||
"[bold cyan]THE STRANGER TEST:[/bold cyan]",
|
||||
"",
|
||||
" Imagine someone who has never seen AIPass before. They clone the",
|
||||
" repo and open your README. Can they answer:",
|
||||
"",
|
||||
" [green]1.[/green] What does this branch do? (what_description)",
|
||||
" [green]2.[/green] How do I run it? (quick_start + invoke_match)",
|
||||
" [green]3.[/green] Do I need to know other branches first? (stranger_accessible)",
|
||||
"",
|
||||
" If any answer is 'no', the README fails the stranger test.",
|
||||
"",
|
||||
"=" * 70,
|
||||
"",
|
||||
"[bold cyan]INTERNAL BRANCH NAMES (checked in stranger_accessible):[/bold cyan]",
|
||||
"",
|
||||
" drone, devpulse, aipass, ai_mail, flow, seedgo, prax, memory,",
|
||||
" spawn, hooks, trigger, api, cli, skills, daemon, commons, backup",
|
||||
"",
|
||||
" [dim]Using more than 2 of these in your opening lines without context",
|
||||
" makes the README insider-only.[/dim]",
|
||||
"",
|
||||
"=" * 70,
|
||||
"",
|
||||
"[bold cyan]GOOD EXAMPLE:[/bold cyan]",
|
||||
"",
|
||||
" # Flow",
|
||||
" Plan lifecycle manager for AIPass. Creates, tracks, and closes",
|
||||
" development plans across the project.",
|
||||
"",
|
||||
" ## Quick Start",
|
||||
" ```",
|
||||
" drone @flow create . 'My Plan'",
|
||||
" drone @flow list",
|
||||
" ```",
|
||||
"",
|
||||
"[bold cyan]BAD EXAMPLE:[/bold cyan]",
|
||||
"",
|
||||
" # AIPASS",
|
||||
" Concierge and librarian for AIPass. Greets new users, walks them",
|
||||
" through setup via drone, hands off to devpulse, integrates with",
|
||||
" seedgo, prax, and spawn for system health.",
|
||||
" [red](5 internal names in opening lines -- stranger cannot follow)[/red]",
|
||||
"",
|
||||
"=" * 70,
|
||||
"",
|
||||
"[bold cyan]HOW TO FIX:[/bold cyan]",
|
||||
"",
|
||||
" 1. Add a Quick Start section with a runnable code block",
|
||||
" 2. Rewrite opening lines to explain what it does in plain terms",
|
||||
" 3. Ensure Invoke/Usage section matches the real CLI entry point",
|
||||
" 4. Write as if the reader has never seen AIPass before",
|
||||
"",
|
||||
"=" * 70,
|
||||
"",
|
||||
"[bold cyan]SCORING:[/bold cyan]",
|
||||
" Score = (passed_checks / 4) * 100",
|
||||
" Pass requires all 4 checks green",
|
||||
"",
|
||||
"[bold cyan]REFERENCE:[/bold cyan]",
|
||||
" [dim]See: seedgo standards pack (readme_quality)[/dim]",
|
||||
" [dim]Complements: readme standard (structural completeness)[/dim]",
|
||||
]
|
||||
|
||||
json_handler.log_operation("standard_content_queried", {"standard": "readme_quality"})
|
||||
return "\n".join(lines)
|
||||
@@ -241,6 +241,21 @@ def print_help() -> None:
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
console.print()
|
||||
console.print(" [dim]# Check a single file against all standards[/dim]")
|
||||
console.print(" [green]drone @seedgo checklist src/aipass/flow/apps/flow.py[/green]")
|
||||
console.print()
|
||||
console.print(" [dim]# Audit a specific branch[/dim]")
|
||||
console.print(" [green]drone @seedgo audit aipass @flow[/green]")
|
||||
console.print()
|
||||
console.print(" [dim]# Look up what a standard checks[/dim]")
|
||||
console.print(" [green]drone @seedgo standards_query aipass_standards cli[/green]")
|
||||
console.print()
|
||||
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
# Commands line for drone discovery
|
||||
console.print(
|
||||
"[dim]Commands: audit, standards_audit, standards_query, checklist, diagnostics,"
|
||||
|
||||
@@ -197,3 +197,66 @@ def test_collect_py_files_empty_branch(tmp_path):
|
||||
|
||||
result = _collect_py_files(tmp_path)
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests -- uppercase registry name resolves to lowercase entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_uppercase_registry_name_resolves_to_lowercase_entry(tmp_path):
|
||||
"""Branches with uppercase registry names (BACKUP, HOOKS, etc.) must
|
||||
resolve to lowercase filesystem paths for entry_file."""
|
||||
from aipass.seedgo.apps.handlers.audit.discovery import _branches_from_registry
|
||||
|
||||
# Create a mock registry with uppercase branch name
|
||||
branch_dir = tmp_path / "backup"
|
||||
branch_dir.mkdir()
|
||||
apps_dir = branch_dir / "apps"
|
||||
apps_dir.mkdir()
|
||||
(apps_dir / "backup.py").write_text("def main(): pass\n")
|
||||
|
||||
registry = {
|
||||
"branches": [
|
||||
{"name": "BACKUP", "path": str(branch_dir)},
|
||||
]
|
||||
}
|
||||
reg_file = tmp_path / "TEST_REGISTRY.json"
|
||||
import json
|
||||
|
||||
reg_file.write_text(json.dumps(registry))
|
||||
|
||||
result = _branches_from_registry(reg_file)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "BACKUP"
|
||||
assert result[0]["entry_file"].endswith("backup.py")
|
||||
assert "BACKUP.py" not in result[0]["entry_file"]
|
||||
|
||||
|
||||
def test_uppercase_registry_name_no_entry_when_only_uppercase_file(tmp_path):
|
||||
"""If a branch only has an UPPERCASE.py file (not lowercase), it should not be found."""
|
||||
from aipass.seedgo.apps.handlers.audit.discovery import _branches_from_registry
|
||||
|
||||
branch_dir = tmp_path / "backup"
|
||||
branch_dir.mkdir()
|
||||
apps_dir = branch_dir / "apps"
|
||||
apps_dir.mkdir()
|
||||
(apps_dir / "BACKUP.py").write_text("def main(): pass\n")
|
||||
|
||||
registry = {
|
||||
"branches": [
|
||||
{"name": "BACKUP", "path": str(branch_dir)},
|
||||
]
|
||||
}
|
||||
reg_file = tmp_path / "TEST_REGISTRY.json"
|
||||
import json
|
||||
|
||||
reg_file.write_text(json.dumps(registry))
|
||||
|
||||
result = _branches_from_registry(reg_file)
|
||||
# On case-insensitive filesystems (macOS/Windows), BACKUP.py might match backup.py
|
||||
# On case-sensitive (Linux), no match → empty result
|
||||
import platform
|
||||
|
||||
if platform.system() == "Linux":
|
||||
assert len(result) == 0
|
||||
|
||||
@@ -329,6 +329,22 @@ def test_debug_print_check_bypass_respected(tmp_path):
|
||||
assert result["score"] == 100, f"Bypass should yield 100: {result}"
|
||||
|
||||
|
||||
def test_debug_print_string_literal_not_flagged(tmp_path):
|
||||
"""print() mentioned inside string literals must not trigger false positives."""
|
||||
f = tmp_path / "messages.py"
|
||||
f.write_text(
|
||||
'"""Module with print() in docstring."""\n\n'
|
||||
"def check():\n"
|
||||
' msg = "use console.print() not bare print()"\n'
|
||||
" return msg\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.debug_print_check import check_module
|
||||
|
||||
result = check_module(str(f))
|
||||
assert result["score"] == 100, f"print() inside strings should not flag: {result}"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 6. deep_nesting_check
|
||||
# ===========================================================================
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_cli_ux.py
|
||||
# Description: Tests for cli_ux_check.py
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-17
|
||||
# Modified: 2026-07-17
|
||||
# =============================================
|
||||
|
||||
"""Tests for cli_ux_check — CLI UX house pattern detection."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_infrastructure(monkeypatch):
|
||||
import sys
|
||||
|
||||
mock_logger = MagicMock()
|
||||
mock_json_handler = MagicMock()
|
||||
mock_json_handler.log_operation = MagicMock(return_value=True)
|
||||
|
||||
prax_mod = MagicMock()
|
||||
prax_mod.logger = mock_logger
|
||||
monkeypatch.setitem(sys.modules, "aipass.prax", prax_mod)
|
||||
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json", json_pkg)
|
||||
json_mod = MagicMock()
|
||||
json_mod.log_operation = mock_json_handler.log_operation
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json.json_handler", json_mod)
|
||||
|
||||
bypass_pkg = MagicMock()
|
||||
bypass_ignore = MagicMock()
|
||||
bypass_ignore.get_template_ignore_patterns = MagicMock(return_value=[])
|
||||
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed as real_is_bypassed
|
||||
|
||||
bypass_utils = MagicMock()
|
||||
bypass_utils.is_bypassed = real_is_bypassed
|
||||
bypass_pkg.utils = bypass_utils
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass", bypass_pkg)
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.ignore_handler", bypass_ignore)
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.utils", bypass_utils)
|
||||
|
||||
for mod_name in ["aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check"]:
|
||||
monkeypatch.delitem(sys.modules, mod_name, raising=False)
|
||||
|
||||
|
||||
def _entry_file(tmp_path, source):
|
||||
apps_dir = tmp_path / "apps"
|
||||
apps_dir.mkdir()
|
||||
f = apps_dir / "branch.py"
|
||||
f.write_text(source)
|
||||
return str(f)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Skip / edge cases
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_init_file_skipped(tmp_path):
|
||||
apps_dir = tmp_path / "apps"
|
||||
apps_dir.mkdir()
|
||||
f = apps_dir / "__init__.py"
|
||||
f.write_text("# init\n")
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(str(f))
|
||||
assert result["passed"] is True
|
||||
assert result["score"] == 100
|
||||
|
||||
|
||||
def test_non_entry_point_skipped(tmp_path):
|
||||
handler_dir = tmp_path / "apps" / "handlers"
|
||||
handler_dir.mkdir(parents=True)
|
||||
f = handler_dir / "helper.py"
|
||||
f.write_text("def main(): pass\n")
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(str(f))
|
||||
assert result["passed"] is True
|
||||
assert result["score"] == 100
|
||||
|
||||
|
||||
def test_missing_file():
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module("/nonexistent/apps/branch.py")
|
||||
assert result["passed"] is False
|
||||
assert result["score"] == 0
|
||||
|
||||
|
||||
def test_empty_file(tmp_path):
|
||||
path = _entry_file(tmp_path, "")
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
assert result["passed"] is True
|
||||
|
||||
|
||||
def test_syntax_error(tmp_path):
|
||||
path = _entry_file(tmp_path, "def broken(:\n")
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
assert result["passed"] is False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Good entry point — all checks pass
|
||||
# ============================================================
|
||||
|
||||
GOOD_ENTRY = """
|
||||
from aipass.cli import console
|
||||
|
||||
def print_introspection():
|
||||
console.print("[bold cyan]Branch Name[/bold cyan]")
|
||||
console.print("[dim]Does something useful[/dim]")
|
||||
console.print("[dim]Run --help for more[/dim]")
|
||||
|
||||
def print_help():
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print(" drone @branch command")
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
console.print(" drone @branch do-stuff")
|
||||
|
||||
def main():
|
||||
pass
|
||||
"""
|
||||
|
||||
|
||||
def test_good_entry_passes_all(tmp_path):
|
||||
path = _entry_file(tmp_path, GOOD_ENTRY)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
assert result["passed"] is True
|
||||
assert result["score"] == 100
|
||||
names = {c["name"] for c in result["checks"] if c["passed"]}
|
||||
assert "two_tier_help" in names
|
||||
assert "rich_console" in names
|
||||
assert "title_markup" in names
|
||||
assert "purpose_line" in names
|
||||
assert "help_pointer" in names
|
||||
assert "usage_section" in names
|
||||
assert "examples_section" in names
|
||||
assert "no_internal_modules" in names
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Bad entry point — no print_introspection/print_help
|
||||
# ============================================================
|
||||
|
||||
BAD_ENTRY = """
|
||||
def main():
|
||||
print("BRANCH - 5 modules discovered")
|
||||
for m in modules:
|
||||
print(f" {m}")
|
||||
"""
|
||||
|
||||
|
||||
def test_bad_entry_fails_two_tier(tmp_path):
|
||||
path = _entry_file(tmp_path, BAD_ENTRY)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
assert result["passed"] is False
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["two_tier_help"] is False
|
||||
assert checks["rich_console"] is False
|
||||
assert checks["title_markup"] is False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Bare print in help functions
|
||||
# ============================================================
|
||||
|
||||
BARE_PRINT_ENTRY = """
|
||||
def print_introspection():
|
||||
print("[bold cyan]Title[/bold cyan]")
|
||||
print("[dim]Purpose[/dim]")
|
||||
print("Run --help for more")
|
||||
|
||||
def print_help():
|
||||
print("Usage: command")
|
||||
print("Examples: stuff")
|
||||
"""
|
||||
|
||||
|
||||
def test_bare_print_fails_rich_console(tmp_path):
|
||||
path = _entry_file(tmp_path, BARE_PRINT_ENTRY)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["two_tier_help"] is True
|
||||
assert checks["rich_console"] is False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Missing title markup
|
||||
# ============================================================
|
||||
|
||||
NO_TITLE_ENTRY = """
|
||||
from aipass.cli import console
|
||||
|
||||
def print_introspection():
|
||||
console.print("Branch Name")
|
||||
console.print("[dim]Purpose[/dim]")
|
||||
console.print("Run --help for more")
|
||||
|
||||
def print_help():
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
"""
|
||||
|
||||
|
||||
def test_missing_title_markup(tmp_path):
|
||||
path = _entry_file(tmp_path, NO_TITLE_ENTRY)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["title_markup"] is False
|
||||
assert checks["purpose_line"] is True
|
||||
assert checks["help_pointer"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Missing purpose line
|
||||
# ============================================================
|
||||
|
||||
NO_PURPOSE_ENTRY = """
|
||||
from aipass.cli import console
|
||||
|
||||
def print_introspection():
|
||||
console.print("[bold cyan]Branch Name[/bold cyan]")
|
||||
console.print("Run --help for more info")
|
||||
|
||||
def print_help():
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
"""
|
||||
|
||||
|
||||
def test_missing_purpose_line(tmp_path):
|
||||
path = _entry_file(tmp_path, NO_PURPOSE_ENTRY)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["title_markup"] is True
|
||||
assert checks["purpose_line"] is False
|
||||
assert checks["help_pointer"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Missing help pointer
|
||||
# ============================================================
|
||||
|
||||
NO_POINTER_ENTRY = """
|
||||
from aipass.cli import console
|
||||
|
||||
def print_introspection():
|
||||
console.print("[bold cyan]Branch Name[/bold cyan]")
|
||||
console.print("[dim]Does stuff[/dim]")
|
||||
|
||||
def print_help():
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
"""
|
||||
|
||||
|
||||
def test_missing_help_pointer(tmp_path):
|
||||
path = _entry_file(tmp_path, NO_POINTER_ENTRY)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["help_pointer"] is False
|
||||
assert checks["title_markup"] is True
|
||||
assert checks["purpose_line"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Missing usage section
|
||||
# ============================================================
|
||||
|
||||
NO_USAGE_ENTRY = """
|
||||
from aipass.cli import console
|
||||
|
||||
def print_introspection():
|
||||
console.print("[bold cyan]Branch[/bold cyan]")
|
||||
console.print("[dim]Purpose[/dim]")
|
||||
console.print("--help for more")
|
||||
|
||||
def print_help():
|
||||
console.print("[yellow]Commands:[/yellow]")
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
"""
|
||||
|
||||
|
||||
def test_missing_usage_section(tmp_path):
|
||||
path = _entry_file(tmp_path, NO_USAGE_ENTRY)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["usage_section"] is False
|
||||
assert checks["examples_section"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Missing examples section
|
||||
# ============================================================
|
||||
|
||||
NO_EXAMPLES_ENTRY = """
|
||||
from aipass.cli import console
|
||||
|
||||
def print_introspection():
|
||||
console.print("[bold cyan]Branch[/bold cyan]")
|
||||
console.print("[dim]Purpose[/dim]")
|
||||
console.print("--help for more")
|
||||
|
||||
def print_help():
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print(" drone @branch command")
|
||||
"""
|
||||
|
||||
|
||||
def test_missing_examples_section(tmp_path):
|
||||
path = _entry_file(tmp_path, NO_EXAMPLES_ENTRY)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["examples_section"] is False
|
||||
assert checks["usage_section"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Internal modules exposed
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_internal_modules_detected(tmp_path):
|
||||
apps_dir = tmp_path / "apps"
|
||||
apps_dir.mkdir()
|
||||
modules_dir = apps_dir / "modules"
|
||||
modules_dir.mkdir()
|
||||
(modules_dir / "doctor_wire.py").write_text("def handle_command(): pass\n")
|
||||
(modules_dir / "doctor_fix.py").write_text("def handle_command(): pass\n")
|
||||
(modules_dir / "good_module.py").write_text("def handle_command(): pass\n")
|
||||
(modules_dir / "__init__.py").write_text("")
|
||||
|
||||
source = """
|
||||
from aipass.cli import console
|
||||
|
||||
def print_introspection():
|
||||
console.print("[bold cyan]Branch[/bold cyan]")
|
||||
console.print("[dim]Purpose[/dim]")
|
||||
console.print("--help for more")
|
||||
|
||||
def print_help():
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
"""
|
||||
f = apps_dir / "branch.py"
|
||||
f.write_text(source)
|
||||
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(str(f))
|
||||
checks = {c["name"]: c for c in result["checks"]}
|
||||
assert checks["no_internal_modules"]["passed"] is False
|
||||
assert "doctor_fix.py" in checks["no_internal_modules"]["message"]
|
||||
assert "doctor_wire.py" in checks["no_internal_modules"]["message"]
|
||||
|
||||
|
||||
def test_underscore_prefixed_internal_ok(tmp_path):
|
||||
apps_dir = tmp_path / "apps"
|
||||
apps_dir.mkdir()
|
||||
modules_dir = apps_dir / "modules"
|
||||
modules_dir.mkdir()
|
||||
(modules_dir / "_doctor_wire.py").write_text("def handle_command(): pass\n")
|
||||
(modules_dir / "__init__.py").write_text("")
|
||||
|
||||
source = """
|
||||
from aipass.cli import console
|
||||
|
||||
def print_introspection():
|
||||
console.print("[bold cyan]Branch[/bold cyan]")
|
||||
console.print("[dim]Purpose[/dim]")
|
||||
console.print("--help for more")
|
||||
|
||||
def print_help():
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
"""
|
||||
f = apps_dir / "branch.py"
|
||||
f.write_text(source)
|
||||
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(str(f))
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["no_internal_modules"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Bypass
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_bypass_passes(tmp_path):
|
||||
path = _entry_file(tmp_path, "def main(): print('hello')\n")
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
bypass = [{"standard": "cli_ux", "file": Path(path).as_posix()}]
|
||||
result = check_module(path, bypass_rules=bypass)
|
||||
assert result["passed"] is True
|
||||
assert result["score"] == 100
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Score calculation
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_partial_score(tmp_path):
|
||||
source = """
|
||||
from aipass.cli import console
|
||||
|
||||
def print_introspection():
|
||||
console.print("[bold cyan]Title[/bold cyan]")
|
||||
|
||||
def print_help():
|
||||
console.print("commands go here")
|
||||
"""
|
||||
path = _entry_file(tmp_path, source)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
assert 0 < result["score"] < 100
|
||||
assert result["passed"] is False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# f-string extraction
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_fstring_title_detected(tmp_path):
|
||||
source = """
|
||||
from aipass.cli import console
|
||||
|
||||
VERSION = "1.0"
|
||||
|
||||
def print_introspection():
|
||||
console.print(f"[bold cyan]Branch v{VERSION}[/bold cyan]")
|
||||
console.print("[dim]Purpose line[/dim]")
|
||||
console.print("Run --help for info")
|
||||
|
||||
def print_help():
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
"""
|
||||
path = _entry_file(tmp_path, source)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["title_markup"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Real entry point validation
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_seedgo_entry_passes():
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
_root = Path(__file__).resolve().parents[1]
|
||||
path = str(_root / "apps" / "seedgo.py")
|
||||
result = check_module(path)
|
||||
assert result["passed"] is True, f"seedgo.py should pass cli_ux: {[c for c in result['checks'] if not c['passed']]}"
|
||||
assert result["score"] == 100
|
||||
|
||||
|
||||
def test_uppercase_registry_name_lowercase_path(tmp_path):
|
||||
"""Regression: uppercase registry name (BACKUP) with lowercase filesystem path
|
||||
must still resolve and score correctly through the checker."""
|
||||
branch_dir = tmp_path / "backup"
|
||||
branch_dir.mkdir()
|
||||
apps_dir = branch_dir / "apps"
|
||||
apps_dir.mkdir()
|
||||
|
||||
source = """
|
||||
from aipass.cli import console
|
||||
|
||||
def print_introspection():
|
||||
console.print("[bold cyan]Backup Manager[/bold cyan]")
|
||||
console.print("[dim]Manages backups for AIPass[/dim]")
|
||||
console.print("[dim]Run --help for more[/dim]")
|
||||
|
||||
def print_help():
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print(" drone @backup run")
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
console.print(" drone @backup snapshot")
|
||||
|
||||
def main():
|
||||
pass
|
||||
"""
|
||||
entry = apps_dir / "backup.py"
|
||||
entry.write_text(source)
|
||||
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.cli_ux_check import check_module
|
||||
|
||||
result = check_module(str(entry))
|
||||
assert result["passed"] is True
|
||||
assert result["score"] == 100
|
||||
@@ -0,0 +1,476 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: test_readme_quality.py
|
||||
# Description: Tests for readme_quality_check.py
|
||||
# Version: 1.0.0
|
||||
# Created: 2026-07-17
|
||||
# Modified: 2026-07-17
|
||||
# =============================================
|
||||
|
||||
"""Tests for readme_quality_check — README content quality from stranger's perspective."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_infrastructure(monkeypatch):
|
||||
import sys
|
||||
|
||||
mock_logger = MagicMock()
|
||||
mock_json_handler = MagicMock()
|
||||
mock_json_handler.log_operation = MagicMock(return_value=True)
|
||||
|
||||
prax_mod = MagicMock()
|
||||
prax_mod.logger = mock_logger
|
||||
monkeypatch.setitem(sys.modules, "aipass.prax", prax_mod)
|
||||
|
||||
json_pkg = MagicMock()
|
||||
json_pkg.json_handler = mock_json_handler
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json", json_pkg)
|
||||
json_mod = MagicMock()
|
||||
json_mod.log_operation = mock_json_handler.log_operation
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json.json_handler", json_mod)
|
||||
|
||||
bypass_pkg = MagicMock()
|
||||
bypass_ignore = MagicMock()
|
||||
bypass_ignore.get_template_ignore_patterns = MagicMock(return_value=[])
|
||||
from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed as real_is_bypassed
|
||||
|
||||
bypass_utils = MagicMock()
|
||||
bypass_utils.is_bypassed = real_is_bypassed
|
||||
bypass_pkg.utils = bypass_utils
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass", bypass_pkg)
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.ignore_handler", bypass_ignore)
|
||||
monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.utils", bypass_utils)
|
||||
|
||||
for mod_name in ["aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check"]:
|
||||
monkeypatch.delitem(sys.modules, mod_name, raising=False)
|
||||
|
||||
|
||||
def _branch_with_readme(tmp_path, readme_content, branch_name="mybranch"):
|
||||
"""Create a branch directory with apps/branch.py and README.md."""
|
||||
branch_dir = tmp_path / branch_name
|
||||
branch_dir.mkdir()
|
||||
apps_dir = branch_dir / "apps"
|
||||
apps_dir.mkdir()
|
||||
entry = apps_dir / f"{branch_name}.py"
|
||||
entry.write_text("def main(): pass\n")
|
||||
|
||||
readme = branch_dir / "README.md"
|
||||
readme.write_text(readme_content)
|
||||
|
||||
return str(entry)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Skip / edge cases
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_init_file_skipped(tmp_path):
|
||||
apps_dir = tmp_path / "apps"
|
||||
apps_dir.mkdir()
|
||||
f = apps_dir / "__init__.py"
|
||||
f.write_text("# init\n")
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(str(f))
|
||||
assert result["passed"] is True
|
||||
assert result["score"] == 100
|
||||
|
||||
|
||||
def test_no_readme(tmp_path):
|
||||
branch_dir = tmp_path / "mybranch"
|
||||
branch_dir.mkdir()
|
||||
apps_dir = branch_dir / "apps"
|
||||
apps_dir.mkdir()
|
||||
f = apps_dir / "mybranch.py"
|
||||
f.write_text("def main(): pass\n")
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(str(f))
|
||||
assert result["passed"] is False
|
||||
assert result["score"] == 0
|
||||
assert all(not c["passed"] for c in result["checks"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Good README — all checks pass
|
||||
# ============================================================
|
||||
|
||||
GOOD_README = """# MyBranch
|
||||
|
||||
A tool that processes data files and generates reports for the AIPass ecosystem.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @mybranch process data.csv
|
||||
drone @mybranch report --format html
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| process | Process input files |
|
||||
| report | Generate reports |
|
||||
"""
|
||||
|
||||
|
||||
def test_good_readme_passes_all(tmp_path):
|
||||
path = _branch_with_readme(tmp_path, GOOD_README)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
assert result["passed"] is True
|
||||
assert result["score"] == 100
|
||||
names = {c["name"] for c in result["checks"] if c["passed"]}
|
||||
assert "quick_start" in names
|
||||
assert "stranger_accessible" in names
|
||||
assert "what_description" in names
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Missing Quick Start
|
||||
# ============================================================
|
||||
|
||||
NO_QUICKSTART = """# MyBranch
|
||||
|
||||
A useful tool for data processing.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
drone @mybranch do-stuff
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def test_missing_quick_start(tmp_path):
|
||||
path = _branch_with_readme(tmp_path, NO_QUICKSTART)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["quick_start"] is False
|
||||
assert checks["what_description"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Quick Start without code block
|
||||
# ============================================================
|
||||
|
||||
QUICKSTART_NO_CODE = """# MyBranch
|
||||
|
||||
Something useful.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run the command to get started. See the docs for more info.
|
||||
"""
|
||||
|
||||
|
||||
def test_quick_start_no_code_fails(tmp_path):
|
||||
path = _branch_with_readme(tmp_path, QUICKSTART_NO_CODE)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["quick_start"] is False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Quick Start with code block passes
|
||||
# ============================================================
|
||||
|
||||
QUICKSTART_WITH_CODE = """# MyBranch
|
||||
|
||||
Something useful.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```
|
||||
mybranch run
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def test_quick_start_with_code_passes(tmp_path):
|
||||
path = _branch_with_readme(tmp_path, QUICKSTART_WITH_CODE)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["quick_start"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Getting Started variant accepted
|
||||
# ============================================================
|
||||
|
||||
GETTING_STARTED = """# MyBranch
|
||||
|
||||
Does things.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
mybranch init
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def test_getting_started_accepted(tmp_path):
|
||||
path = _branch_with_readme(tmp_path, GETTING_STARTED)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["quick_start"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Stranger accessible — too many internal names
|
||||
# ============================================================
|
||||
|
||||
TOO_MANY_INTERNALS = """# MyBranch
|
||||
|
||||
Integrates with drone for routing, devpulse for orchestration,
|
||||
and seedgo for standards compliance. Uses prax for logging.
|
||||
"""
|
||||
|
||||
|
||||
def test_too_many_internal_names_fails(tmp_path):
|
||||
path = _branch_with_readme(tmp_path, TOO_MANY_INTERNALS)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c for c in result["checks"]}
|
||||
assert checks["stranger_accessible"]["passed"] is False
|
||||
assert "drone" in checks["stranger_accessible"]["message"]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Stranger accessible — within limit
|
||||
# ============================================================
|
||||
|
||||
FEW_INTERNALS = """# MyBranch
|
||||
|
||||
A data processing tool for AIPass. Uses drone for command routing.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```
|
||||
drone @mybranch process
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def test_few_internal_names_passes(tmp_path):
|
||||
path = _branch_with_readme(tmp_path, FEW_INTERNALS)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["stranger_accessible"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Stranger accessible — aipass project name excluded
|
||||
# ============================================================
|
||||
|
||||
AIPASS_PROJECT_NAME = """# MyBranch
|
||||
|
||||
Standards compliance platform for AIPass. Audits all agents
|
||||
against code standards and reports violations.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```
|
||||
drone @mybranch audit
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def test_aipass_project_name_not_counted(tmp_path):
|
||||
path = _branch_with_readme(tmp_path, AIPASS_PROJECT_NAME)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["stranger_accessible"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Invoke match — correct branch
|
||||
# ============================================================
|
||||
|
||||
INVOKE_MATCH = """# mybranch
|
||||
|
||||
Does things.
|
||||
|
||||
## Invoke
|
||||
|
||||
```
|
||||
drone @mybranch command
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```
|
||||
drone @mybranch start
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def test_invoke_matches_branch(tmp_path):
|
||||
path = _branch_with_readme(tmp_path, INVOKE_MATCH, branch_name="mybranch")
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["invoke_match"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Invoke match — wrong branch
|
||||
# ============================================================
|
||||
|
||||
INVOKE_MISMATCH = """# mybranch
|
||||
|
||||
Does things.
|
||||
|
||||
## Invoke
|
||||
|
||||
```
|
||||
drone @otherbranch command
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```
|
||||
drone @mybranch start
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def test_invoke_wrong_branch_fails(tmp_path):
|
||||
path = _branch_with_readme(tmp_path, INVOKE_MISMATCH, branch_name="mybranch")
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["invoke_match"] is False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# No invoke section — skipped (passes)
|
||||
# ============================================================
|
||||
|
||||
NO_INVOKE = """# mybranch
|
||||
|
||||
Does things.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```
|
||||
drone @mybranch command
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def test_no_invoke_section_skipped(tmp_path):
|
||||
path = _branch_with_readme(tmp_path, NO_INVOKE, branch_name="mybranch")
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c for c in result["checks"]}
|
||||
assert checks["invoke_match"]["passed"] is True
|
||||
assert "skipped" in checks["invoke_match"]["message"].lower()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# What description — present
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_description_in_first_10_lines(tmp_path):
|
||||
readme = "# Branch\n\nThis tool processes data and generates comprehensive reports.\n"
|
||||
path = _branch_with_readme(tmp_path, readme)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["what_description"] is True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# What description — missing (only short lines)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_no_description_fails(tmp_path):
|
||||
readme = "# Branch\n\nv1.0\nTODO\n\n---\n\n## Stuff\n"
|
||||
path = _branch_with_readme(tmp_path, readme)
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
checks = {c["name"]: c["passed"] for c in result["checks"]}
|
||||
assert checks["what_description"] is False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Bypass
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_bypass_passes(tmp_path):
|
||||
path = _branch_with_readme(tmp_path, "# Bad\n\nNo content.\n")
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
bypass = [{"standard": "readme_quality", "file": Path(path).as_posix()}]
|
||||
result = check_module(path, bypass_rules=bypass)
|
||||
assert result["passed"] is True
|
||||
assert result["score"] == 100
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Real entry point validation
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_seedgo_readme_passes():
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
_root = Path(__file__).resolve().parents[1]
|
||||
path = str(_root / "apps" / "seedgo.py")
|
||||
result = check_module(path)
|
||||
assert result["passed"] is True, (
|
||||
f"seedgo README should pass readme_quality: {[c for c in result['checks'] if not c['passed']]}"
|
||||
)
|
||||
assert result["score"] == 100
|
||||
|
||||
|
||||
def test_uppercase_registry_name_lowercase_path(tmp_path):
|
||||
"""Regression: uppercase registry name (BACKUP) with lowercase filesystem path
|
||||
must resolve correctly — branch_name derived from directory, not registry."""
|
||||
readme = """# Backup
|
||||
|
||||
A tool for managing automated backups across all AIPass branches.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @backup snapshot
|
||||
drone @backup restore latest
|
||||
```
|
||||
"""
|
||||
path = _branch_with_readme(tmp_path, readme, branch_name="backup")
|
||||
from aipass.seedgo.apps.handlers.aipass_standards.readme_quality_check import check_module
|
||||
|
||||
result = check_module(path)
|
||||
assert result["passed"] is True
|
||||
assert result["score"] == 100
|
||||
@@ -9,6 +9,25 @@
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# List all available skills
|
||||
drone @skills list
|
||||
|
||||
# Get details about a skill
|
||||
drone @skills info telegram
|
||||
|
||||
# Run a skill
|
||||
drone @skills run inbox_check
|
||||
|
||||
# Create a new skill
|
||||
drone @skills create my-skill --with-handler
|
||||
|
||||
# Check if a skill's requirements are met
|
||||
drone @skills validate telegram
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
## Three Tiers
|
||||
|
||||
@@ -38,6 +38,8 @@ def print_introspection():
|
||||
console.print(" [dim]- creator.py (create_skill — scaffold new skills from templates)[/dim]")
|
||||
console.print(" [dim]- validator.py (validate_skill — check skill requirements)[/dim]")
|
||||
console.print()
|
||||
console.print("[dim]Run 'drone @skills --help' for usage information[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_command(command, args=None):
|
||||
@@ -124,6 +126,17 @@ def print_help():
|
||||
console.print(" 1. .aipass/skills/ Project-local skills")
|
||||
console.print(" 2. ~/.aipass/skills/ Global user skills")
|
||||
console.print(" 3. src/aipass/skills/lib/ Built-in skills")
|
||||
console.print()
|
||||
console.print("Examples:")
|
||||
console.print()
|
||||
console.print(" [dim]# List all available skills[/dim]")
|
||||
console.print(" [green]drone @skills list[/green]")
|
||||
console.print()
|
||||
console.print(" [dim]# Get info about a skill[/dim]")
|
||||
console.print(" [green]drone @skills info telegram[/green]")
|
||||
console.print()
|
||||
console.print(" [dim]# Create a new skill[/dim]")
|
||||
console.print(" [green]drone @skills create my-skill --with-handler[/green]")
|
||||
|
||||
|
||||
def _cmd_list():
|
||||
|
||||
@@ -20,6 +20,24 @@
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Create a new branch
|
||||
drone @spawn create /path/to/my_agent --role "Analyst" --purpose "Data reports"
|
||||
|
||||
# Preview an update before applying
|
||||
drone @spawn update @my_agent
|
||||
|
||||
# Apply the update
|
||||
drone @spawn update @my_agent --apply
|
||||
|
||||
# Check registry health
|
||||
drone @spawn sync-registry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Citizen Classes
|
||||
|
||||
Every branch belongs to a **citizen class**, which determines its template:
|
||||
|
||||
@@ -27,6 +27,11 @@ CITIZEN_CLASSES = {
|
||||
"description": "Full 3-layer branch with apps/, modules/, handlers/",
|
||||
"default": True,
|
||||
},
|
||||
"project_agent": {
|
||||
"template_dir": "project_agent",
|
||||
"description": "Project-root resident agent (manager class, collision-safe)",
|
||||
"default": False,
|
||||
},
|
||||
}
|
||||
|
||||
# The default class when none is specified
|
||||
|
||||
@@ -41,11 +41,12 @@ def build_replacements_dict(target_dir, branch_name, **overrides):
|
||||
lower = branch_name.lower().replace("-", "_")
|
||||
now = datetime.now()
|
||||
|
||||
registry_id = ""
|
||||
registry_path = find_registry(start_path=Path(target_dir).parent)
|
||||
if registry_path.exists():
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
registry_id = data.get("metadata", {}).get("id", "")
|
||||
registry_id = overrides.get("registry_id", "")
|
||||
if not registry_id:
|
||||
registry_path = find_registry(start_path=Path(target_dir).parent)
|
||||
if registry_path.exists():
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
registry_id = data.get("metadata", {}).get("id", "")
|
||||
|
||||
replacements = {
|
||||
"BRANCHNAME": upper,
|
||||
|
||||
@@ -260,6 +260,13 @@ def _spawn_agent(
|
||||
reg_path = _find_project_registry(target)
|
||||
citizen_number = get_next_citizen_number(reg_path)
|
||||
|
||||
# Read registry_id from the resolved registry for credential linkage
|
||||
resolved_registry_id = ""
|
||||
if reg_path.exists():
|
||||
reg_data = json_handler.read_json(reg_path)
|
||||
if reg_data:
|
||||
resolved_registry_id = reg_data.get("metadata", {}).get("id", "")
|
||||
|
||||
# Build placeholder replacements
|
||||
meta_tabs = _load_meta_tabs()
|
||||
replacements = build_replacements_dict(
|
||||
@@ -272,6 +279,7 @@ def _spawn_agent(
|
||||
citizen_number=citizen_number,
|
||||
citizen_class=citizen_class,
|
||||
meta_tabs=meta_tabs,
|
||||
registry_id=resolved_registry_id,
|
||||
)
|
||||
|
||||
# Step 1: Copy template with placeholder replacement in content
|
||||
|
||||
@@ -76,6 +76,17 @@ def print_help():
|
||||
warning("--dry-run", details="Preview changes without modifying files (default for update/repair)")
|
||||
warning("--trace", details="Enable verbose logging")
|
||||
console.print()
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
console.print()
|
||||
console.print(" [dim]# Create a new branch with role and purpose[/dim]")
|
||||
console.print(' [green]drone @spawn create /path/to/new_agent --role "Analyst" --purpose "Reports"[/green]')
|
||||
console.print()
|
||||
console.print(" [dim]# Preview an update before applying[/dim]")
|
||||
console.print(" [green]drone @spawn update @branch_name[/green]")
|
||||
console.print()
|
||||
console.print(" [dim]# Sync registry against filesystem[/dim]")
|
||||
console.print(" [green]drone @spawn sync-registry --fix[/green]")
|
||||
console.print()
|
||||
|
||||
|
||||
def handle_create(args):
|
||||
@@ -212,6 +223,8 @@ def print_introspection():
|
||||
console.print(" [dim]- regenerate_registry.py (handle_regenerate_registry — regenerate template registry)[/dim]")
|
||||
console.print(" [dim]- repair.py (handle_repair — project structure repair)[/dim]")
|
||||
console.print()
|
||||
console.print("[dim]Run 'drone @spawn --help' for usage information[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Template file tracking registry for ID-based updates",
|
||||
"last_updated": "2026-07-12",
|
||||
"last_updated": "2026-07-17",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# {{BRANCHNAME}} — Branch Prompt
|
||||
|
||||
*Injected every turn. Breadcrumbs only — details in README, --help, .trinity/ memories.*
|
||||
|
||||
## Identity
|
||||
|
||||
You are {{BRANCHNAME}} — resident project agent and manager of this project.
|
||||
|
||||
## What I Do
|
||||
|
||||
- Manage this project as its resident agent (citizen_class: manager)
|
||||
- Route commands to discovered modules
|
||||
- Coordinate project work and maintain project context
|
||||
|
||||
## Key Commands
|
||||
|
||||
```
|
||||
drone @{{BRANCH}} hello # Confirm agent is alive
|
||||
drone @{{BRANCH}} --help # Full help text
|
||||
drone @{{BRANCH}} # Show connected modules
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/
|
||||
├── {{BRANCH}}.py # Entry point
|
||||
├── modules/ # Business logic (auto-discovered)
|
||||
└── handlers/ # Implementation details
|
||||
```
|
||||
|
||||
## Integration
|
||||
|
||||
- **Depends on:** @prax for logging, @cli for console output
|
||||
- **Serves:** Project users — routes commands, manages context
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"document_metadata": {
|
||||
"document_type": "session_history",
|
||||
"document_name": "{{BRANCHNAME}}.LOCAL",
|
||||
"version": "2.0.0",
|
||||
"schema_version": "3.0.0",
|
||||
"created": "{{DATE}}",
|
||||
"last_updated": "{{DATE}}",
|
||||
"managed_by": "{{BRANCHNAME}}",
|
||||
"tags": [
|
||||
"session_tracking",
|
||||
"work_log",
|
||||
"{{BRANCHNAME}}"
|
||||
],
|
||||
"_usage": "Automated file — add entries within your sections, newest on top. Rollover auto-archives sessions/key_learnings (+ observations.json) to @memory; todos[] are OPERATIONAL and NEVER rolled — prune done ones by hand at /prep. Limits live in @memory's memory.config.json.",
|
||||
"status": {
|
||||
"health": "healthy",
|
||||
"last_health_check": "{{DATE}}"
|
||||
}
|
||||
},
|
||||
"todos_meta": "{{TODOS_META}}",
|
||||
"todos": [],
|
||||
"key_learnings_meta": "{{KEY_LEARNINGS_META}}",
|
||||
"key_learnings": [],
|
||||
"sessions_meta": "{{SESSIONS_META}}",
|
||||
"sessions": [
|
||||
{
|
||||
"number": 1,
|
||||
"date": "{{DATE}}",
|
||||
"summary": "Branch initialized - {{BRANCHNAME}} created by aipass new.",
|
||||
"status": "completed",
|
||||
"tags": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"document_metadata": {
|
||||
"document_type": "collaboration_patterns",
|
||||
"document_name": "{{BRANCHNAME}}.OBSERVATIONS",
|
||||
"version": "1.0.0",
|
||||
"schema_version": "3.0.0",
|
||||
"created": "{{DATE}}",
|
||||
"last_updated": "{{DATE}}",
|
||||
"managed_by": "{{BRANCHNAME}}",
|
||||
"tags": [
|
||||
"collaboration",
|
||||
"patterns",
|
||||
"{{BRANCHNAME}}"
|
||||
],
|
||||
"_usage": "Automated file — add entries within your sections, newest on top. Rollover auto-archives sessions/key_learnings (+ observations.json) to @memory; todos[] are OPERATIONAL and NEVER rolled — prune done ones by hand at /prep. Limits live in @memory's memory.config.json.",
|
||||
"status": {
|
||||
"health": "healthy",
|
||||
"last_health_check": "{{DATE}}"
|
||||
}
|
||||
},
|
||||
"guidelines": {
|
||||
"purpose": "Capture collaboration patterns and experiential insights over time",
|
||||
"chronological_order": "Newest entries at TOP, oldest at BOTTOM - NEVER reorder"
|
||||
},
|
||||
"observations_meta": "{{OBSERVATIONS_META}}",
|
||||
"observations": [
|
||||
{
|
||||
"number": 1,
|
||||
"date": "{{DATE}}",
|
||||
"note": "Branch initialized. Ready to begin capturing collaboration patterns.",
|
||||
"tags": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"document_metadata": {
|
||||
"document_type": "branch_identity",
|
||||
"document_name": "{{BRANCHNAME}}.PASSPORT",
|
||||
"version": "1.0.0",
|
||||
"schema_version": "1.0.0",
|
||||
"created": "{{DATE}}",
|
||||
"last_updated": "{{DATE}}",
|
||||
"managed_by": "{{BRANCHNAME}}",
|
||||
"tags": ["identity", "passport", "branch_profile"]
|
||||
},
|
||||
"branch_info": {
|
||||
"branch_name": "{{BRANCHNAME}}",
|
||||
"alias": "",
|
||||
"path": "{{CWD}}",
|
||||
"module": "{{MODULE}}",
|
||||
"created": "{{DATE}}",
|
||||
"git_branch": "main"
|
||||
},
|
||||
"identity": {
|
||||
"citizen_class": "manager",
|
||||
"role": "{{ROLE}}",
|
||||
"purpose": "{{PURPOSE_BRIEF}}",
|
||||
"what_i_do": [],
|
||||
"what_i_dont_do": []
|
||||
},
|
||||
"principles": [
|
||||
"Code is truth - fail honestly",
|
||||
"Memory persists - context survives",
|
||||
"Simple solutions over complex architecture"
|
||||
],
|
||||
"citizenship": {
|
||||
"registered": true,
|
||||
"registry_id": "{{REGISTRY_ID}}",
|
||||
"communications": true,
|
||||
"memory": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# {{BRANCHNAME}}
|
||||
|
||||
**Resident project agent and manager.**
|
||||
|
||||
**Module:** `{{MODULE}}` | **Class:** manager | **Created:** {{DATE}}
|
||||
|
||||
---
|
||||
|
||||
## What I Do
|
||||
|
||||
- Manage this project as its resident agent (citizen_class: manager)
|
||||
- Route commands to discovered modules
|
||||
- Coordinate project work and maintain project context
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
All commands run through `drone @{{BRANCH}} <command>`.
|
||||
|
||||
```bash
|
||||
drone @{{BRANCH}} # Show connected modules
|
||||
drone @{{BRANCH}} hello # Confirm the agent is alive
|
||||
drone @{{BRANCH}} --help # Full help text
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/
|
||||
├── {{BRANCH}}.py # Entry point — CLI routing, introspection, help
|
||||
├── modules/ # Business logic (auto-discovered)
|
||||
└── handlers/ # Implementation details
|
||||
```
|
||||
|
||||
### Three-Layer Design
|
||||
|
||||
1. **Entry point** (`{{BRANCH}}.py`) — Routes CLI commands, never imports handlers directly
|
||||
2. **Modules** (`modules/`) — Business logic coordinators, parse arguments, delegate to handlers
|
||||
3. **Handlers** (`handlers/`) — Implementation details, pure functions where possible
|
||||
|
||||
---
|
||||
|
||||
## Integration
|
||||
|
||||
### Depends On
|
||||
|
||||
- **aipass.prax** — Logging via `system_logger`
|
||||
- **aipass.cli** — Console output (header, error, warning)
|
||||
|
||||
---
|
||||
|
||||
*Created by `aipass new` via spawn project_agent template.*
|
||||
@@ -0,0 +1,5 @@
|
||||
# Handlers
|
||||
|
||||
Implementation details for `{{BRANCHNAME}}`. Pure functions where possible.
|
||||
|
||||
Handlers do the actual work — file I/O, data transforms, external calls. They never import from modules/ (no circular deps).
|
||||
@@ -0,0 +1,5 @@
|
||||
# Modules
|
||||
|
||||
Business logic for `{{BRANCHNAME}}`. One module per command.
|
||||
|
||||
Modules orchestrate work by calling handlers. They are the public API of the branch — drone routes commands here.
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
{{BRANCHNAME}} — Project Agent
|
||||
|
||||
Auto-discovery architecture:
|
||||
- Scans modules/ directory for .py files with handle_command()
|
||||
- Routes commands to discovered modules automatically
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, List
|
||||
|
||||
PROJECT_ROOT = str(Path(__file__).resolve().parent.parent)
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
os.environ.setdefault("AIPASS_BRANCH_NAME", "{{BRANCH}}")
|
||||
|
||||
from aipass.cli.apps.modules import console # noqa: E402
|
||||
from aipass.prax import logger # noqa: E402
|
||||
|
||||
MODULES_DIR = Path(__file__).parent / "modules"
|
||||
|
||||
|
||||
def _module_import_path(stem: str) -> str:
|
||||
for prefix in (
|
||||
f"aipass.{{BRANCH}}.apps.modules.{stem}",
|
||||
f"apps.modules.{stem}",
|
||||
):
|
||||
try:
|
||||
importlib.import_module(prefix)
|
||||
return prefix
|
||||
except ImportError:
|
||||
continue
|
||||
return f"apps.modules.{stem}"
|
||||
|
||||
|
||||
def discover_modules() -> List[Any]:
|
||||
"""Auto-discover modules in modules/ directory."""
|
||||
modules = []
|
||||
if not MODULES_DIR.exists():
|
||||
return modules
|
||||
for file_path in MODULES_DIR.glob("*.py"):
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
module_name = _module_import_path(file_path.stem)
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
if hasattr(module, "handle_command"):
|
||||
modules.append(module)
|
||||
except Exception as e:
|
||||
logger.error("[{{BRANCHNAME}}] Failed to load module %s: %s", module_name, e)
|
||||
return modules
|
||||
|
||||
|
||||
def route_command(command: str, args: List[str], modules: List[Any]) -> bool:
|
||||
"""Route command to appropriate module."""
|
||||
for module in modules:
|
||||
try:
|
||||
if module.handle_command(command, args):
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("[{{BRANCHNAME}}] Module %s error: %s", module.__name__, e)
|
||||
return False
|
||||
|
||||
|
||||
def print_introspection() -> None:
|
||||
"""Bare invocation — title, purpose, module list, help pointer."""
|
||||
console.print()
|
||||
console.print("[bold cyan]{{BRANCHNAME}} — Project Agent[/bold cyan]")
|
||||
console.print("[dim]{{PURPOSE_BRIEF}}[/dim]")
|
||||
console.print()
|
||||
modules = discover_modules()
|
||||
if modules:
|
||||
console.print("[yellow]Modules:[/yellow]")
|
||||
for m in modules:
|
||||
cmd = getattr(m, "COMMAND", m.__name__.split(".")[-1])
|
||||
desc = (m.__doc__ or "").strip().split("\n")[0]
|
||||
console.print(f" [green]{cmd:16}[/green] [dim]{desc}[/dim]")
|
||||
console.print()
|
||||
console.print("[dim]Run 'drone @{{BRANCH}} --help' for usage information[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Full help — usage, commands, examples."""
|
||||
console.print()
|
||||
console.print("[bold cyan]{{BRANCHNAME}} — Project Agent[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]{{PURPOSE_BRIEF}}[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Usage:[/yellow]")
|
||||
console.print(" [green]drone @{{BRANCH}}[/green] [dim]<command>[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Commands:[/yellow]")
|
||||
console.print(" [green]hello[/green] [dim]Confirm the agent is alive[/dim]")
|
||||
modules = discover_modules()
|
||||
for m in modules:
|
||||
cmd = getattr(m, "COMMAND", m.__name__.split(".")[-1])
|
||||
desc = (m.__doc__ or "").strip().split("\n")[0]
|
||||
console.print(f" [green]{cmd:10}[/green] [dim]{desc}[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Examples:[/yellow]")
|
||||
console.print()
|
||||
console.print(" [dim]# Check the agent is alive[/dim]")
|
||||
console.print(" [green]drone @{{BRANCH}} hello[/green]")
|
||||
console.print()
|
||||
console.print(" [dim]# Show connected modules[/dim]")
|
||||
console.print(" [green]drone @{{BRANCH}}[/green]")
|
||||
console.print()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
args = sys.argv[1:]
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return 0
|
||||
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_help()
|
||||
return 0
|
||||
|
||||
if args[0] == "hello":
|
||||
console.print("[cyan]{{BRANCHNAME}}[/cyan] here. Project agent, alive and ready.")
|
||||
return 0
|
||||
|
||||
command = args[0]
|
||||
remaining = args[1:] if len(args) > 1 else []
|
||||
modules = discover_modules()
|
||||
|
||||
if remaining and remaining[0] in ("--help", "-h"):
|
||||
remaining = ["--help"]
|
||||
|
||||
if route_command(command, remaining, modules):
|
||||
return 0
|
||||
|
||||
console.print(f"Unknown command: {command}")
|
||||
console.print("[dim]Run 'drone @{{BRANCH}} --help' for usage information[/dim]")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
_reconfigure = getattr(_stream, "reconfigure", None)
|
||||
if _reconfigure is not None:
|
||||
_reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("{{BRANCHNAME}} interrupted by user (KeyboardInterrupt)")
|
||||
console.print("\n\nOperation cancelled by user")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.error("{{BRANCHNAME}} entry point error: %s", e, exc_info=True)
|
||||
console.print(f"\nError: {e}")
|
||||
sys.exit(1)
|
||||
@@ -5,7 +5,16 @@
|
||||
**Purpose:** Event bus and error dispatch for AIPass. Branches fire events, registered handlers react. Medic watches logs for errors, fingerprints them, gates dispatch through an 8-stage pipeline, and notifies the responsible branch.
|
||||
**Module:** `aipass.trigger`
|
||||
**Version:** 2.2.0
|
||||
**Last Updated:** 2026-05-16
|
||||
**Last Updated:** 2026-07-17
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
drone @trigger status # Event bus + medic state
|
||||
drone @trigger errors list # View tracked errors
|
||||
drone @trigger medic status # Check dispatch gating
|
||||
drone @trigger fire error_detected branch=api error_type=ImportError
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
@@ -179,6 +179,15 @@ def print_help(modules: List[Any]):
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold cyan]EXAMPLES:[/bold cyan]")
|
||||
console.print()
|
||||
console.print(" [dim]drone @trigger fire error_detected branch=api error_type=ImportError[/dim]")
|
||||
console.print(" [dim]drone @trigger errors list[/dim]")
|
||||
console.print(" [dim]drone @trigger medic status[/dim]")
|
||||
console.print()
|
||||
console.print("─" * 70)
|
||||
console.print()
|
||||
|
||||
console.print("[bold]TIP:[/bold] For module-specific help:")
|
||||
console.print(" [dim]drone @trigger <command> --help[/dim]")
|
||||
console.print()
|
||||
|
||||
Reference in New Issue
Block a user